@tuturuuu/ui 0.14.0 → 0.16.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  12. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  13. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  14. package/src/components/ui/custom/statistic-card.tsx +104 -0
  15. package/src/components/ui/custom/structure.test.tsx +29 -0
  16. package/src/components/ui/custom/structure.tsx +103 -88
  17. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  18. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  23. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  24. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  25. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  26. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  27. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  28. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  29. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  30. package/src/components/ui/finance/invoices/utils.ts +56 -134
  31. package/src/components/ui/resizable.test.ts +15 -0
  32. package/src/components/ui/resizable.tsx +28 -6
  33. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
  36. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  37. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  38. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  39. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  40. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  41. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  45. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  46. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  47. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  51. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  52. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  53. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  54. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  55. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  56. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  57. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  58. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  59. package/src/components/ui/tu-do/projects/types.ts +4 -1
  60. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  61. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  62. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  63. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  64. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  65. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  66. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  69. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  75. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  76. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  77. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  78. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  79. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  80. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  81. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  82. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  83. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  84. package/src/hooks/task-actions-personal-external.ts +12 -6
  85. package/src/hooks/use-notifications.ts +14 -2
  86. package/vitest.config.ts +4 -0
@@ -0,0 +1,231 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import type { Task } from '@tuturuuu/types/primitives/Task';
4
+ import type { ReactNode } from 'react';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { updateWorkspaceTask } from '../task-api';
7
+ import {
8
+ type UseTaskMutationsProps,
9
+ useTaskMutations,
10
+ } from '../use-task-mutations';
11
+
12
+ vi.mock('../task-api', () => ({
13
+ updateWorkspaceTask: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('@tuturuuu/ui/hooks/use-toast', () => ({
17
+ useToast: () => ({ toast: vi.fn() }),
18
+ }));
19
+
20
+ describe('useTaskMutations', () => {
21
+ let queryClient: QueryClient;
22
+
23
+ const wrapper = ({ children }: { children: ReactNode }) => (
24
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
25
+ );
26
+
27
+ const personalOverlayTask = {
28
+ id: 'task-1',
29
+ name: 'Overlay task',
30
+ description: '',
31
+ display_number: 1,
32
+ created_at: '2026-07-07T00:00:00.000Z',
33
+ list_id: 'personal-list',
34
+ personal_board_id: 'personal-board',
35
+ personal_list_id: 'personal-list',
36
+ personal_sort_key: 1234,
37
+ sort_key: 1234,
38
+ source_workspace_id: 'source-ws',
39
+ source_board_id: 'source-board',
40
+ source_list_id: 'source-list',
41
+ source_list_name: 'Source list',
42
+ source_list_status: 'active',
43
+ is_personal_external: true,
44
+ priority: 'normal',
45
+ estimation_points: 3,
46
+ end_date: null,
47
+ labels: [],
48
+ assignees: [],
49
+ projects: [],
50
+ } satisfies Task;
51
+
52
+ const sourceTask = {
53
+ ...personalOverlayTask,
54
+ list_id: 'source-list',
55
+ personal_board_id: null,
56
+ personal_list_id: null,
57
+ personal_sort_key: null,
58
+ is_personal_external: false,
59
+ } satisfies Task;
60
+
61
+ function makeProps(
62
+ overrides: Partial<UseTaskMutationsProps> = {}
63
+ ): UseTaskMutationsProps {
64
+ return {
65
+ wsId: 'source-ws',
66
+ taskId: 'task-1',
67
+ isCreateMode: false,
68
+ boardId: 'source-board',
69
+ visibleBoardId: 'personal-board',
70
+ visibleTaskSnapshot: personalOverlayTask,
71
+ estimationPoints: 3,
72
+ priority: 'normal',
73
+ selectedListId: 'source-list',
74
+ taskName: 'Overlay task',
75
+ setEstimationPoints: vi.fn(),
76
+ setPriority: vi.fn(),
77
+ setStartDate: vi.fn(),
78
+ setEndDate: vi.fn(),
79
+ setSelectedListId: vi.fn(),
80
+ fallbackBroadcast: null,
81
+ onUpdate: vi.fn(),
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ function getPersonalOverlayCacheTask() {
87
+ return queryClient.getQueryData<Task[]>(['tasks', 'personal-board'])?.[0] as
88
+ | (Task & { _localMutationAt?: number })
89
+ | undefined;
90
+ }
91
+
92
+ beforeEach(() => {
93
+ queryClient = new QueryClient({
94
+ defaultOptions: {
95
+ queries: { retry: false },
96
+ mutations: { retry: false },
97
+ },
98
+ });
99
+ queryClient.setQueryData(
100
+ ['tasks', 'personal-board'],
101
+ [personalOverlayTask]
102
+ );
103
+ vi.mocked(updateWorkspaceTask).mockReset();
104
+ });
105
+
106
+ afterEach(() => {
107
+ queryClient.clear();
108
+ vi.restoreAllMocks();
109
+ });
110
+
111
+ it('keeps a personal external card in the personal board cache when priority changes', async () => {
112
+ const onUpdate = vi.fn();
113
+ vi.mocked(updateWorkspaceTask).mockResolvedValueOnce({
114
+ task: {
115
+ ...sourceTask,
116
+ priority: 'high',
117
+ list_id: 'source-list',
118
+ },
119
+ });
120
+
121
+ const { result } = renderHook(
122
+ () => useTaskMutations(makeProps({ onUpdate })),
123
+ { wrapper }
124
+ );
125
+
126
+ await act(async () => {
127
+ await result.current.updatePriority('high');
128
+ });
129
+
130
+ expect(updateWorkspaceTask).toHaveBeenCalledWith('source-ws', 'task-1', {
131
+ priority: 'high',
132
+ });
133
+ expect(onUpdate).not.toHaveBeenCalled();
134
+ expect(getPersonalOverlayCacheTask()).toMatchObject({
135
+ id: 'task-1',
136
+ priority: 'high',
137
+ list_id: 'personal-list',
138
+ personal_board_id: 'personal-board',
139
+ personal_list_id: 'personal-list',
140
+ personal_sort_key: 1234,
141
+ source_board_id: 'source-board',
142
+ source_list_id: 'source-list',
143
+ _localMutationAt: expect.any(Number),
144
+ });
145
+ });
146
+
147
+ it('preserves personal placement while updating date, estimate, and name fields', async () => {
148
+ const startIso = '2026-07-08T00:00:00.000Z';
149
+ const endIso = '2026-07-09T00:00:00.000Z';
150
+ const onUpdate = vi.fn();
151
+
152
+ vi.mocked(updateWorkspaceTask)
153
+ .mockResolvedValueOnce({
154
+ task: { ...sourceTask, start_date: startIso, list_id: 'source-list' },
155
+ })
156
+ .mockResolvedValueOnce({
157
+ task: { ...sourceTask, end_date: endIso, list_id: 'source-list' },
158
+ })
159
+ .mockResolvedValueOnce({
160
+ task: {
161
+ ...sourceTask,
162
+ estimation_points: 8,
163
+ list_id: 'source-list',
164
+ },
165
+ })
166
+ .mockResolvedValueOnce({
167
+ task: { ...sourceTask, name: 'Renamed task', list_id: 'source-list' },
168
+ });
169
+
170
+ const { result } = renderHook(
171
+ () => useTaskMutations(makeProps({ onUpdate })),
172
+ { wrapper }
173
+ );
174
+
175
+ await act(async () => {
176
+ await result.current.updateStartDate(new Date(startIso));
177
+ await result.current.updateEndDate(new Date(endIso));
178
+ await result.current.updateEstimation(8);
179
+ await result.current.saveNameToDatabase('Renamed task');
180
+ });
181
+
182
+ expect(onUpdate).not.toHaveBeenCalled();
183
+ expect(getPersonalOverlayCacheTask()).toMatchObject({
184
+ id: 'task-1',
185
+ name: 'Renamed task',
186
+ list_id: 'personal-list',
187
+ personal_list_id: 'personal-list',
188
+ start_date: startIso,
189
+ end_date: endIso,
190
+ estimation_points: 8,
191
+ _localMutationAt: expect.any(Number),
192
+ });
193
+ });
194
+
195
+ it('keeps normal source-board property refresh behavior unchanged', async () => {
196
+ const onUpdate = vi.fn();
197
+ queryClient.setQueryData(['tasks', 'source-board'], [sourceTask]);
198
+ vi.mocked(updateWorkspaceTask).mockResolvedValueOnce({
199
+ task: { ...sourceTask, priority: 'high' },
200
+ });
201
+
202
+ const { result } = renderHook(
203
+ () =>
204
+ useTaskMutations(
205
+ makeProps({
206
+ onUpdate,
207
+ visibleBoardId: undefined,
208
+ visibleTaskSnapshot: undefined,
209
+ })
210
+ ),
211
+ { wrapper }
212
+ );
213
+
214
+ await act(async () => {
215
+ await result.current.updatePriority('high');
216
+ });
217
+
218
+ const sourceCacheTask = queryClient.getQueryData<Task[]>([
219
+ 'tasks',
220
+ 'source-board',
221
+ ])?.[0] as (Task & { _localMutationAt?: number }) | undefined;
222
+
223
+ expect(onUpdate).toHaveBeenCalledTimes(1);
224
+ expect(sourceCacheTask).toMatchObject({
225
+ id: 'task-1',
226
+ priority: 'high',
227
+ list_id: 'source-list',
228
+ });
229
+ expect(sourceCacheTask?._localMutationAt).toBeUndefined();
230
+ });
231
+ });
@@ -4,9 +4,10 @@ import {
4
4
  updateCurrentUserTaskSchedulingSettings,
5
5
  } from '@tuturuuu/internal-api';
6
6
  import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
7
- import type { CalendarHoursType } from '@tuturuuu/types/primitives/Task';
7
+ import type { CalendarHoursType, Task } from '@tuturuuu/types/primitives/Task';
8
8
  import { useToast } from '@tuturuuu/ui/hooks/use-toast';
9
9
  import { useCallback, useState } from 'react';
10
+ import { isPersonalExternalOverlayTask } from '../../../../../../lib/task-personal-external';
10
11
  import {
11
12
  type BoardBroadcastFn,
12
13
  getActiveBroadcast,
@@ -33,6 +34,8 @@ export interface UseTaskMutationsProps {
33
34
  taskId?: string;
34
35
  isCreateMode: boolean;
35
36
  boardId: string;
37
+ visibleBoardId?: string;
38
+ visibleTaskSnapshot?: Partial<Task>;
36
39
  estimationPoints: number | null;
37
40
  priority: TaskPriority | null;
38
41
  selectedListId?: string;
@@ -66,6 +69,27 @@ export interface UseTaskMutationsReturn {
66
69
  schedulingSaving: boolean;
67
70
  }
68
71
 
72
+ type PropertyPatch = Pick<
73
+ Partial<Task>,
74
+ 'name' | 'priority' | 'start_date' | 'end_date' | 'estimation_points'
75
+ >;
76
+
77
+ function hasPersonalExternalVisibleContext({
78
+ boardId,
79
+ visibleBoardId,
80
+ visibleTaskSnapshot,
81
+ }: {
82
+ boardId: string;
83
+ visibleBoardId?: string;
84
+ visibleTaskSnapshot?: Partial<Task>;
85
+ }) {
86
+ return Boolean(
87
+ visibleBoardId &&
88
+ visibleBoardId !== boardId &&
89
+ isPersonalExternalOverlayTask(visibleTaskSnapshot as Task | undefined)
90
+ );
91
+ }
92
+
69
93
  /**
70
94
  * Custom hook for task database mutations (CRUD operations on task properties)
71
95
  * Extracted from task-edit-dialog.tsx to improve maintainability
@@ -75,6 +99,8 @@ export function useTaskMutations({
75
99
  taskId,
76
100
  isCreateMode,
77
101
  boardId,
102
+ visibleBoardId,
103
+ visibleTaskSnapshot,
78
104
  estimationPoints,
79
105
  priority,
80
106
  selectedListId,
@@ -94,16 +120,57 @@ export function useTaskMutations({
94
120
  contextBroadcast ?? getActiveBroadcast() ?? fallbackBroadcast;
95
121
  const [estimationSaving, setEstimationSaving] = useState(false);
96
122
  const [schedulingSaving, setSchedulingSaving] = useState(false);
123
+ const hasPersonalExternalContext = hasPersonalExternalVisibleContext({
124
+ boardId,
125
+ visibleBoardId,
126
+ visibleTaskSnapshot,
127
+ });
128
+ const propertyCacheBoardId =
129
+ hasPersonalExternalContext && visibleBoardId ? visibleBoardId : boardId;
97
130
 
98
131
  // Helper to trigger refresh after successful mutations
99
132
  // Note: The kanban board uses realtime subscriptions directly and doesn't
100
133
  // register a refresh callback via the task dialog system, so calling onUpdate
101
134
  // here won't conflict with realtime sync on the board page.
102
135
  // Also invalidate task-history so the activity section updates immediately.
103
- const triggerRefresh = useCallback(() => {
136
+ const triggerRefresh = useCallback(
137
+ (options?: { refreshBoard?: boolean }) => {
138
+ const refreshBoard = options?.refreshBoard ?? true;
139
+
140
+ queryClient.invalidateQueries({ queryKey: ['task-history'] });
141
+ if (refreshBoard) {
142
+ onUpdate();
143
+ }
144
+ },
145
+ [queryClient, onUpdate]
146
+ );
147
+
148
+ const patchPropertyInVisibleCaches = useCallback(
149
+ (patch: PropertyPatch) => {
150
+ if (!taskId) return;
151
+
152
+ patchTaskInVisibleCaches({
153
+ queryClient,
154
+ boardId: propertyCacheBoardId,
155
+ taskId,
156
+ updater: (task) => ({
157
+ ...task,
158
+ ...patch,
159
+ ...(hasPersonalExternalContext
160
+ ? { _localMutationAt: Date.now() }
161
+ : {}),
162
+ }),
163
+ });
164
+ },
165
+ [hasPersonalExternalContext, propertyCacheBoardId, queryClient, taskId]
166
+ );
167
+
168
+ const triggerPropertyRefresh = useCallback(() => {
104
169
  queryClient.invalidateQueries({ queryKey: ['task-history'] });
105
- onUpdate();
106
- }, [queryClient, onUpdate]);
170
+ if (!hasPersonalExternalContext) {
171
+ onUpdate();
172
+ }
173
+ }, [hasPersonalExternalContext, queryClient, onUpdate]);
107
174
 
108
175
  const updateEstimation = useCallback(
109
176
  async (points: number | null) => {
@@ -113,29 +180,30 @@ export function useTaskMutations({
113
180
  return;
114
181
  }
115
182
  setEstimationSaving(true);
116
- const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
117
- taskId,
118
- ]);
183
+ const cacheSnapshot = snapshotVisibleTaskCaches(
184
+ queryClient,
185
+ propertyCacheBoardId,
186
+ [taskId]
187
+ );
119
188
 
120
189
  // Optimistic update - prevents flicker by updating cache immediately
121
- patchTaskInVisibleCaches({
122
- queryClient,
123
- boardId,
124
- taskId,
125
- updater: (task) => ({ ...task, estimation_points: points }),
126
- });
190
+ patchPropertyInVisibleCaches({ estimation_points: points });
127
191
 
128
192
  try {
129
193
  const { task } = await updateWorkspaceTask(wsId, taskId, {
130
194
  estimation_points: points,
131
195
  });
196
+ const updatedEstimationPoints = task.estimation_points ?? points;
197
+ patchPropertyInVisibleCaches({
198
+ estimation_points: updatedEstimationPoints,
199
+ });
132
200
  broadcast?.('task:upsert', {
133
201
  task: {
134
202
  id: taskId,
135
- estimation_points: task.estimation_points ?? points,
203
+ estimation_points: updatedEstimationPoints,
136
204
  },
137
205
  });
138
- triggerRefresh();
206
+ triggerPropertyRefresh();
139
207
  } catch (e: any) {
140
208
  console.error('Failed updating estimation', e);
141
209
  // Revert optimistic update on error
@@ -155,10 +223,11 @@ export function useTaskMutations({
155
223
  wsId,
156
224
  taskId,
157
225
  queryClient,
158
- boardId,
226
+ propertyCacheBoardId,
159
227
  toast,
160
228
  setEstimationPoints,
161
- triggerRefresh,
229
+ patchPropertyInVisibleCaches,
230
+ triggerPropertyRefresh,
162
231
  broadcast,
163
232
  ]
164
233
  );
@@ -170,29 +239,28 @@ export function useTaskMutations({
170
239
  if (isCreateMode || !taskId || taskId === 'new') {
171
240
  return;
172
241
  }
173
- const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
174
- taskId,
175
- ]);
242
+ const cacheSnapshot = snapshotVisibleTaskCaches(
243
+ queryClient,
244
+ propertyCacheBoardId,
245
+ [taskId]
246
+ );
176
247
 
177
248
  // Optimistic update - prevents flicker
178
- patchTaskInVisibleCaches({
179
- queryClient,
180
- boardId,
181
- taskId,
182
- updater: (task) => ({ ...task, priority: newPriority }),
183
- });
249
+ patchPropertyInVisibleCaches({ priority: newPriority });
184
250
 
185
251
  try {
186
252
  const { task } = await updateWorkspaceTask(wsId, taskId, {
187
253
  priority: newPriority,
188
254
  });
255
+ const updatedPriority = task.priority ?? newPriority;
256
+ patchPropertyInVisibleCaches({ priority: updatedPriority });
189
257
  broadcast?.('task:upsert', {
190
258
  task: {
191
259
  id: taskId,
192
- priority: task.priority ?? newPriority,
260
+ priority: updatedPriority,
193
261
  },
194
262
  });
195
- triggerRefresh();
263
+ triggerPropertyRefresh();
196
264
  } catch (e: any) {
197
265
  console.error('Failed updating priority', e);
198
266
  // Revert optimistic update on error
@@ -210,10 +278,11 @@ export function useTaskMutations({
210
278
  wsId,
211
279
  taskId,
212
280
  queryClient,
213
- boardId,
281
+ propertyCacheBoardId,
214
282
  toast,
215
283
  setPriority,
216
- triggerRefresh,
284
+ patchPropertyInVisibleCaches,
285
+ triggerPropertyRefresh,
217
286
  broadcast,
218
287
  ]
219
288
  );
@@ -226,29 +295,30 @@ export function useTaskMutations({
226
295
  }
227
296
 
228
297
  const dateString = newDate ? newDate.toISOString() : null;
229
- const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
230
- taskId,
231
- ]);
298
+ const cacheSnapshot = snapshotVisibleTaskCaches(
299
+ queryClient,
300
+ propertyCacheBoardId,
301
+ [taskId]
302
+ );
232
303
 
233
304
  // Optimistic update - prevents flicker
234
- patchTaskInVisibleCaches({
235
- queryClient,
236
- boardId,
237
- taskId,
238
- updater: (task) => ({ ...task, start_date: dateString ?? undefined }),
239
- });
305
+ patchPropertyInVisibleCaches({ start_date: dateString ?? undefined });
240
306
 
241
307
  try {
242
308
  const { task } = await updateWorkspaceTask(wsId, taskId, {
243
309
  start_date: dateString,
244
310
  });
311
+ const updatedStartDate = task.start_date ?? dateString;
312
+ patchPropertyInVisibleCaches({
313
+ start_date: updatedStartDate ?? undefined,
314
+ });
245
315
  broadcast?.('task:upsert', {
246
316
  task: {
247
317
  id: taskId,
248
- start_date: task.start_date ?? dateString,
318
+ start_date: updatedStartDate,
249
319
  },
250
320
  });
251
- triggerRefresh();
321
+ triggerPropertyRefresh();
252
322
  } catch (e: any) {
253
323
  console.error('Failed updating start date', e);
254
324
  // Revert optimistic update on error
@@ -265,10 +335,11 @@ export function useTaskMutations({
265
335
  wsId,
266
336
  taskId,
267
337
  queryClient,
268
- boardId,
338
+ propertyCacheBoardId,
269
339
  toast,
270
340
  setStartDate,
271
- triggerRefresh,
341
+ patchPropertyInVisibleCaches,
342
+ triggerPropertyRefresh,
272
343
  broadcast,
273
344
  ]
274
345
  );
@@ -281,29 +352,28 @@ export function useTaskMutations({
281
352
  }
282
353
 
283
354
  const dateString = newDate ? newDate.toISOString() : null;
284
- const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
285
- taskId,
286
- ]);
355
+ const cacheSnapshot = snapshotVisibleTaskCaches(
356
+ queryClient,
357
+ propertyCacheBoardId,
358
+ [taskId]
359
+ );
287
360
 
288
361
  // Optimistic update - prevents flicker
289
- patchTaskInVisibleCaches({
290
- queryClient,
291
- boardId,
292
- taskId,
293
- updater: (task) => ({ ...task, end_date: dateString }),
294
- });
362
+ patchPropertyInVisibleCaches({ end_date: dateString });
295
363
 
296
364
  try {
297
365
  const { task } = await updateWorkspaceTask(wsId, taskId, {
298
366
  end_date: dateString,
299
367
  });
368
+ const updatedEndDate = task.end_date ?? dateString;
369
+ patchPropertyInVisibleCaches({ end_date: updatedEndDate });
300
370
  broadcast?.('task:upsert', {
301
371
  task: {
302
372
  id: taskId,
303
- end_date: task.end_date ?? dateString,
373
+ end_date: updatedEndDate,
304
374
  },
305
375
  });
306
- triggerRefresh();
376
+ triggerPropertyRefresh();
307
377
  } catch (e: any) {
308
378
  console.error('Failed updating end date', e);
309
379
  // Revert optimistic update on error
@@ -320,10 +390,11 @@ export function useTaskMutations({
320
390
  wsId,
321
391
  taskId,
322
392
  queryClient,
323
- boardId,
393
+ propertyCacheBoardId,
324
394
  toast,
325
395
  setEndDate,
326
- triggerRefresh,
396
+ patchPropertyInVisibleCaches,
397
+ triggerPropertyRefresh,
327
398
  broadcast,
328
399
  ]
329
400
  );
@@ -411,24 +482,23 @@ export function useTaskMutations({
411
482
 
412
483
  // Optimistically update the cache instead of invalidating
413
484
  // This prevents conflicts with realtime sync
414
- const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
415
- taskId,
416
- ]);
417
- patchTaskInVisibleCaches({
485
+ const cacheSnapshot = snapshotVisibleTaskCaches(
418
486
  queryClient,
419
- boardId,
420
- taskId,
421
- updater: (task) => ({ ...task, name: trimmedName }),
422
- });
487
+ propertyCacheBoardId,
488
+ [taskId]
489
+ );
490
+ patchPropertyInVisibleCaches({ name: trimmedName });
423
491
 
424
492
  try {
425
493
  const { task } = await updateWorkspaceTask(wsId, taskId, {
426
494
  name: trimmedName,
427
495
  });
496
+ const updatedName = task.name ?? trimmedName;
497
+ patchPropertyInVisibleCaches({ name: updatedName });
428
498
  broadcast?.('task:upsert', {
429
- task: { id: taskId, name: task.name ?? trimmedName },
499
+ task: { id: taskId, name: updatedName },
430
500
  });
431
- triggerRefresh();
501
+ triggerPropertyRefresh();
432
502
  } catch (e: any) {
433
503
  console.error('Failed updating task name', e);
434
504
  // Revert optimistic update without refetching visible board caches.
@@ -446,9 +516,10 @@ export function useTaskMutations({
446
516
  taskId,
447
517
  isCreateMode,
448
518
  queryClient,
449
- boardId,
519
+ propertyCacheBoardId,
450
520
  toast,
451
- triggerRefresh,
521
+ patchPropertyInVisibleCaches,
522
+ triggerPropertyRefresh,
452
523
  broadcast,
453
524
  ]
454
525
  );
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { fireEvent, render, screen } from '@testing-library/react';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { TaskDialogActions } from './task-dialog-actions';
8
+
9
+ vi.mock('next-intl', () => ({
10
+ useTranslations: () => (key: string) => key,
11
+ }));
12
+
13
+ vi.mock('@tuturuuu/ui/sonner', () => ({
14
+ toast: {
15
+ success: vi.fn(),
16
+ },
17
+ }));
18
+
19
+ describe('TaskDialogActions', () => {
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ it('shows the optional archive action in the edit menu', () => {
25
+ const onArchiveTask = vi.fn();
26
+ const onMoreMenuOpenChange = vi.fn();
27
+
28
+ render(
29
+ <TaskDialogActions
30
+ boardId="board-1"
31
+ hasDraft={false}
32
+ isCreateMode={false}
33
+ moreMenuOpen
34
+ onArchiveTask={onArchiveTask}
35
+ onClearDraft={vi.fn()}
36
+ onClose={vi.fn()}
37
+ onMoreMenuOpenChange={onMoreMenuOpenChange}
38
+ onShowDeleteDialog={vi.fn()}
39
+ taskId="task-1"
40
+ wsId="workspace-1"
41
+ />
42
+ );
43
+
44
+ fireEvent.click(screen.getByText('common.archive'));
45
+
46
+ expect(onArchiveTask).toHaveBeenCalledTimes(1);
47
+ expect(onMoreMenuOpenChange).toHaveBeenCalledWith(false);
48
+ });
49
+
50
+ it('keeps the archive action hidden when no handler is provided', () => {
51
+ render(
52
+ <TaskDialogActions
53
+ boardId="board-1"
54
+ hasDraft={false}
55
+ isCreateMode={false}
56
+ moreMenuOpen
57
+ onClearDraft={vi.fn()}
58
+ onClose={vi.fn()}
59
+ onMoreMenuOpenChange={vi.fn()}
60
+ onShowDeleteDialog={vi.fn()}
61
+ taskId="task-1"
62
+ wsId="workspace-1"
63
+ />
64
+ );
65
+
66
+ expect(screen.queryByText('common.archive')).not.toBeInTheDocument();
67
+ });
68
+ });