Compare commits

..
Author SHA1 Message Date
vamsikrishnamathala 97bdad00f8 Merge branch 'preview' of github.com:makeplane/plane into fix-cycle_timezone 2025-06-09 12:57:47 +05:30
vamsikrishnamathala d7fff8e200 chore: moved helper function to utils package 2025-06-09 12:55:12 +05:30
sriram veeraghanta b3b285b1e5 chore: upgrade django version to 4.2.22 2025-06-09 12:49:26 +05:30
Prateek ShouryaandGitHub 11debee402 fix: build errors related to project member list (#7185) 2025-06-09 00:31:27 +05:30
Vamsi KrishnaandGitHub 1608e4f122 [WEB-3374]feat: added merge date display (#7141)
* feat: added merge date display

* chore: moved formatter ti utils

* chore: removed unwanted props
2025-06-08 23:47:08 +05:30
edeeee1227 [WEB-4063]chore: updated work item email template (#7044)
* chore: updated work item email template

* chore: passed dynamic value for email template

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-06-08 23:46:12 +05:30
vamsikrishnamathala 66cfb3d8f2 fix: timezone conversion for cycles date 2025-06-06 17:19:39 +05:30
22 changed files with 312 additions and 411 deletions
@@ -284,6 +284,7 @@ def send_email_notification(
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
"entity_type": "issue",
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.21
Django==4.2.22
# rest framework
djangorestframework==3.15.2
# postgres
@@ -3,7 +3,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Updates on issue</title>
<title>Updates on {{entity_type}}</title>
<style type="text/css" emogrify="no"> html { font-family: system-ui; } p, h1, h2, h3, h4, ol, ul { margin: 0; } h-full { height: 100%; } a:hover { color: #3358d4 !important; } </style>
<style> *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
@@ -37,7 +37,7 @@
{% else %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> {{summary}} <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} </span>and others. </p>
{% endif %} <!-- {% if actors_involved == 1 %} {% if data|length > 0 and comments|length == 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} to the issue. </p> {% elif data|length == 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name }} </span> added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %}. </p> {% elif data|length > 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} and added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %} on the issue. </p> {% endif %} {% else %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> There are {{ total_updates }} new updates and {{total_comments}} new comments on the issue. </p> {% endif %} --> {% for update in data %} {% if update.changes.name %} <!-- Issue title updated -->
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The issue title has been updated to {{ issue.name}} </p>
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The {{entity_type}} title has been updated to {{ issue.name}} </p>
{% endif %} <!-- Outer update Box start --> {% if data %}
<div style=" background-color: #f7f9ff; border-radius: 8px; border-style: solid; border-width: 1px; border-color: #c1d0ff; padding: 20px; margin-top: 15px; max-width: 100%; " >
<!-- Block Heading -->
@@ -224,7 +224,7 @@
{% endif %}
</div>
<a href="{{ issue_url }}" style="text-decoration: none;">
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View issue </div>
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View {{entity_type}} </div>
</a>
</div>
<!-- Footer -->
@@ -232,7 +232,7 @@
<tr>
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the issue</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
</div>
</td>
+2 -1
View File
@@ -85,15 +85,16 @@ export interface IProjectMemberLite {
export type TProjectMembership = {
member: string;
role: TUserPermissions | EUserProjectRoles;
created_at: string;
} & (
| {
id: string;
original_role: EUserProjectRoles;
created_at: string;
}
| {
id: null;
original_role: null;
created_at: null;
}
);
+109
View File
@@ -333,3 +333,112 @@ export const generateDateArray = (startDate: string | Date, endDate: string | Da
}
return dateArray;
};
/**
* @returns {string | null} formatted date in the format of yyyy-mm-dd to be used in payload
* @description Returns date in the formatted format to be used in payload
* @param {Date | string} date
* @example renderFormattedPayloadDate("Jan 01, 20224") // "2024-01-01"
*/
export const renderFormattedPayloadDate = (date: Date | string | undefined | null): string | undefined => {
// Parse the date to check if it is valid
const parsedDate = getDate(date);
// return if undefined
if (!parsedDate) return;
// Check if the parsed date is valid before formatting
if (!isValid(parsedDate)) return; // Return null for invalid dates
// Format the date in payload format (yyyy-mm-dd)
const formattedDate = format(parsedDate, "yyyy-MM-dd");
return formattedDate;
};
/**
* @returns {string} safely formatted date or fallback text
* @description Safely formats a date using renderFormattedPayloadDate and date-fns format, with fallback for invalid dates
* @param {Date | string | undefined | null} date
* @param {string} formatToken (optional) // default "MMM dd, yyyy"
* @param {string} fallback (optional) // default "Invalid date"
* @example renderSafeFormattedDate("2024-01-01") // "Jan 01, 2024"
* @example renderSafeFormattedDate(null) // "Invalid date"
* @example renderSafeFormattedDate("2024-01-01", "MM/dd/yyyy", "N/A") // "01/01/2024"
*/
export const renderSafeFormattedDate = (
date: Date | string | undefined | null,
formatToken: string = "MMM dd, yyyy",
fallback: string = "Invalid date"
): string => {
if (!date) return fallback;
// Use renderFormattedPayloadDate to get a properly formatted payload date
const payloadDate = renderFormattedPayloadDate(date);
// If renderFormattedPayloadDate returns undefined/null, return fallback
if (!payloadDate) return fallback;
try {
// Parse and format the payload date
const parsedDate = getDate(payloadDate);
if (!parsedDate || !isValid(parsedDate)) return fallback;
return format(parsedDate, formatToken);
} catch (error) {
// Return fallback if any error occurs during formatting
return fallback;
}
};
/*
* Formats merged date range display with smart formatting
* - Single date: "Jan 24, 2025"
* - Same year, same month: "Jan 24 - 28, 2025"
* - Same year, different month: "Jan 24 - Feb 6, 2025"
* - Different year: "Dec 28, 2024 - Jan 4, 2025"
*/
export const formatDateRange = (
parsedStartDate: Date | null | undefined,
parsedEndDate: Date | null | undefined
): string => {
// If no dates are provided
if (!parsedStartDate && !parsedEndDate) {
return "";
}
// If only start date is provided
if (parsedStartDate && !parsedEndDate) {
return format(parsedStartDate, "MMM dd, yyyy");
}
// If only end date is provided
if (!parsedStartDate && parsedEndDate) {
return format(parsedEndDate, "MMM dd, yyyy");
}
// If both dates are provided
if (parsedStartDate && parsedEndDate) {
const startYear = parsedStartDate.getFullYear();
const startMonth = parsedStartDate.getMonth();
const endYear = parsedEndDate.getFullYear();
const endMonth = parsedEndDate.getMonth();
// Same year, same month
if (startYear === endYear && startMonth === endMonth) {
const startDay = format(parsedStartDate, "dd");
const endDay = format(parsedEndDate, "dd");
return `${format(parsedStartDate, "MMM")} ${startDay} - ${endDay}, ${startYear}`;
}
// Same year, different month
if (startYear === endYear) {
const startFormatted = format(parsedStartDate, "MMM dd");
const endFormatted = format(parsedEndDate, "MMM dd");
return `${startFormatted} - ${endFormatted}, ${startYear}`;
}
// Different year
const startFormatted = format(parsedStartDate, "MMM dd, yyyy");
const endFormatted = format(parsedEndDate, "MMM dd, yyyy");
return `${startFormatted} - ${endFormatted}`;
}
return "";
};
-3
View File
@@ -1,3 +0,0 @@
EMAIL=
PASSWORD_BASE64=
BASE_URL=
-8
View File
@@ -1,8 +0,0 @@
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
playwright/.auth
-110
View File
@@ -1,110 +0,0 @@
# Integration Tests
This directory contains end-to-end integration tests for the application using Playwright.
## Prerequisites
- Node.js (v18 or higher)
- npm or yarn
- Playwright browsers installed
## Setup
1. Install dependencies:
```bash
npm install
# or
yarn install
```
2. Install Playwright browsers:
```bash
npx playwright install
```
3. Create a `.env` file in the `tests/integration-tests` directory with the following variables:
```env
BASE_URL=http://localhost:3000 # Your application URL
EMAIL=your-email@example.com # Your test user email
PASSWORD_BASE64=base64-encoded-password # Your test user password in base64
```
## Running Tests
### Authentication Setup
First, you need to set up the authentication state:
```bash
npx playwright test auth.setup.ts
```
This will create a `playwright/.auth/user.json` file containing the authentication state.
### Running All Tests
```bash
npx playwright test
```
### Running Specific Test Files
```bash
# Run a specific test file
npx playwright test projects.spec.ts
# Run tests in a specific browser
npx playwright test --project=chromium
```
### Running Tests in UI Mode
```bash
npx playwright test --ui
```
### Debugging Tests
```bash
# Run tests in debug mode
npx playwright test --debug
# Run a specific test in debug mode
npx playwright test projects.spec.ts --debug
```
## Test Structure
- `auth.setup.ts`: Handles user authentication and creates a persistent auth state
- `projects.spec.ts`: Contains the actual test cases for the projects functionality
## Test Reports
After running tests, you can view the HTML report:
```bash
npx playwright show-report
```
## Troubleshooting
1. If tests fail due to authentication:
- Delete the `playwright/.auth/user.json` file
- Run `npx playwright test auth.setup.ts` again
- Try running your tests
2. If you need to see what's happening during test execution:
- Use `--debug` flag
- Or run with `--headed` flag to see the browser
3. If you need to update the auth state:
- Delete the existing auth file
- Run the auth setup again
- Run your tests
## CI/CD Integration
For CI/CD environments, make sure to:
1. Set the appropriate environment variables
2. Run `npx playwright install-deps` before running tests
3. Use `--reporter=html` for test reports
-14
View File
@@ -1,14 +0,0 @@
{
"name": "integration-tests",
"version": "1.0.0",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@playwright/test": "^1.51.1",
"@types/node": "^22.13.14"
}
}
@@ -1,71 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import dotenv from "dotenv";
import path from "path";
dotenv.config({ path: path.resolve(__dirname, ".env") });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.BASE_URL || "http://localhost:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "setup",
testMatch: /auth.setup.ts$/,
},
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "./playwright/.auth/user.json",
},
dependencies: ["setup"],
testMatch: /.*\.spec\.ts$/,
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
storageState: "playwright/.auth/user.json",
},
dependencies: ["setup"],
testMatch: /.*\.spec\.ts/,
},
{
name: "webkit",
use: {
...devices["Desktop Safari"],
storageState: "playwright/.auth/user.json",
},
dependencies: ["setup"],
testMatch: /.*\.spec\.ts/,
},
],
});
@@ -1,31 +0,0 @@
import { test as setup } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('authenticate', async ({ page }) => {
const BASE_URL = process.env.BASE_URL;
const EMAIL = process.env.EMAIL;
// decode the password
const PWD = Buffer.from(process.env.PASSWORD_BASE64 || '', 'base64').toString('utf-8');
if (!BASE_URL || !EMAIL || !PWD) {
throw new Error('BASE_URL, EMAIL or PWD is not set');
}
await page.goto(BASE_URL);
await page.getByRole('textbox', { name: 'Email' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill(EMAIL);
await page.getByRole('textbox', { name: 'Email' }).press('Enter');
if (await page.getByRole('button', { name: 'Continue' }).isVisible()) {
await page.getByRole('button', { name: 'Continue' }).click();
}
await page.getByRole('textbox', { name: 'password' }).click();
await page.getByRole('textbox', { name: 'password' }).fill(PWD);
await page.keyboard.press('Enter');
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);
await page.context().storageState({ path: authFile });
});
@@ -1,20 +0,0 @@
import { test, expect } from '@playwright/test';
// This is a test file
test('basic test', async () => {
expect(true).toBeTruthy();
});
test.describe('Project tests', () => {
test.beforeEach(async ({ page }) => {
// The page should already be authenticated due to storageState
await page.goto('/');
// Wait for the page to be fully loaded
await page.waitForLoadState('networkidle');
});
test('should load projects page', async ({ page }) => {
// Basic test to verify page loads
await page.getByRole('listitem').getByText('Home').click();
});
});
-41
View File
@@ -1,41 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@playwright/test@^1.51.1":
version "1.52.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.52.0.tgz#267ec595b43a8f4fa5e444ea503689629e91a5b8"
integrity sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==
dependencies:
playwright "1.52.0"
"@types/node@^22.13.14":
version "22.15.30"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.30.tgz#3a20431783e28dd0b0326f84ab386a2ec81d921d"
integrity sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==
dependencies:
undici-types "~6.21.0"
fsevents@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
playwright-core@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.52.0.tgz#238f1f0c3edd4ebba0434ce3f4401900319a3dca"
integrity sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==
playwright@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.52.0.tgz#26cb9a63346651e1c54c8805acfd85683173d4bd"
integrity sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==
dependencies:
playwright-core "1.52.0"
optionalDependencies:
fsevents "2.3.2"
undici-types@~6.21.0:
version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
@@ -3,21 +3,12 @@
import React, { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import {
ArchiveIcon,
ArchiveRestoreIcon,
ArrowRight,
ChevronRight,
EllipsisIcon,
LinkIcon,
Trash2,
} from "lucide-react";
import { ArrowRight, ChevronRight } from "lucide-react";
// Plane Imports
import { CYCLE_STATUS, CYCLE_UPDATED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ICycle } from "@plane/types";
import { CustomMenu, setToast, TOAST_TYPE } from "@plane/ui";
import { copyUrlToClipboard } from "@plane/utils";
import { setToast, TOAST_TYPE } from "@plane/ui";
// components
import { DateRangeDropdown } from "@/components/dropdowns";
// helpers
@@ -239,6 +230,7 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
</span>
}
mergeDates
showTooltip={!!cycleDetails.start_date && !!cycleDetails.end_date} // show tooltip only if both start and end date are present
required={cycleDetails.status !== "draft"}
disabled={!isEditingAllowed || isArchived || isCompleted}
@@ -1,7 +1,6 @@
"use client";
import React, { FC, MouseEvent, useEffect, useMemo, useState } from "react";
import { format, parseISO } from "date-fns";
import { observer } from "mobx-react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
@@ -17,12 +16,12 @@ import {
import { useLocalStorage } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { ICycle, TCycleGroups } from "@plane/types";
// ui
import { Avatar, AvatarGroup, FavoriteStar, LayersIcon, Tooltip, TransferIcon, setPromiseToast } from "@plane/ui";
// components
import { CycleQuickActions, TransferIssuesModal } from "@/components/cycles";
import { DateRangeDropdown } from "@/components/dropdowns";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
import { getDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
@@ -64,7 +63,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const searchParams = useSearchParams();
const pathname = usePathname();
// store hooks
const { addCycleToFavorites, removeCycleFromFavorites, updateCycleDetails } = useCycle();
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { captureEvent } = useEventTracker();
const { allowPermissions } = useUserPermissions();
@@ -77,7 +76,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const { getUserDetails } = useMember();
// form
const { control, reset, getValues } = useForm({
const { reset } = useForm({
defaultValues,
});
@@ -230,9 +229,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
>
<div className="flex gap-1 text-xs text-custom-text-300 font-medium items-center">
<CalendarDays className="h-3 w-3 flex-shrink-0 my-auto" />
{cycleDetails.start_date && <span>{format(parseISO(cycleDetails.start_date), "MMM dd, yyyy")}</span>}
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
{cycleDetails.end_date && <span>{format(parseISO(cycleDetails.end_date), "MMM dd, yyyy")}</span>}
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
</div>
</Tooltip>
{projectUTCOffset && (
@@ -269,6 +266,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
</span>
}
mergeDates
required={cycleDetails.status !== "draft"}
disabled
hideIcon={{
+83 -27
View File
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
import { Placement } from "@popperjs/core";
import { DateRange, Matcher } from "react-day-picker";
import { usePopper } from "react-popper";
import { ArrowRight, CalendarCheck2, CalendarDays } from "lucide-react";
import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react";
import { Combobox } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
@@ -17,6 +17,7 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
import { useDropdown } from "@/hooks/use-dropdown";
// components
import { DropdownButton } from "./buttons";
import { MergedDateDisplay } from "./merged-date";
// types
import { TButtonVariants } from "./types";
@@ -30,11 +31,14 @@ type Props = {
buttonVariant: TButtonVariants;
cancelButtonText?: string;
className?: string;
clearIconClassName?: string;
disabled?: boolean;
hideIcon?: {
from?: boolean;
to?: boolean;
};
isClearable?: boolean;
mergeDates?: boolean;
minDate?: Date;
maxDate?: Date;
onSelect?: (range: DateRange | undefined) => void;
@@ -65,11 +69,14 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
buttonToDateClassName,
buttonVariant,
className,
clearIconClassName = "",
disabled = false,
hideIcon = {
from: true,
to: true,
},
isClearable = false,
mergeDates,
minDate,
maxDate,
onSelect,
@@ -118,20 +125,18 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
setIsOpen,
});
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
setDateRange({
from: value.from,
to: value.to,
});
if (referenceElement) referenceElement.blur();
};
const disabledDays: Matcher[] = [];
if (minDate) disabledDays.push({ before: minDate });
if (maxDate) disabledDays.push({ after: maxDate });
const clearDates = () => {
const clearedRange = { from: undefined, to: undefined };
setDateRange(clearedRange);
onSelect?.(clearedRange);
};
const hasDisplayedDates = dateRange.from || dateRange.to;
useEffect(() => {
setDateRange(value);
}, [value]);
@@ -158,9 +163,9 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
tooltipContent={
customTooltipContent ?? (
<>
{dateRange.from ? renderFormattedDate(dateRange.from) : "N/A"}
{" - "}
{dateRange.to ? renderFormattedDate(dateRange.to) : "N/A"}
{dateRange.from ? renderFormattedDate(dateRange.from) : ""}
{dateRange.from && dateRange.to ? " - " : ""}
{dateRange.to ? renderFormattedDate(dateRange.to) : ""}
</>
)
}
@@ -168,19 +173,70 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
variant={buttonVariant}
renderToolTipByDefault={renderByDefault}
>
<span
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonFromDateClassName)}
>
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
{dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""}
</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonToDateClassName)}
>
{!hideIcon.to && <CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
{dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""}
</span>
{mergeDates ? (
// Merged date display
<div className="flex items-center gap-1.5 w-full">
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
{dateRange.from || dateRange.to ? (
<MergedDateDisplay
startDate={dateRange.from}
endDate={dateRange.to}
className="flex-grow truncate text-xs"
/>
) : (
renderPlaceholder && (
<>
<span className="text-custom-text-400">{placeholder.from}</span>
<ArrowRight className="h-3 w-3 flex-shrink-0 text-custom-text-400" />
<span className="text-custom-text-400">{placeholder.to}</span>
</>
)
)}
{isClearable && !disabled && hasDisplayedDates && (
<X
className={cn("h-2.5 w-2.5 flex-shrink-0 cursor-pointer", clearIconClassName)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
clearDates();
}}
/>
)}
</div>
) : (
// Original separate date display
<>
<span
className={cn(
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
buttonFromDateClassName
)}
>
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
{dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""}
</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span
className={cn(
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
buttonToDateClassName
)}
>
{!hideIcon.to && <CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
{dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""}
</span>
{isClearable && !disabled && hasDisplayedDates && (
<X
className={cn("h-2.5 w-2.5 flex-shrink-0 cursor-pointer ml-1", clearIconClassName)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
clearDates();
}}
/>
)}
</>
)}
</DropdownButton>
</button>
);
+1
View File
@@ -3,6 +3,7 @@ export * from "./cycle";
export * from "./date-range";
export * from "./date";
export * from "./estimate";
export * from "./merged-date";
export * from "./module";
export * from "./priority";
export * from "./project";
@@ -0,0 +1,34 @@
import React from "react";
import { observer } from "mobx-react";
// helpers
import { formatDateRange } from "@plane/utils";
import { getDate } from "@/helpers/date-time.helper";
type Props = {
startDate: Date | string | null | undefined;
endDate: Date | string | null | undefined;
className?: string;
};
/**
* Formats merged date range display with smart formatting
* - Single date: "Jan 24, 2025"
* - Same year, same month: "Jan 24 - 28, 2025"
* - Same year, different month: "Jan 24 - Feb 6, 2025"
* - Different year: "Dec 28, 2024 - Jan 4, 2025"
*/
export const MergedDateDisplay: React.FC<Props> = observer((props) => {
const { startDate, endDate, className = "" } = props;
// Parse dates
const parsedStartDate = getDate(startDate);
const parsedEndDate = getDate(endDate);
const displayText = formatDateRange(parsedStartDate, parsedEndDate);
if (!displayText) {
return null;
}
return <span className={className}>{displayText}</span>;
});
@@ -1,11 +1,10 @@
// plane imports
import { SyntheticEvent } from "react";
import { observer } from "mobx-react";
import { CalendarClock } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IIssueDisplayProperties, TIssue } from "@plane/types";
// components
import { PriorityDropdown, MemberDropdown, StateDropdown, DateDropdown } from "@/components/dropdowns";
import { PriorityDropdown, MemberDropdown, StateDropdown, DateRangeDropdown } from "@/components/dropdowns";
// hooks
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
@@ -37,6 +36,22 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
e.preventDefault();
};
const handleStartDate = (date: Date | null) => {
if (issue.project_id) {
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
start_date: date ? renderFormattedPayloadDate(date) : null,
});
}
};
const handleTargetDate = (date: Date | null) => {
if (issue.project_id) {
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
target_date: date ? renderFormattedPayloadDate(date) : null,
});
}
};
if (!displayProperties) return <></>;
const maxDate = getDate(issue.target_date);
@@ -88,29 +103,32 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue.target_date ?? null}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
target_date: val ? renderFormattedPayloadDate(val) : null,
},
{ ...issue }
)
}
maxDate={maxDate}
placeholder={t("common.order_by.due_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-30"
{/* merged dates */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={["start_date", "due_date"]}
shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateRangeDropdown
value={{
from: getDate(issue.start_date) || undefined,
to: getDate(issue.target_date) || undefined,
}}
onSelect={(range) => {
handleStartDate(range?.from ?? null);
handleTargetDate(range?.to ?? null);
}}
hideIcon={{
from: false,
}}
isClearable
mergeDates
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
disabled={!disabled}
showTooltip
customTooltipHeading="Date Range"
renderPlaceholder={false}
/>
</div>
</WithDisplayPropertiesHOC>
@@ -5,7 +5,7 @@ import xor from "lodash/xor";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// icons
import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react";
import { Layers, Link, Paperclip } from "lucide-react";
// types
import { ISSUE_UPDATED } from "@plane/constants";
// i18n
@@ -15,13 +15,13 @@ import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"
import { Tooltip } from "@plane/ui";
// components
import {
DateDropdown,
EstimateDropdown,
PriorityDropdown,
MemberDropdown,
ModuleDropdown,
CycleDropdown,
StateDropdown,
DateRangeDropdown,
} from "@/components/dropdowns";
// constants
// helpers
@@ -265,12 +265,6 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
const minDate = getDate(issue.start_date);
minDate?.setDate(minDate.getDate());
const maxDate = getDate(issue.target_date);
maxDate?.setDate(maxDate.getDate());
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
@@ -310,40 +304,34 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
</div>
</WithDisplayPropertiesHOC>
{/* start date */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
{/* merged dates */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={["start_date", "due_date"]}
shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue.start_date ?? null}
onChange={handleStartDate}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-10"
disabled={isReadOnly}
renderByDefault={isMobile}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* target/due date */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue?.target_date ?? null}
onChange={handleTargetDate}
minDate={minDate}
placeholder={t("common.order_by.due_date")}
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
<DateRangeDropdown
value={{
from: getDate(issue.start_date) || undefined,
to: getDate(issue.target_date) || undefined,
}}
onSelect={(range) => {
handleStartDate(range?.from ?? null);
handleTargetDate(range?.to ?? null);
}}
hideIcon={{
from: false,
}}
isClearable
mergeDates
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""}
clearIconClassName="!text-custom-text-100"
optionsClassName="z-10"
disabled={isReadOnly}
renderByDefault={isMobile}
showTooltip
renderPlaceholder={false}
customTooltipHeading="Date Range"
/>
</div>
</WithDisplayPropertiesHOC>
@@ -158,6 +158,7 @@ export const ModuleListItemAction: FC<Props> = observer((props) => {
target_date: val?.to ? renderFormattedPayloadDate(val.to) : null,
});
}}
mergeDates
placeholder={{
from: t("start_date"),
to: t("end_date"),
@@ -177,7 +177,7 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
original_role: projectMember.original_role,
member: {
...userDetails,
joining_date: projectMember.created_at,
joining_date: projectMember.created_at ?? undefined,
},
created_at: projectMember.created_at,
};