@tuturuuu/ui 0.14.0 → 0.17.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 +96 -0
- package/biome.json +1 -1
- package/package.json +63 -62
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
- package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
- package/src/components/ui/custom/notification-popover-client.tsx +5 -1
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
- package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
- package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
- package/src/components/ui/custom/sidebar-context.tsx +58 -3
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
- package/src/components/ui/custom/statistic-card.tsx +104 -0
- package/src/components/ui/custom/structure.test.tsx +29 -0
- package/src/components/ui/custom/structure.tsx +103 -88
- package/src/components/ui/finance/invoices/civil-month.ts +224 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
- package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
- package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
- package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
- package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
- package/src/components/ui/finance/invoices/utils.test.ts +84 -0
- package/src/components/ui/finance/invoices/utils.ts +56 -134
- package/src/components/ui/resizable.test.ts +15 -0
- package/src/components/ui/resizable.tsx +28 -6
- package/src/components/ui/storefront/browse-panel.tsx +161 -0
- package/src/components/ui/storefront/bundle-selection-dialog.tsx +1 -1
- package/src/components/ui/storefront/cart-popover.tsx +1 -1
- package/src/components/ui/storefront/hero-panel.tsx +35 -30
- package/src/components/ui/storefront/listing-card.tsx +10 -5
- package/src/components/ui/storefront/merch-sections.tsx +7 -5
- package/src/components/ui/storefront/product-detail.tsx +79 -6
- package/src/components/ui/storefront/product-dialog.tsx +5 -1
- package/src/components/ui/storefront/storefront-surface.test.tsx +72 -0
- package/src/components/ui/storefront/storefront-surface.tsx +67 -63
- package/src/components/ui/storefront/types.ts +28 -0
- package/src/components/ui/storefront/utils.ts +4 -5
- package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +59 -1
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +37 -8
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
- package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
- package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
- package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
- package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +60 -26
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
- package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
- package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
- package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
- package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
- package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
- package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
- package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
- package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
- package/src/components/ui/tu-do/projects/types.ts +4 -1
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
- package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
- package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
- package/src/components/ui/tu-do/templates/client.tsx +97 -78
- package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
- package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
- package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
- package/src/globals.css +1 -0
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
- package/src/hooks/task-actions-personal-external.ts +12 -6
- package/src/hooks/use-notifications.ts +14 -2
- package/vitest.config.ts +4 -0
|
@@ -126,6 +126,23 @@ export function usesPersonalPlacement(task: Task) {
|
|
|
126
126
|
return isPersonalExternalOverlayTask(task);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
export function getPersonalPlacementTargetBoardId({
|
|
130
|
+
boardId,
|
|
131
|
+
columns,
|
|
132
|
+
targetListId,
|
|
133
|
+
}: {
|
|
134
|
+
boardId: string | null;
|
|
135
|
+
columns: Pick<TaskList, 'board_id' | 'id'>[];
|
|
136
|
+
targetListId: string;
|
|
137
|
+
}) {
|
|
138
|
+
const stagingBoardId = getPersonalExternalStagingBoardId(targetListId);
|
|
139
|
+
if (stagingBoardId) return stagingBoardId;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
columns.find((column) => column.id === targetListId)?.board_id ?? boardId
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
129
146
|
function getTaskDropPosition(
|
|
130
147
|
event: DragMoveEvent | DragEndEvent,
|
|
131
148
|
pointerY?: number | null,
|
|
@@ -442,8 +459,20 @@ export function useKanbanDnd({
|
|
|
442
459
|
}
|
|
443
460
|
|
|
444
461
|
const stagingBoardId = getPersonalExternalStagingBoardId(targetListId);
|
|
445
|
-
const targetBoardId =
|
|
462
|
+
const targetBoardId = getPersonalPlacementTargetBoardId({
|
|
463
|
+
boardId,
|
|
464
|
+
columns,
|
|
465
|
+
targetListId,
|
|
466
|
+
});
|
|
467
|
+
if (!targetBoardId) {
|
|
468
|
+
throw new Error('Board ID is required');
|
|
469
|
+
}
|
|
446
470
|
const isStagingTarget = stagingBoardId !== null;
|
|
471
|
+
const targetList = columns.find((column) => column.id === targetListId);
|
|
472
|
+
const terminalStatus =
|
|
473
|
+
targetList?.status === 'done' || targetList?.status === 'closed'
|
|
474
|
+
? targetList.status
|
|
475
|
+
: undefined;
|
|
447
476
|
const nextTask = {
|
|
448
477
|
...task,
|
|
449
478
|
list_id: isStagingTarget
|
|
@@ -483,6 +512,7 @@ export function useKanbanDnd({
|
|
|
483
512
|
personal_sort_key: newSortKey,
|
|
484
513
|
previous_task_id: order?.previousTaskId ?? null,
|
|
485
514
|
next_task_id: order?.nextTaskId ?? null,
|
|
515
|
+
terminal_status: terminalStatus,
|
|
486
516
|
});
|
|
487
517
|
|
|
488
518
|
if (isStagingTarget) {
|
|
@@ -540,7 +570,7 @@ export function useKanbanDnd({
|
|
|
540
570
|
throw error;
|
|
541
571
|
}
|
|
542
572
|
},
|
|
543
|
-
[boardId, queryClient]
|
|
573
|
+
[boardId, columns, queryClient]
|
|
544
574
|
);
|
|
545
575
|
|
|
546
576
|
// Use the extracted calculateSortKeyWithRetry helper
|
|
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
|
2
2
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
4
4
|
import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
5
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
|
|
7
7
|
const taskSchedulingApiMock = vi.hoisted(() => ({
|
|
8
8
|
getCurrentUserTaskSchedule: vi.fn(),
|
|
@@ -67,8 +67,8 @@ vi.mock('@tuturuuu/ui/dropdown-menu', () => ({
|
|
|
67
67
|
DropdownMenuSub: ({ children }: any) => (
|
|
68
68
|
<div data-testid="dropdown-sub">{children}</div>
|
|
69
69
|
),
|
|
70
|
-
DropdownMenuSubTrigger: ({ children }: any) => (
|
|
71
|
-
<button type="button" data-testid="dropdown-trigger">
|
|
70
|
+
DropdownMenuSubTrigger: ({ children, className }: any) => (
|
|
71
|
+
<button type="button" className={className} data-testid="dropdown-trigger">
|
|
72
72
|
{children}
|
|
73
73
|
</button>
|
|
74
74
|
),
|
|
@@ -151,6 +151,10 @@ describe('TaskDueDateMenu', () => {
|
|
|
151
151
|
const mockOnMenuItemSelect = vi.fn((_, action) => action());
|
|
152
152
|
const mockOnClose = vi.fn();
|
|
153
153
|
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
vi.useRealTimers();
|
|
156
|
+
});
|
|
157
|
+
|
|
154
158
|
it('should render due date menu', () => {
|
|
155
159
|
render(
|
|
156
160
|
<TaskDueDateMenu
|
|
@@ -200,6 +204,40 @@ describe('TaskDueDateMenu', () => {
|
|
|
200
204
|
|
|
201
205
|
expect(screen.getByText('Remove Due Date')).toBeInTheDocument();
|
|
202
206
|
});
|
|
207
|
+
|
|
208
|
+
it('should render compact due date summaries without wrapping the trigger label', () => {
|
|
209
|
+
vi.useFakeTimers();
|
|
210
|
+
vi.setSystemTime(new Date('2026-01-15T12:00:00.000Z'));
|
|
211
|
+
|
|
212
|
+
render(
|
|
213
|
+
<TaskDueDateMenu
|
|
214
|
+
endDate="2026-03-16T12:00:00.000Z"
|
|
215
|
+
isLoading={false}
|
|
216
|
+
onDueDateChange={mockOnDueDateChange}
|
|
217
|
+
onCustomDateClick={mockOnCustomDateClick}
|
|
218
|
+
onMenuItemSelect={mockOnMenuItemSelect}
|
|
219
|
+
onClose={mockOnClose}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const triggerLabel = screen.getByText('Due Date');
|
|
224
|
+
const compactDate = screen.getByText('in 2mo');
|
|
225
|
+
|
|
226
|
+
expect(screen.getByTestId('dropdown-trigger')).toHaveClass('min-w-0');
|
|
227
|
+
expect(triggerLabel).toHaveClass(
|
|
228
|
+
'min-w-0',
|
|
229
|
+
'flex-1',
|
|
230
|
+
'truncate',
|
|
231
|
+
'whitespace-nowrap'
|
|
232
|
+
);
|
|
233
|
+
expect(compactDate).toHaveClass(
|
|
234
|
+
'shrink-0',
|
|
235
|
+
'truncate',
|
|
236
|
+
'whitespace-nowrap',
|
|
237
|
+
'text-right'
|
|
238
|
+
);
|
|
239
|
+
expect(screen.queryByText('in about 2 months')).not.toBeInTheDocument();
|
|
240
|
+
});
|
|
203
241
|
});
|
|
204
242
|
|
|
205
243
|
describe('task scheduling utilities', () => {
|
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
DropdownMenuSubTrigger,
|
|
8
8
|
} from '@tuturuuu/ui/dropdown-menu';
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
differenceInCalendarDays,
|
|
11
11
|
isToday,
|
|
12
12
|
isTomorrow,
|
|
13
|
+
isValid,
|
|
13
14
|
isYesterday,
|
|
14
15
|
} from 'date-fns';
|
|
15
16
|
import { calculateDaysUntilEndOfWeek } from '../../../utils/weekDateUtils';
|
|
@@ -55,6 +56,22 @@ const calculateDaysForPreset = (
|
|
|
55
56
|
}
|
|
56
57
|
};
|
|
57
58
|
|
|
59
|
+
const formatCompactRelativeDate = (date: Date, now = new Date()) => {
|
|
60
|
+
const days = differenceInCalendarDays(date, now);
|
|
61
|
+
const absoluteDays = Math.abs(days);
|
|
62
|
+
|
|
63
|
+
const [value, unit] =
|
|
64
|
+
absoluteDays < 7
|
|
65
|
+
? [absoluteDays, 'd']
|
|
66
|
+
: absoluteDays < 30
|
|
67
|
+
? [Math.max(1, Math.round(absoluteDays / 7)), 'wk']
|
|
68
|
+
: absoluteDays < 365
|
|
69
|
+
? [Math.max(1, Math.round(absoluteDays / 30)), 'mo']
|
|
70
|
+
: [Math.max(1, Math.round(absoluteDays / 365)), 'y'];
|
|
71
|
+
|
|
72
|
+
return days > 0 ? `in ${value}${unit}` : `${value}${unit} ago`;
|
|
73
|
+
};
|
|
74
|
+
|
|
58
75
|
export function TaskDueDateMenu({
|
|
59
76
|
endDate,
|
|
60
77
|
isLoading,
|
|
@@ -82,7 +99,7 @@ export function TaskDueDateMenu({
|
|
|
82
99
|
if (isToday(date)) return t.today;
|
|
83
100
|
if (isTomorrow(date)) return t.tomorrow;
|
|
84
101
|
if (isYesterday(date)) return t.yesterday;
|
|
85
|
-
return
|
|
102
|
+
return formatCompactRelativeDate(date);
|
|
86
103
|
};
|
|
87
104
|
|
|
88
105
|
const dueDateOptionsTranslated = [
|
|
@@ -104,19 +121,23 @@ export function TaskDueDateMenu({
|
|
|
104
121
|
},
|
|
105
122
|
];
|
|
106
123
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
const parsedEndDate = endDate ? new Date(endDate) : null;
|
|
125
|
+
const currentDateDisplay =
|
|
126
|
+
parsedEndDate && isValid(parsedEndDate)
|
|
127
|
+
? formatSmartDateTranslated(parsedEndDate)
|
|
128
|
+
: t.none;
|
|
110
129
|
|
|
111
130
|
return (
|
|
112
131
|
<DropdownMenuSub>
|
|
113
|
-
<DropdownMenuSubTrigger>
|
|
114
|
-
<div className="h-4 w-4">
|
|
132
|
+
<DropdownMenuSubTrigger className="min-w-0">
|
|
133
|
+
<div className="h-4 w-4 shrink-0">
|
|
115
134
|
<Calendar className="h-4 w-4 text-dynamic-purple" />
|
|
116
135
|
</div>
|
|
117
|
-
<div className="flex w-
|
|
118
|
-
<span>
|
|
119
|
-
|
|
136
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
137
|
+
<span className="min-w-0 flex-1 truncate whitespace-nowrap">
|
|
138
|
+
{t.dueDate}
|
|
139
|
+
</span>
|
|
140
|
+
<span className="max-w-20 shrink-0 truncate whitespace-nowrap text-right text-muted-foreground text-xs">
|
|
120
141
|
{currentDateDisplay}
|
|
121
142
|
</span>
|
|
122
143
|
</div>
|
|
@@ -104,13 +104,15 @@ export function TaskPriorityMenu({
|
|
|
104
104
|
|
|
105
105
|
return (
|
|
106
106
|
<DropdownMenuSub>
|
|
107
|
-
<DropdownMenuSubTrigger>
|
|
108
|
-
<div className="h-4 w-4">
|
|
107
|
+
<DropdownMenuSubTrigger className="min-w-0">
|
|
108
|
+
<div className="h-4 w-4 shrink-0">
|
|
109
109
|
<Flag className="h-4 w-4 text-dynamic-orange" />
|
|
110
110
|
</div>
|
|
111
|
-
<div className="flex w-
|
|
112
|
-
<span>
|
|
113
|
-
|
|
111
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
112
|
+
<span className="min-w-0 flex-1 truncate whitespace-nowrap">
|
|
113
|
+
{t.priority}
|
|
114
|
+
</span>
|
|
115
|
+
<span className="max-w-20 shrink-0 truncate whitespace-nowrap text-right text-muted-foreground text-xs">
|
|
114
116
|
{currentPriority
|
|
115
117
|
? priorityLabelTranslated[currentPriority]
|
|
116
118
|
: t.none}
|
|
@@ -296,12 +296,14 @@ export function TaskSchedulingMenu({
|
|
|
296
296
|
|
|
297
297
|
return (
|
|
298
298
|
<DropdownMenuSub open={open} onOpenChange={setOpen}>
|
|
299
|
-
<DropdownMenuSubTrigger>
|
|
300
|
-
<CalendarClock className="h-4 w-4 text-dynamic-amber" />
|
|
301
|
-
<div className="flex w-
|
|
302
|
-
<span>
|
|
299
|
+
<DropdownMenuSubTrigger className="min-w-0">
|
|
300
|
+
<CalendarClock className="h-4 w-4 shrink-0 text-dynamic-amber" />
|
|
301
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
302
|
+
<span className="min-w-0 flex-1 truncate whitespace-nowrap">
|
|
303
|
+
{t.schedule}
|
|
304
|
+
</span>
|
|
303
305
|
{formattedDuration && (
|
|
304
|
-
<span className="
|
|
306
|
+
<span className="max-w-20 shrink-0 truncate whitespace-nowrap text-right text-muted-foreground text-xs">
|
|
305
307
|
{formattedDuration}
|
|
306
308
|
</span>
|
|
307
309
|
)}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { shouldRenderTaskCardQuickArchive } from './task-card-archive-visibility';
|
|
3
|
+
|
|
4
|
+
describe('shouldRenderTaskCardQuickArchive', () => {
|
|
5
|
+
it('shows quick archive for document lists when a closed list exists', () => {
|
|
6
|
+
expect(
|
|
7
|
+
shouldRenderTaskCardQuickArchive({
|
|
8
|
+
hasTargetClosedList: true,
|
|
9
|
+
isOverlay: false,
|
|
10
|
+
taskListStatus: 'documents',
|
|
11
|
+
})
|
|
12
|
+
).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('keeps quick archive hidden for document lists without a closed list', () => {
|
|
16
|
+
expect(
|
|
17
|
+
shouldRenderTaskCardQuickArchive({
|
|
18
|
+
hasTargetClosedList: false,
|
|
19
|
+
isOverlay: false,
|
|
20
|
+
taskListStatus: 'documents',
|
|
21
|
+
})
|
|
22
|
+
).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('keeps non-done workflow lists unchanged', () => {
|
|
26
|
+
expect(
|
|
27
|
+
shouldRenderTaskCardQuickArchive({
|
|
28
|
+
hasTargetClosedList: true,
|
|
29
|
+
isOverlay: false,
|
|
30
|
+
taskListStatus: 'active',
|
|
31
|
+
})
|
|
32
|
+
).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('hides quick archive while rendering a drag overlay', () => {
|
|
36
|
+
expect(
|
|
37
|
+
shouldRenderTaskCardQuickArchive({
|
|
38
|
+
hasTargetClosedList: true,
|
|
39
|
+
isOverlay: true,
|
|
40
|
+
taskListStatus: 'documents',
|
|
41
|
+
})
|
|
42
|
+
).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface TaskCardQuickArchiveVisibilityOptions {
|
|
2
|
+
hasTargetClosedList: boolean;
|
|
3
|
+
isOverlay: boolean;
|
|
4
|
+
taskListStatus?: string | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function shouldRenderTaskCardQuickArchive({
|
|
8
|
+
hasTargetClosedList,
|
|
9
|
+
isOverlay,
|
|
10
|
+
taskListStatus,
|
|
11
|
+
}: TaskCardQuickArchiveVisibilityOptions) {
|
|
12
|
+
return (
|
|
13
|
+
!isOverlay &&
|
|
14
|
+
hasTargetClosedList &&
|
|
15
|
+
(taskListStatus === 'done' || taskListStatus === 'documents')
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -109,6 +109,7 @@ export function getTaskCardHydratingOpenOptions({
|
|
|
109
109
|
}: TaskCardOpenOptionsInput) {
|
|
110
110
|
const sourceWorkspaceId = task.source_workspace_id;
|
|
111
111
|
const sourceBoardId = task.source_board_id;
|
|
112
|
+
const isExternalSnapshot = isExternalTaskSnapshot(task);
|
|
112
113
|
const initialSharedContext = buildInitialSourceContext(
|
|
113
114
|
task,
|
|
114
115
|
sourceWorkspaceId ?? undefined,
|
|
@@ -137,5 +138,11 @@ export function getTaskCardHydratingOpenOptions({
|
|
|
137
138
|
assigneeMemberSource:
|
|
138
139
|
assigneeMemberSource ?? (sourceWorkspaceId ? 'board' : undefined),
|
|
139
140
|
initialSharedContext,
|
|
141
|
+
...(isExternalSnapshot
|
|
142
|
+
? {
|
|
143
|
+
visibleBoardId: boardId,
|
|
144
|
+
visibleTaskSnapshot: task,
|
|
145
|
+
}
|
|
146
|
+
: {}),
|
|
140
147
|
};
|
|
141
148
|
}
|
|
@@ -143,6 +143,7 @@ import { TaskNewLabelDialog } from '../task-dialogs/TaskNewLabelDialog';
|
|
|
143
143
|
import { TaskNewProjectDialog } from '../task-dialogs/TaskNewProjectDialog';
|
|
144
144
|
import { getTaskCardParentBadgeState } from '../task-parent-badge-state';
|
|
145
145
|
import { TaskCardCheckbox } from './TaskCardCheckbox';
|
|
146
|
+
import { shouldRenderTaskCardQuickArchive } from './task-card-archive-visibility';
|
|
146
147
|
import {
|
|
147
148
|
getTaskCardSelectionCheckboxToneClasses,
|
|
148
149
|
TASK_CARD_OVERDUE_CHECKBOX_TONE_CLASSES,
|
|
@@ -780,8 +781,12 @@ function TaskCardInner({
|
|
|
780
781
|
|
|
781
782
|
// Find the first list with 'done' or 'closed' status
|
|
782
783
|
const getTargetCompletionList = () => {
|
|
783
|
-
const doneList = availableLists.find(
|
|
784
|
-
|
|
784
|
+
const doneList = availableLists.find(
|
|
785
|
+
(list) => list.status === 'done' && !list.deleted
|
|
786
|
+
);
|
|
787
|
+
const closedList = availableLists.find(
|
|
788
|
+
(list) => list.status === 'closed' && !list.deleted
|
|
789
|
+
);
|
|
785
790
|
return doneList || closedList || null;
|
|
786
791
|
};
|
|
787
792
|
|
|
@@ -874,7 +879,11 @@ function TaskCardInner({
|
|
|
874
879
|
|
|
875
880
|
// Find specifically the closed list
|
|
876
881
|
const getTargetClosedList = () => {
|
|
877
|
-
return
|
|
882
|
+
return (
|
|
883
|
+
availableLists.find(
|
|
884
|
+
(list) => list.status === 'closed' && !list.deleted
|
|
885
|
+
) || null
|
|
886
|
+
);
|
|
878
887
|
};
|
|
879
888
|
|
|
880
889
|
const targetCompletionList = getTargetCompletionList();
|
|
@@ -883,8 +892,15 @@ function TaskCardInner({
|
|
|
883
892
|
targetCompletionList && targetCompletionList.id !== task.list_id;
|
|
884
893
|
const canMoveToClose =
|
|
885
894
|
targetClosedList && targetClosedList.id !== task.list_id;
|
|
886
|
-
const
|
|
887
|
-
|
|
895
|
+
const isDocumentList = taskList?.status === 'documents';
|
|
896
|
+
const canQuickArchive = shouldRenderTaskCardQuickArchive({
|
|
897
|
+
hasTargetClosedList: Boolean(canMoveToClose),
|
|
898
|
+
isOverlay: Boolean(isOverlay),
|
|
899
|
+
taskListStatus: taskList?.status,
|
|
900
|
+
});
|
|
901
|
+
const canShowCloseAction =
|
|
902
|
+
Boolean(canMoveToClose) &&
|
|
903
|
+
(isDocumentList || targetClosedList?.id !== targetCompletionList?.id);
|
|
888
904
|
const quickArchiveToneClass = taskList?.color
|
|
889
905
|
? DESTINATION_TONE_COLORS[taskList.color]
|
|
890
906
|
: DESTINATION_TONE_COLORS.GRAY;
|
|
@@ -2035,7 +2051,7 @@ function TaskCardInner({
|
|
|
2035
2051
|
e.stopPropagation(); // Prevent triggering task card click
|
|
2036
2052
|
}}
|
|
2037
2053
|
>
|
|
2038
|
-
{
|
|
2054
|
+
{!isDocumentList && (
|
|
2039
2055
|
<DropdownMenuItem
|
|
2040
2056
|
className="cursor-pointer"
|
|
2041
2057
|
disabled={isLoading}
|
|
@@ -2051,11 +2067,9 @@ function TaskCardInner({
|
|
|
2051
2067
|
</Link>
|
|
2052
2068
|
</DropdownMenuItem>
|
|
2053
2069
|
)}
|
|
2054
|
-
{
|
|
2055
|
-
<DropdownMenuSeparator />
|
|
2056
|
-
)}
|
|
2070
|
+
{!isDocumentList && <DropdownMenuSeparator />}
|
|
2057
2071
|
{/* Quick Completion Action */}
|
|
2058
|
-
{
|
|
2072
|
+
{!isDocumentList && canMoveToCompletion && (
|
|
2059
2073
|
<DropdownMenuItem
|
|
2060
2074
|
onSelect={(e) =>
|
|
2061
2075
|
handleMenuItemSelect(
|
|
@@ -2074,28 +2088,28 @@ function TaskCardInner({
|
|
|
2074
2088
|
)}
|
|
2075
2089
|
|
|
2076
2090
|
{/* Mark as Closed Action - Only show if closed list exists and is different from the generic completion */}
|
|
2077
|
-
{
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2091
|
+
{canShowCloseAction && (
|
|
2092
|
+
<DropdownMenuItem
|
|
2093
|
+
onSelect={(e) =>
|
|
2094
|
+
handleMenuItemSelect(
|
|
2095
|
+
e as unknown as Event,
|
|
2096
|
+
handleMoveToClose
|
|
2097
|
+
)
|
|
2098
|
+
}
|
|
2099
|
+
className="cursor-pointer"
|
|
2100
|
+
disabled={isLoading}
|
|
2101
|
+
>
|
|
2102
|
+
{isDocumentList ? (
|
|
2103
|
+
<Archive className="h-4 w-4 text-dynamic-purple" />
|
|
2104
|
+
) : (
|
|
2090
2105
|
<CircleSlash className="h-4 w-4 text-dynamic-purple" />
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2106
|
+
)}
|
|
2107
|
+
{isDocumentList ? t('archive') : t('mark_as_closed')}
|
|
2108
|
+
</DropdownMenuItem>
|
|
2109
|
+
)}
|
|
2094
2110
|
|
|
2095
|
-
{
|
|
2096
|
-
|
|
2097
|
-
<DropdownMenuSeparator />
|
|
2098
|
-
)}
|
|
2111
|
+
{((!isDocumentList && canMoveToCompletion) ||
|
|
2112
|
+
canShowCloseAction) && <DropdownMenuSeparator />}
|
|
2099
2113
|
|
|
2100
2114
|
{/* Priority Menu */}
|
|
2101
2115
|
<TaskPriorityMenu
|
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
mockT,
|
|
16
16
|
mockToastError,
|
|
17
17
|
mockUpdateWorkspaceTask,
|
|
18
|
+
mockUpsertCurrentUserTaskPersonalPlacement,
|
|
18
19
|
} = vi.hoisted(() => ({
|
|
19
20
|
mockAddWorkspaceTaskLabel: vi.fn(),
|
|
20
21
|
mockDeleteWorkspaceTask: vi.fn(),
|
|
@@ -26,6 +27,7 @@ const {
|
|
|
26
27
|
mockT: vi.fn((key: string) => key),
|
|
27
28
|
mockToastError: vi.fn(),
|
|
28
29
|
mockUpdateWorkspaceTask: vi.fn(),
|
|
30
|
+
mockUpsertCurrentUserTaskPersonalPlacement: vi.fn(),
|
|
29
31
|
}));
|
|
30
32
|
|
|
31
33
|
vi.mock('@tanstack/react-query', () => ({
|
|
@@ -45,6 +47,8 @@ vi.mock('@tuturuuu/internal-api', () => ({
|
|
|
45
47
|
listWorkspaceTaskLists: mockListWorkspaceTaskLists,
|
|
46
48
|
removeWorkspaceTaskLabel: mockRemoveWorkspaceTaskLabel,
|
|
47
49
|
updateWorkspaceTask: mockUpdateWorkspaceTask,
|
|
50
|
+
upsertCurrentUserTaskPersonalPlacement:
|
|
51
|
+
mockUpsertCurrentUserTaskPersonalPlacement,
|
|
48
52
|
}));
|
|
49
53
|
|
|
50
54
|
vi.mock('next-intl', () => ({
|
|
@@ -106,6 +110,9 @@ describe('useTaskContextActions', () => {
|
|
|
106
110
|
});
|
|
107
111
|
mockRemoveWorkspaceTaskLabel.mockResolvedValue({ success: true });
|
|
108
112
|
mockUpdateWorkspaceTask.mockResolvedValue({ task: null });
|
|
113
|
+
mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValue({
|
|
114
|
+
task: { id: 'task-123' },
|
|
115
|
+
});
|
|
109
116
|
});
|
|
110
117
|
|
|
111
118
|
it('handleDoneWithMyPart sends PUT with personally_unassigned=true', async () => {
|
|
@@ -230,6 +237,53 @@ describe('useTaskContextActions', () => {
|
|
|
230
237
|
);
|
|
231
238
|
});
|
|
232
239
|
|
|
240
|
+
it('handleComplete uses terminal personal placement for personal external tasks', async () => {
|
|
241
|
+
const taskWithPersonalPlacement = {
|
|
242
|
+
...mockTask,
|
|
243
|
+
overrides: {
|
|
244
|
+
task_id: 'task-123',
|
|
245
|
+
user_id: 'user-1',
|
|
246
|
+
self_managed: false,
|
|
247
|
+
completed_at: null,
|
|
248
|
+
priority_override: null,
|
|
249
|
+
due_date_override: null,
|
|
250
|
+
estimation_override: null,
|
|
251
|
+
personally_unassigned: false,
|
|
252
|
+
notes: null,
|
|
253
|
+
personal_board_id: 'personal-board',
|
|
254
|
+
personal_list_id: 'personal-list',
|
|
255
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
256
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
257
|
+
},
|
|
258
|
+
} as TaskWithRelations;
|
|
259
|
+
|
|
260
|
+
const { result } = renderHook(() =>
|
|
261
|
+
useTaskContextActions({
|
|
262
|
+
task: taskWithPersonalPlacement,
|
|
263
|
+
userId,
|
|
264
|
+
onTaskUpdate,
|
|
265
|
+
onClose,
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await act(async () => {
|
|
270
|
+
await result.current.handleComplete();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(mockListWorkspaceTaskLists).not.toHaveBeenCalled();
|
|
274
|
+
expect(mockUpdateWorkspaceTask).not.toHaveBeenCalled();
|
|
275
|
+
expect(mockUpsertCurrentUserTaskPersonalPlacement).toHaveBeenCalledWith(
|
|
276
|
+
'task-123',
|
|
277
|
+
{
|
|
278
|
+
personal_board_id: 'personal-board',
|
|
279
|
+
personal_list_id: 'personal-list',
|
|
280
|
+
terminal_status: 'done',
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
expect(onTaskUpdate).toHaveBeenCalled();
|
|
284
|
+
expect(onClose).toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
|
|
233
287
|
it('handleComplete clears overrides when task has personally_unassigned', async () => {
|
|
234
288
|
const taskWithPersonallyUnassigned = {
|
|
235
289
|
...mockTask,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
listWorkspaceTaskLists,
|
|
16
16
|
updateWorkspaceTask,
|
|
17
|
+
upsertCurrentUserTaskPersonalPlacement,
|
|
17
18
|
} from '@tuturuuu/internal-api';
|
|
18
19
|
import type { TaskWithRelations } from '@tuturuuu/types';
|
|
19
20
|
import type { Task as PrimitiveTask } from '@tuturuuu/types/primitives/Task';
|
|
@@ -200,8 +201,50 @@ export default function TaskListWithCompletion({
|
|
|
200
201
|
|
|
201
202
|
setCompletingTasks((prev) => new Set(prev).add(task.id));
|
|
202
203
|
const snapshot = snapshotCache();
|
|
204
|
+
const isCompleted = task.list?.status === 'done';
|
|
205
|
+
const personalPlacement = task.overrides as
|
|
206
|
+
| (NonNullable<typeof task.overrides> & {
|
|
207
|
+
personal_board_id?: string | null;
|
|
208
|
+
personal_list_id?: string | null;
|
|
209
|
+
})
|
|
210
|
+
| null;
|
|
211
|
+
const personalBoardId = personalPlacement?.personal_board_id ?? null;
|
|
203
212
|
|
|
204
213
|
try {
|
|
214
|
+
if (personalBoardId && !isCompleted) {
|
|
215
|
+
removeTaskFromCache(task.id);
|
|
216
|
+
|
|
217
|
+
await upsertCurrentUserTaskPersonalPlacement(task.id, {
|
|
218
|
+
personal_board_id: personalBoardId,
|
|
219
|
+
personal_list_id: personalPlacement?.personal_list_id ?? null,
|
|
220
|
+
terminal_status: 'done',
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
task.overrides?.completed_at ||
|
|
225
|
+
task.overrides?.personally_unassigned
|
|
226
|
+
) {
|
|
227
|
+
await fetch(
|
|
228
|
+
getTaskApiUrl(`/api/v1/users/me/tasks/${task.id}/overrides`),
|
|
229
|
+
{
|
|
230
|
+
method: 'PUT',
|
|
231
|
+
credentials: 'include',
|
|
232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
completed_at: null,
|
|
235
|
+
personally_unassigned: false,
|
|
236
|
+
}),
|
|
237
|
+
}
|
|
238
|
+
).catch(() => {
|
|
239
|
+
// Non-critical cleanup — don't block the completion flow
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
toast.success(t('task_completed_toast'));
|
|
244
|
+
onTaskUpdate?.();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
205
248
|
// Find the board's done list
|
|
206
249
|
const { lists } = await listWorkspaceTaskLists(
|
|
207
250
|
task.list.board.ws_id,
|
|
@@ -221,7 +264,6 @@ export default function TaskListWithCompletion({
|
|
|
221
264
|
return;
|
|
222
265
|
}
|
|
223
266
|
|
|
224
|
-
const isCompleted = task.list?.status === 'done';
|
|
225
267
|
const targetListId = isCompleted ? notStartedList.id : doneList.id;
|
|
226
268
|
|
|
227
269
|
// Optimistic: remove from view (completion hides task)
|