@tuturuuu/ui 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/biome.json +1 -1
- package/package.json +73 -71
- package/src/components/ui/accordion.tsx +1 -1
- package/src/components/ui/breadcrumb.tsx +1 -1
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +4 -0
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +239 -33
- package/src/components/ui/calendar-app/components/load-smart-scheduling-tasks.tsx +143 -0
- package/src/components/ui/calendar-app/components/priority-view.tsx +10 -3
- package/src/components/ui/calendar-app/components/tasks-sidebar.tsx +4 -116
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +67 -2
- package/src/components/ui/calendar.tsx +1 -1
- package/src/components/ui/carousel.tsx +1 -1
- package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +1 -1
- package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +1 -1
- package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +1 -1
- package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +1 -1
- package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +1 -1
- package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +1 -1
- package/src/components/ui/chat/chat-agent-details-sidebar.tsx +2 -2
- package/src/components/ui/chat/chat-agent-details-utils.test.ts +1 -1
- package/src/components/ui/chat/chat-agent-details-utils.tsx +1 -1
- package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +2 -2
- package/src/components/ui/checkbox.tsx +1 -1
- package/src/components/ui/color-picker.tsx +1 -1
- package/src/components/ui/command.tsx +1 -1
- package/src/components/ui/context-menu.tsx +5 -1
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +3 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +19 -0
- package/src/components/ui/custom/combobox.test.tsx +195 -0
- package/src/components/ui/custom/combobox.tsx +273 -156
- package/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +5 -13
- package/src/components/ui/custom/facebook-mockup/facebook-mockup.tsx +7 -1
- package/src/components/ui/custom/facebook-mockup/form.tsx +1 -1
- package/src/components/ui/custom/facebook-mockup/image-upload-field.tsx +1 -1
- package/src/components/ui/custom/facebook-mockup/preview.tsx +1 -1
- package/src/components/ui/custom/settings-dialog-shell.tsx +2 -1
- package/src/components/ui/custom/theme-toggle.tsx +1 -1
- package/src/components/ui/custom/workspace-select.tsx +8 -3
- package/src/components/ui/dialog.test.tsx +52 -0
- package/src/components/ui/dialog.tsx +6 -2
- package/src/components/ui/dropdown-menu.tsx +5 -1
- package/src/components/ui/finance/debts/debt-loan-form.tsx +12 -5
- package/src/components/ui/finance/debts/debt-loan-summary.tsx +3 -2
- package/src/components/ui/finance/debts/debts-page.test.tsx +54 -5
- package/src/components/ui/finance/debts/debts-page.tsx +15 -2
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +3 -5
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +25 -5
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +7 -2
- package/src/components/ui/finance/invoices/standard-invoice.tsx +4 -2
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -2
- package/src/components/ui/finance/invoices/utils.ts +3 -1
- package/src/components/ui/finance/transactions/form-content-dialog.tsx +3 -0
- package/src/components/ui/finance/transactions/form-types.ts +1 -0
- package/src/components/ui/finance/transactions/form.tsx +2 -0
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +2 -0
- package/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +1 -1
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +1 -4
- package/src/components/ui/finance/transactions/transactions-create-summary.tsx +3 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +4 -1
- package/src/components/ui/finance/wallets/form.test.tsx +51 -3
- package/src/components/ui/finance/wallets/form.tsx +15 -4
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +4 -2
- package/src/components/ui/finance/wallets/wallets-data-table.tsx +1 -0
- package/src/components/ui/finance/wallets/wallets-page.tsx +5 -2
- package/src/components/ui/input-otp.tsx +1 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +28 -39
- package/src/components/ui/legacy/calendar/calendar-cell.tsx +2 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +10 -6
- package/src/components/ui/legacy/calendar/calendar-header.tsx +23 -3
- package/src/components/ui/legacy/calendar/calendar-loading-skeleton.tsx +135 -0
- package/src/components/ui/legacy/calendar/calendar-matrix.tsx +175 -237
- package/src/components/ui/legacy/calendar/event-card.test.tsx +177 -0
- package/src/components/ui/legacy/calendar/event-card.tsx +220 -131
- package/src/components/ui/legacy/calendar/event-modal.tsx +17 -17
- package/src/components/ui/legacy/calendar/event-provider-display.tsx +69 -0
- package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +86 -4
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +32 -2
- package/src/components/ui/legacy/meet/create-plan-dialog.tsx +19 -10
- package/src/components/ui/navigation-menu.tsx +1 -1
- package/src/components/ui/pagination.tsx +1 -1
- package/src/components/ui/radio-group.tsx +1 -1
- package/src/components/ui/select.tsx +5 -1
- package/src/components/ui/sheet.tsx +1 -1
- package/src/components/ui/sidebar.tsx +1 -1
- package/src/components/ui/storefront/cart-popover.tsx +61 -0
- package/src/components/ui/storefront/cart-summary-parts.tsx +290 -0
- package/src/components/ui/storefront/cart-summary.tsx +93 -154
- package/src/components/ui/storefront/checkout-overlay.tsx +4 -5
- package/src/components/ui/storefront/listing-card.tsx +1 -1
- package/src/components/ui/storefront/merch-sections.tsx +70 -0
- package/src/components/ui/storefront/product-detail.tsx +1 -1
- package/src/components/ui/storefront/storefront-surface.test.tsx +106 -11
- package/src/components/ui/storefront/storefront-surface.tsx +101 -166
- package/src/components/ui/storefront/types.ts +4 -0
- package/src/components/ui/storefront/utils.ts +6 -0
- package/src/components/ui/text-editor/__tests__/extensions.test.ts +123 -0
- package/src/components/ui/text-editor/background-color-extension.ts +62 -0
- package/src/components/ui/text-editor/color-controls.tsx +284 -0
- package/src/components/ui/text-editor/editor.tsx +69 -14
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/highlight-extension.ts +22 -0
- package/src/components/ui/text-editor/tool-bar.tsx +9 -16
- package/src/components/ui/toast.tsx +1 -1
- package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +270 -0
- package/src/components/ui/tu-do/boards/board-public-link-section.tsx +231 -0
- package/src/components/ui/tu-do/boards/board-share-dialog.tsx +222 -109
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +112 -43
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +2 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-move.ts +5 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +3 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/data/kanban-deadline-query.ts +50 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/__tests__/column-reorder.test.ts +17 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/column-reorder.ts +4 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +38 -9
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-order.ts +2 -8
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-sort-key.ts +47 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +81 -30
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/__tests__/kanban-planner-island.test.tsx +380 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/kanban-planner-dialog.tsx +204 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-digest-panel.tsx +61 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-item-strip.tsx +54 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-plan-toolbar.tsx +251 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-scope-badge.tsx +27 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-section.tsx +58 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-share-dialog.tsx +238 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-target-controls.tsx +143 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-utils.ts +65 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/planner/use-kanban-planner-state.ts +234 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +397 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +103 -13
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +443 -19
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +94 -32
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +213 -106
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +26 -4
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +5 -2
- package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +3 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +3 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +191 -28
- package/src/components/ui/tu-do/boards/boardId/task-filter.test.tsx +152 -0
- package/src/components/ui/tu-do/boards/boardId/task-filter.tsx +555 -545
- package/src/components/ui/tu-do/boards/boardId/task-list.tsx +7 -0
- package/src/components/ui/tu-do/boards/share-section.tsx +100 -0
- package/src/components/ui/tu-do/drafts/draft-convert-dialog.tsx +10 -12
- package/src/components/ui/tu-do/drafts/drafts-page.tsx +33 -16
- package/src/components/ui/tu-do/initiatives/task-initiatives-client.tsx +56 -88
- package/src/components/ui/tu-do/my-tasks/my-tasks-content.tsx +26 -2
- package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +55 -8
- package/src/components/ui/tu-do/notes/note-edit-dialog.tsx +1 -4
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +25 -0
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +341 -38
- package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +253 -0
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +203 -2
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +17 -0
- package/src/components/ui/tu-do/shared/__tests__/task-legacy-route-recovery.test.tsx +16 -0
- package/src/components/ui/tu-do/shared/board-client.tsx +2 -7
- package/src/components/ui/tu-do/shared/board-config-storage.ts +7 -1
- package/src/components/ui/tu-do/shared/board-header.tsx +464 -975
- package/src/components/ui/tu-do/shared/board-layout-settings.tsx +165 -136
- package/src/components/ui/tu-do/shared/board-switcher.tsx +209 -217
- package/src/components/ui/tu-do/shared/board-views.tsx +587 -75
- package/src/components/ui/tu-do/shared/list-view.tsx +227 -1
- package/src/components/ui/tu-do/shared/recycle-bin-panel.tsx +142 -94
- package/src/components/ui/tu-do/shared/special-task-list-pins.ts +51 -0
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +28 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/field-diff-viewer.tsx +3 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.test.tsx +91 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.tsx +123 -78
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +7 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-snapshot-dialog.tsx +8 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +2 -1
- package/src/components/ui/tu-do/shared/task-legacy-route-recovery.tsx +2 -9
- package/src/declarations.d.ts +1 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +322 -2
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +446 -0
- package/src/hooks/use-calendar-sync.tsx +247 -243
- package/src/hooks/use-calendar.tsx +323 -138
- package/src/hooks/use-task-actions.ts +24 -0
- package/src/hooks/use-user-workspace-config.ts +75 -0
- package/src/hooks/use-workspace-currency.ts +8 -3
- package/src/hooks/useBoardRealtimeEventHandler.ts +11 -0
|
@@ -111,6 +111,28 @@ describe('TaskBoardServerPage', () => {
|
|
|
111
111
|
expect(mocks.getWorkspace).not.toHaveBeenCalled();
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
it('treats invalid board routes as not found instead of crashing the server render', async () => {
|
|
115
|
+
mocks.getWorkspaceTaskBoard.mockRejectedValue(
|
|
116
|
+
new mocks.InternalApiError('Invalid workspace or board ID', 400)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
await expect(renderServerPage()).rejects.toThrow('NEXT_NOT_FOUND');
|
|
120
|
+
|
|
121
|
+
expect(mocks.getWorkspace).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps unexpected board loader failures visible', async () => {
|
|
125
|
+
mocks.getWorkspaceTaskBoard.mockRejectedValue(
|
|
126
|
+
new mocks.InternalApiError('Failed to load task board', 500)
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
await expect(renderServerPage()).rejects.toThrow(
|
|
130
|
+
'Failed to load task board'
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(mocks.getWorkspace).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
114
136
|
it('uses a minimal workspace shell for board guests', async () => {
|
|
115
137
|
mocks.getWorkspaceTaskBoard.mockResolvedValue({
|
|
116
138
|
board: {
|
|
@@ -134,10 +156,10 @@ describe('TaskBoardServerPage', () => {
|
|
|
134
156
|
expect(element.props.currentUserId).toBe('user-1');
|
|
135
157
|
});
|
|
136
158
|
|
|
137
|
-
it('loads the full member workspace
|
|
159
|
+
it('loads the full member workspace from the resolved board workspace', async () => {
|
|
138
160
|
const workspace = {
|
|
139
161
|
creator_id: 'creator-1',
|
|
140
|
-
id: 'ws-
|
|
162
|
+
id: 'ws-board',
|
|
141
163
|
joined: true,
|
|
142
164
|
name: 'Member Workspace',
|
|
143
165
|
personal: false,
|
|
@@ -147,14 +169,14 @@ describe('TaskBoardServerPage', () => {
|
|
|
147
169
|
board: {
|
|
148
170
|
access_type: 'member',
|
|
149
171
|
id: BOARD_ID,
|
|
150
|
-
ws_id: 'ws-
|
|
172
|
+
ws_id: 'ws-board',
|
|
151
173
|
},
|
|
152
174
|
});
|
|
153
175
|
mocks.getWorkspace.mockResolvedValue(workspace);
|
|
154
176
|
|
|
155
177
|
const element = await renderServerPage();
|
|
156
178
|
|
|
157
|
-
expect(mocks.getWorkspace).toHaveBeenCalledWith('ws-
|
|
179
|
+
expect(mocks.getWorkspace).toHaveBeenCalledWith('ws-board', {
|
|
158
180
|
useAdmin: true,
|
|
159
181
|
});
|
|
160
182
|
expect(element.type).toBe(mocks.BoardClient);
|
|
@@ -53,7 +53,10 @@ async function getAuthorizedBoard(wsId: string, boardId: string) {
|
|
|
53
53
|
} catch (error) {
|
|
54
54
|
if (
|
|
55
55
|
error instanceof InternalApiError &&
|
|
56
|
-
(error.status ===
|
|
56
|
+
(error.status === 400 ||
|
|
57
|
+
error.status === 401 ||
|
|
58
|
+
error.status === 403 ||
|
|
59
|
+
error.status === 404)
|
|
57
60
|
) {
|
|
58
61
|
return null;
|
|
59
62
|
}
|
|
@@ -82,7 +85,7 @@ export default async function TaskBoardServerPage({
|
|
|
82
85
|
|
|
83
86
|
const isMemberBoardAccess = board.access_type === 'member';
|
|
84
87
|
const workspace = isMemberBoardAccess
|
|
85
|
-
? await getWorkspace(
|
|
88
|
+
? await getWorkspace(board.ws_id, { useAdmin: true })
|
|
86
89
|
: createBoardGuestWorkspace(board.ws_id);
|
|
87
90
|
if (!workspace) notFound();
|
|
88
91
|
|
|
@@ -21,6 +21,7 @@ interface MeasuredTaskCardProps {
|
|
|
21
21
|
optimisticUpdateInProgress?: Set<string>;
|
|
22
22
|
selectedTasks?: Set<string>;
|
|
23
23
|
bulkUpdateCustomDueDate?: (date: Date | null) => Promise<void>;
|
|
24
|
+
readOnly?: boolean;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export function MeasuredTaskCard({
|
|
@@ -41,6 +42,7 @@ export function MeasuredTaskCard({
|
|
|
41
42
|
optimisticUpdateInProgress,
|
|
42
43
|
selectedTasks,
|
|
43
44
|
bulkUpdateCustomDueDate,
|
|
45
|
+
readOnly = false,
|
|
44
46
|
}: MeasuredTaskCardProps) {
|
|
45
47
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
46
48
|
const onHeightRef = useRef(onHeight);
|
|
@@ -103,6 +105,7 @@ export function MeasuredTaskCard({
|
|
|
103
105
|
optimisticUpdateInProgress={optimisticUpdateInProgress}
|
|
104
106
|
selectedTasks={selectedTasks}
|
|
105
107
|
bulkUpdateCustomDueDate={bulkUpdateCustomDueDate}
|
|
108
|
+
readOnly={readOnly}
|
|
106
109
|
/>
|
|
107
110
|
</div>
|
|
108
111
|
);
|
|
@@ -25,6 +25,7 @@ export function areTaskCardPropsEqual(
|
|
|
25
25
|
next: TaskCardProps
|
|
26
26
|
) {
|
|
27
27
|
if (prev.isOverlay !== next.isOverlay) return false;
|
|
28
|
+
if (prev.readOnly !== next.readOnly) return false;
|
|
28
29
|
if (prev.isSelected !== next.isSelected) return false;
|
|
29
30
|
if (prev.isMultiSelectMode !== next.isMultiSelectMode) return false;
|
|
30
31
|
if (
|
|
@@ -36,6 +37,8 @@ export function areTaskCardPropsEqual(
|
|
|
36
37
|
}
|
|
37
38
|
if (prev.boardId !== next.boardId) return false;
|
|
38
39
|
if (prev.workspaceId !== next.workspaceId) return false;
|
|
40
|
+
if (prev.deadlineContext !== next.deadlineContext) return false;
|
|
41
|
+
if (prev.deadlineNow !== next.deadlineNow) return false;
|
|
39
42
|
if (prev.dragDisabled !== next.dragDisabled) return false;
|
|
40
43
|
if (prev.sortableId !== next.sortableId) return false;
|
|
41
44
|
if (prev.suppressSortableTransform !== next.suppressSortableTransform) {
|
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
import { isTaskBoardResolvedStatus } from '@tuturuuu/utils/task-list-status';
|
|
70
70
|
import { getDescriptionMetadata } from '@tuturuuu/utils/text-helper';
|
|
71
71
|
import { getTimeFormatPattern } from '@tuturuuu/utils/time-helper';
|
|
72
|
-
import { format,
|
|
72
|
+
import { format, formatDistance } from 'date-fns';
|
|
73
73
|
import { enUS, vi } from 'date-fns/locale';
|
|
74
74
|
import Link from 'next/link';
|
|
75
75
|
import { useParams } from 'next/navigation';
|
|
@@ -116,6 +116,7 @@ import {
|
|
|
116
116
|
import { formatSmartDate } from '../../../utils/taskDateUtils';
|
|
117
117
|
import { getPriorityIndicator } from '../../../utils/taskPriorityUtils';
|
|
118
118
|
import { sortByDisplayName } from '../board-text-utils';
|
|
119
|
+
import { invalidateKanbanDeadlineTasks } from '../kanban/data/kanban-deadline-query';
|
|
119
120
|
import {
|
|
120
121
|
TaskAssigneesMenu,
|
|
121
122
|
TaskBlockingMenu,
|
|
@@ -170,6 +171,128 @@ export interface TaskCardProps {
|
|
|
170
171
|
optimisticUpdateInProgress?: Set<string>;
|
|
171
172
|
selectedTasks?: Set<string>; // For bulk operations
|
|
172
173
|
bulkUpdateCustomDueDate?: (date: Date | null) => Promise<void>; // From useBulkOperations
|
|
174
|
+
deadlineContext?: 'overdue' | 'upcoming';
|
|
175
|
+
deadlineNow?: number;
|
|
176
|
+
readOnly?: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function ReadOnlyTaskCard({ task, taskList }: TaskCardProps) {
|
|
180
|
+
const t = useTranslations('common');
|
|
181
|
+
const publicBoardT = useTranslations('ws-task-boards.public');
|
|
182
|
+
const priorityT = useTranslations('ws-task-boards.dialog.priority');
|
|
183
|
+
const locale = useLocale();
|
|
184
|
+
const dateLocale = locale === 'vi' ? vi : enUS;
|
|
185
|
+
const ticketPrefix = (task as Task & { ticket_prefix?: string | null })
|
|
186
|
+
.ticket_prefix;
|
|
187
|
+
const ticketIdentifier =
|
|
188
|
+
typeof task.display_number === 'number' && task.display_number > 0
|
|
189
|
+
? getTicketIdentifier(ticketPrefix, task.display_number)
|
|
190
|
+
: null;
|
|
191
|
+
const dueDate = task.end_date
|
|
192
|
+
? format(new Date(task.end_date), 'MMM d, yyyy', { locale: dateLocale })
|
|
193
|
+
: null;
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<Card
|
|
197
|
+
className={cn(
|
|
198
|
+
'relative overflow-hidden rounded-lg border border-l-4 bg-background p-3 shadow-xs',
|
|
199
|
+
getCardColorClassesUtil(taskList, task.priority),
|
|
200
|
+
task.closed_at && 'opacity-60 saturate-50'
|
|
201
|
+
)}
|
|
202
|
+
data-task-card-id={task.id}
|
|
203
|
+
data-task-read-only="true"
|
|
204
|
+
>
|
|
205
|
+
<TaskCardIdentifierRow
|
|
206
|
+
externalSourceLabel=""
|
|
207
|
+
isMultiSelectMode={false}
|
|
208
|
+
isPersonalExternalTask={false}
|
|
209
|
+
isSelected={false}
|
|
210
|
+
selectTaskLabel={t('select_task', { name: task.name })}
|
|
211
|
+
taskListStatus={taskList?.status}
|
|
212
|
+
ticketBadgeClassName={getTicketBadgeColorClasses(
|
|
213
|
+
taskList,
|
|
214
|
+
task.priority
|
|
215
|
+
)}
|
|
216
|
+
ticketIdentifier={ticketIdentifier}
|
|
217
|
+
ticketTitle={ticketIdentifier ?? ''}
|
|
218
|
+
/>
|
|
219
|
+
|
|
220
|
+
<div className="space-y-2">
|
|
221
|
+
<h3
|
|
222
|
+
className={cn(
|
|
223
|
+
'line-clamp-2 break-words font-medium text-sm leading-5',
|
|
224
|
+
(task.completed_at || task.closed_at) &&
|
|
225
|
+
'text-muted-foreground line-through'
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
{task.name}
|
|
229
|
+
</h3>
|
|
230
|
+
|
|
231
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
232
|
+
{task.priority && (
|
|
233
|
+
<Badge variant="outline" className="h-5 px-1.5 font-normal text-xs">
|
|
234
|
+
{priorityT(task.priority)}
|
|
235
|
+
</Badge>
|
|
236
|
+
)}
|
|
237
|
+
{dueDate && (
|
|
238
|
+
<Badge
|
|
239
|
+
variant="secondary"
|
|
240
|
+
className="h-5 gap-1 px-1.5 font-normal text-xs"
|
|
241
|
+
>
|
|
242
|
+
<Calendar className="h-3 w-3" />
|
|
243
|
+
{dueDate}
|
|
244
|
+
</Badge>
|
|
245
|
+
)}
|
|
246
|
+
{typeof task.estimation_points === 'number' && (
|
|
247
|
+
<Badge variant="outline" className="h-5 px-1.5 font-normal text-xs">
|
|
248
|
+
{publicBoardT('points', { count: task.estimation_points })}
|
|
249
|
+
</Badge>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{(task.labels?.length ||
|
|
254
|
+
task.projects?.length ||
|
|
255
|
+
task.assignees?.length) && (
|
|
256
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
257
|
+
{task.labels?.map((label) => (
|
|
258
|
+
<Badge
|
|
259
|
+
key={label.id}
|
|
260
|
+
variant="outline"
|
|
261
|
+
className="h-5 gap-1 px-1.5 font-normal text-xs"
|
|
262
|
+
>
|
|
263
|
+
<span
|
|
264
|
+
aria-hidden="true"
|
|
265
|
+
className="h-2 w-2 rounded-full"
|
|
266
|
+
style={{ backgroundColor: label.color }}
|
|
267
|
+
/>
|
|
268
|
+
{label.name}
|
|
269
|
+
</Badge>
|
|
270
|
+
))}
|
|
271
|
+
{task.projects?.map((project) => (
|
|
272
|
+
<Badge
|
|
273
|
+
key={project.id}
|
|
274
|
+
variant="outline"
|
|
275
|
+
className="h-5 gap-1 px-1.5 font-normal text-xs"
|
|
276
|
+
>
|
|
277
|
+
<Box className="h-3 w-3" />
|
|
278
|
+
{project.name}
|
|
279
|
+
</Badge>
|
|
280
|
+
))}
|
|
281
|
+
{task.assignees?.map((assignee) => (
|
|
282
|
+
<Badge
|
|
283
|
+
key={assignee.id}
|
|
284
|
+
variant="secondary"
|
|
285
|
+
className="h-5 px-1.5 font-normal text-xs"
|
|
286
|
+
>
|
|
287
|
+
{assignee.display_name ||
|
|
288
|
+
(assignee.handle ? `@${assignee.handle}` : t('assignee'))}
|
|
289
|
+
</Badge>
|
|
290
|
+
))}
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
</Card>
|
|
295
|
+
);
|
|
173
296
|
}
|
|
174
297
|
|
|
175
298
|
// Memoized full TaskCard
|
|
@@ -192,6 +315,8 @@ function TaskCardInner({
|
|
|
192
315
|
optimisticUpdateInProgress,
|
|
193
316
|
selectedTasks,
|
|
194
317
|
bulkUpdateCustomDueDate,
|
|
318
|
+
deadlineContext,
|
|
319
|
+
deadlineNow,
|
|
195
320
|
}: TaskCardProps) {
|
|
196
321
|
const { wsId: rawWsId } = useParams();
|
|
197
322
|
const wsId = Array.isArray(rawWsId) ? rawWsId[0] : rawWsId;
|
|
@@ -769,7 +894,7 @@ function TaskCardInner({
|
|
|
769
894
|
opacity: isOverlay ? 1 : isOptimistic ? 0.6 : undefined,
|
|
770
895
|
};
|
|
771
896
|
|
|
772
|
-
const now = new Date();
|
|
897
|
+
const now = useMemo(() => new Date(deadlineNow ?? Date.now()), [deadlineNow]);
|
|
773
898
|
const shouldRenderDueDate = shouldShowTaskDueDate({
|
|
774
899
|
completedAt: task.completed_at,
|
|
775
900
|
closedAt: task.closed_at,
|
|
@@ -789,6 +914,18 @@ function TaskCardInner({
|
|
|
789
914
|
const isResolvedListStatus = isTaskBoardResolvedStatus(taskList?.status);
|
|
790
915
|
const startDate = task.start_date ? new Date(task.start_date) : null;
|
|
791
916
|
const endDate = task.end_date ? new Date(task.end_date) : null;
|
|
917
|
+
const upcomingDeadlineCountdown =
|
|
918
|
+
deadlineContext === 'upcoming' && endDate
|
|
919
|
+
? formatDistance(endDate, now, {
|
|
920
|
+
addSuffix: true,
|
|
921
|
+
locale: dateLocale,
|
|
922
|
+
})
|
|
923
|
+
: null;
|
|
924
|
+
const upcomingDeadlineExactDate = endDate
|
|
925
|
+
? format(endDate, `MMM dd '${t('at')}' ${timePattern}`, {
|
|
926
|
+
locale: dateLocale,
|
|
927
|
+
})
|
|
928
|
+
: null;
|
|
792
929
|
const selectionCheckboxClassName = cn(
|
|
793
930
|
getTaskCardSelectionCheckboxToneClasses(taskList?.color as SupportedColor),
|
|
794
931
|
isOverdue &&
|
|
@@ -1025,6 +1162,7 @@ function TaskCardInner({
|
|
|
1025
1162
|
)
|
|
1026
1163
|
);
|
|
1027
1164
|
|
|
1165
|
+
void invalidateKanbanDeadlineTasks(queryClient, boardId);
|
|
1028
1166
|
toast.success(tTasks('moved_to_external_tasks'));
|
|
1029
1167
|
} catch (error) {
|
|
1030
1168
|
console.error('Failed to move task to external staging:', error);
|
|
@@ -1045,6 +1183,7 @@ function TaskCardInner({
|
|
|
1045
1183
|
old?.filter((candidate) => candidate.id !== task.id)
|
|
1046
1184
|
);
|
|
1047
1185
|
|
|
1186
|
+
void invalidateKanbanDeadlineTasks(queryClient, boardId);
|
|
1048
1187
|
toast.success(tTasks('removed_from_personal_board'));
|
|
1049
1188
|
} catch (error) {
|
|
1050
1189
|
console.error('Failed to remove task from personal board:', error);
|
|
@@ -2247,30 +2386,46 @@ function TaskCardInner({
|
|
|
2247
2386
|
: 'text-muted-foreground'
|
|
2248
2387
|
)}
|
|
2249
2388
|
>
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
{isOverdue && !task.closed_at ? (
|
|
2265
|
-
<Badge className="ml-1 h-4 bg-dynamic-red px-1 font-semibold text-[9px] text-white tracking-wide">
|
|
2266
|
-
{t('overdue')}
|
|
2267
|
-
</Badge>
|
|
2389
|
+
{upcomingDeadlineCountdown && upcomingDeadlineExactDate ? (
|
|
2390
|
+
<Tooltip>
|
|
2391
|
+
<TooltipTrigger asChild>
|
|
2392
|
+
<span className="inline-flex min-w-0 items-center gap-1 truncate font-medium">
|
|
2393
|
+
<Timer className="h-2.5 w-2.5" />
|
|
2394
|
+
<span className="truncate">
|
|
2395
|
+
{upcomingDeadlineCountdown}
|
|
2396
|
+
</span>
|
|
2397
|
+
</span>
|
|
2398
|
+
</TooltipTrigger>
|
|
2399
|
+
<TooltipContent side="top" className="text-xs">
|
|
2400
|
+
{upcomingDeadlineExactDate}
|
|
2401
|
+
</TooltipContent>
|
|
2402
|
+
</Tooltip>
|
|
2268
2403
|
) : (
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2404
|
+
<>
|
|
2405
|
+
<Calendar className="h-2.5 w-2.5 shrink-0" />
|
|
2406
|
+
<span className="truncate">
|
|
2407
|
+
{t('due_at', {
|
|
2408
|
+
date: formatSmartDate(
|
|
2409
|
+
endDate,
|
|
2410
|
+
{
|
|
2411
|
+
today: t('today'),
|
|
2412
|
+
tomorrow: t('tomorrow'),
|
|
2413
|
+
yesterday: t('yesterday'),
|
|
2414
|
+
},
|
|
2415
|
+
dateLocale
|
|
2416
|
+
),
|
|
2417
|
+
})}
|
|
2418
|
+
</span>
|
|
2419
|
+
{isOverdue && !task.closed_at ? (
|
|
2420
|
+
<Badge className="ml-1 h-4 bg-dynamic-red px-1 font-semibold text-[9px] text-white tracking-wide">
|
|
2421
|
+
{t('overdue')}
|
|
2422
|
+
</Badge>
|
|
2423
|
+
) : (
|
|
2424
|
+
<span className="ml-1 hidden text-[10px] text-muted-foreground md:inline">
|
|
2425
|
+
{upcomingDeadlineExactDate}
|
|
2426
|
+
</span>
|
|
2427
|
+
)}
|
|
2428
|
+
</>
|
|
2274
2429
|
)}
|
|
2275
2430
|
</div>
|
|
2276
2431
|
)}
|
|
@@ -2293,7 +2448,7 @@ function TaskCardInner({
|
|
|
2293
2448
|
<CheckCircle2 className="h-2.5 w-2.5 shrink-0" />
|
|
2294
2449
|
<span className="truncate">
|
|
2295
2450
|
{t('completed')}{' '}
|
|
2296
|
-
{
|
|
2451
|
+
{formatDistance(new Date(task.completed_at), now, {
|
|
2297
2452
|
addSuffix: true,
|
|
2298
2453
|
locale: dateLocale,
|
|
2299
2454
|
})}
|
|
@@ -2317,7 +2472,7 @@ function TaskCardInner({
|
|
|
2317
2472
|
<CircleSlash className="h-2.5 w-2.5 shrink-0" />
|
|
2318
2473
|
<span className="truncate">
|
|
2319
2474
|
{t('closed')}{' '}
|
|
2320
|
-
{
|
|
2475
|
+
{formatDistance(new Date(task.closed_at), now, {
|
|
2321
2476
|
addSuffix: true,
|
|
2322
2477
|
locale: dateLocale,
|
|
2323
2478
|
})}
|
|
@@ -2590,4 +2745,12 @@ function TaskCardInner({
|
|
|
2590
2745
|
);
|
|
2591
2746
|
}
|
|
2592
2747
|
|
|
2593
|
-
|
|
2748
|
+
function TaskCardComponent(props: TaskCardProps) {
|
|
2749
|
+
if (props.readOnly) {
|
|
2750
|
+
return <ReadOnlyTaskCard {...props} />;
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
return <TaskCardInner {...props} />;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
export const TaskCard = React.memo(TaskCardComponent, areTaskCardPropsEqual);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import type React from 'react';
|
|
5
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import type { TaskFilters } from '../../shared/task-filter.types';
|
|
7
|
+
import { TaskFilter } from './task-filter';
|
|
8
|
+
|
|
9
|
+
vi.mock('next-intl', () => ({
|
|
10
|
+
useTranslations: () => (key: string) => key,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('@tuturuuu/internal-api/tasks', () => ({
|
|
14
|
+
listWorkspaceLabels: vi.fn(() => Promise.resolve([])),
|
|
15
|
+
listWorkspaceTaskBoards: vi.fn(() =>
|
|
16
|
+
Promise.resolve({ boards: [], count: 0 })
|
|
17
|
+
),
|
|
18
|
+
listWorkspaceTaskProjects: vi.fn(() => Promise.resolve([])),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('@tuturuuu/internal-api/workspaces', () => ({
|
|
22
|
+
listWorkspaces: vi.fn(() => Promise.resolve([])),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('@tuturuuu/ui/hooks/use-workspace-members', () => ({
|
|
26
|
+
useWorkspaceMembers: () => ({ data: [] }),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('@tuturuuu/ui/custom/combobox', () => ({
|
|
30
|
+
Combobox: ({
|
|
31
|
+
placeholder,
|
|
32
|
+
}: {
|
|
33
|
+
children?: React.ReactNode;
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
}) => (
|
|
36
|
+
<button type="button" aria-label={placeholder}>
|
|
37
|
+
{placeholder}
|
|
38
|
+
</button>
|
|
39
|
+
),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
class ResizeObserverMock {
|
|
44
|
+
observe() {}
|
|
45
|
+
unobserve() {}
|
|
46
|
+
disconnect() {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const baseFilters: TaskFilters = {
|
|
53
|
+
assignees: [],
|
|
54
|
+
dueDateRange: null,
|
|
55
|
+
estimationRange: null,
|
|
56
|
+
includeMyTasks: false,
|
|
57
|
+
includeUnassigned: false,
|
|
58
|
+
labels: [],
|
|
59
|
+
priorities: [],
|
|
60
|
+
projects: [],
|
|
61
|
+
sourceBoardIds: [],
|
|
62
|
+
sourceScope: 'all_visible',
|
|
63
|
+
sourceWorkspaceIds: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function renderTaskFilter(
|
|
67
|
+
overrides?: Partial<React.ComponentProps<typeof TaskFilter>>
|
|
68
|
+
) {
|
|
69
|
+
const queryClient = new QueryClient({
|
|
70
|
+
defaultOptions: {
|
|
71
|
+
queries: {
|
|
72
|
+
retry: false,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const onFiltersChange = vi.fn();
|
|
77
|
+
|
|
78
|
+
render(
|
|
79
|
+
<QueryClientProvider client={queryClient}>
|
|
80
|
+
<TaskFilter
|
|
81
|
+
currentUserId="user-1"
|
|
82
|
+
filters={baseFilters}
|
|
83
|
+
onFiltersChange={onFiltersChange}
|
|
84
|
+
wsId="ws-1"
|
|
85
|
+
{...overrides}
|
|
86
|
+
/>
|
|
87
|
+
</QueryClientProvider>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return { onFiltersChange };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('TaskFilter', () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
vi.clearAllMocks();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('renders compact filter sections with responsive due-date controls', () => {
|
|
99
|
+
const { onFiltersChange } = renderTaskFilter();
|
|
100
|
+
|
|
101
|
+
fireEvent.click(screen.getByRole('button', { name: 'common.filters' }));
|
|
102
|
+
|
|
103
|
+
expect(screen.getByText('common.quick_filters')).toBeInTheDocument();
|
|
104
|
+
expect(
|
|
105
|
+
screen.getAllByText('ws-tasks.filter_source_scope').length
|
|
106
|
+
).toBeGreaterThan(0);
|
|
107
|
+
expect(screen.getByText('common.people')).toBeInTheDocument();
|
|
108
|
+
expect(screen.getByText('common.details')).toBeInTheDocument();
|
|
109
|
+
expect(screen.getAllByText('common.due_date').length).toBeGreaterThan(0);
|
|
110
|
+
expect(screen.getByLabelText('common.from')).toHaveAttribute(
|
|
111
|
+
'type',
|
|
112
|
+
'date'
|
|
113
|
+
);
|
|
114
|
+
expect(screen.getByLabelText('common.to')).toHaveAttribute('type', 'date');
|
|
115
|
+
expect(screen.getByLabelText('common.clear')).toBeInTheDocument();
|
|
116
|
+
expect(
|
|
117
|
+
screen.queryByRole('grid', { name: /calendar/i })
|
|
118
|
+
).not.toBeInTheDocument();
|
|
119
|
+
|
|
120
|
+
fireEvent.change(screen.getByLabelText('common.from'), {
|
|
121
|
+
target: { value: '2026-06-22' },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(onFiltersChange).toHaveBeenCalledWith(
|
|
125
|
+
expect.objectContaining({
|
|
126
|
+
dueDateRange: expect.objectContaining({
|
|
127
|
+
from: expect.any(Date),
|
|
128
|
+
}),
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('shows active count badges for compact sections', () => {
|
|
134
|
+
renderTaskFilter({
|
|
135
|
+
filters: {
|
|
136
|
+
...baseFilters,
|
|
137
|
+
dueDateRange: { from: new Date(2026, 5, 22), to: undefined },
|
|
138
|
+
includeMyTasks: true,
|
|
139
|
+
sourceScope: 'external_current_workspace',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(
|
|
144
|
+
screen.getByRole('button', { name: 'common.filters' })
|
|
145
|
+
).toHaveTextContent('3');
|
|
146
|
+
|
|
147
|
+
fireEvent.click(screen.getByRole('button', { name: 'common.filters' }));
|
|
148
|
+
|
|
149
|
+
expect(screen.getByText('common.quick_filters')).toBeInTheDocument();
|
|
150
|
+
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(3);
|
|
151
|
+
});
|
|
152
|
+
});
|