@tuturuuu/ui 0.9.0 → 0.11.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 (123) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +6 -5
  3. package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
  5. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
  6. package/src/components/ui/custom/nav-link.test.tsx +165 -0
  7. package/src/components/ui/custom/nav-link.tsx +69 -11
  8. package/src/components/ui/custom/navigation.tsx +1 -0
  9. package/src/components/ui/custom/settings/task-settings.tsx +104 -0
  10. package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
  11. package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
  12. package/src/components/ui/custom/settings-dialog-search.ts +75 -0
  13. package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
  14. package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
  15. package/src/components/ui/custom/workspace-select.tsx +17 -16
  16. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  17. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  18. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  21. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  22. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  23. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  24. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  25. package/src/components/ui/finance/invoices/utils.ts +240 -7
  26. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  27. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  28. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  29. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  30. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  31. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  33. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  35. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  36. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  37. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  38. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  39. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  40. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  41. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  46. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  50. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  51. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  52. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  53. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  54. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  55. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  56. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  57. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  58. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  59. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  60. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  61. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  62. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  63. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +123 -1
  64. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  65. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  66. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  67. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +58 -0
  68. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  69. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  70. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  71. package/src/components/ui/tu-do/shared/board-client.tsx +16 -4
  72. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  73. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  74. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  75. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  76. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  77. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +58 -4
  78. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  79. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  102. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  103. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  104. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  105. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  106. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  107. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  108. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +390 -34
  109. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  110. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  111. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  112. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  113. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  114. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  115. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  116. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  117. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  118. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
  119. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  120. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  121. package/src/hooks/useBoardPresence.ts +364 -0
  122. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  123. package/src/lib/workspace-actions.ts +2 -6
@@ -0,0 +1,394 @@
1
+ import type { QueryClient, QueryKey } from '@tanstack/react-query';
2
+ import type { Task } from '@tuturuuu/types/primitives/Task';
3
+
4
+ type WorkspaceTaskCache = {
5
+ task?: Task | null;
6
+ };
7
+
8
+ type MyTasksCache = {
9
+ overdue?: Task[];
10
+ today?: Task[];
11
+ upcoming?: Task[];
12
+ completed?: Task[];
13
+ };
14
+
15
+ type MyCompletedTasksCache = {
16
+ pages?: Array<{
17
+ completed?: Task[];
18
+ }>;
19
+ };
20
+
21
+ export type VisibleTaskCacheSnapshot = {
22
+ boardTaskEntries: [QueryKey, Task[] | undefined][];
23
+ taskEntries: [QueryKey, Task | undefined][];
24
+ workspaceTaskEntries: [QueryKey, WorkspaceTaskCache | undefined][];
25
+ myTasksEntries: [QueryKey, MyTasksCache | undefined][];
26
+ myCompletedTaskEntries: [QueryKey, MyCompletedTasksCache | undefined][];
27
+ };
28
+
29
+ function isBoardTaskQuery(queryKey: QueryKey, boardId: string) {
30
+ return (
31
+ Array.isArray(queryKey) &&
32
+ (queryKey[0] === 'tasks' || queryKey[0] === 'tasks-full') &&
33
+ queryKey[1] === boardId
34
+ );
35
+ }
36
+
37
+ function isTaskDetailQuery(queryKey: QueryKey, taskIds: Set<string>) {
38
+ return (
39
+ Array.isArray(queryKey) &&
40
+ queryKey[0] === 'task' &&
41
+ typeof queryKey[1] === 'string' &&
42
+ taskIds.has(queryKey[1])
43
+ );
44
+ }
45
+
46
+ function isWorkspaceTaskQuery(queryKey: QueryKey, taskIds: Set<string>) {
47
+ return (
48
+ Array.isArray(queryKey) &&
49
+ queryKey[0] === 'workspaceTask' &&
50
+ typeof queryKey[2] === 'string' &&
51
+ taskIds.has(queryKey[2])
52
+ );
53
+ }
54
+
55
+ function patchTaskArray<T extends { id?: string }>(
56
+ tasks: T[] | undefined,
57
+ taskIds: Set<string>,
58
+ updater: (task: Task) => Task
59
+ ): T[] | undefined {
60
+ if (!tasks) return tasks;
61
+
62
+ let changed = false;
63
+ const nextTasks = tasks.map((task) => {
64
+ if (!task.id || !taskIds.has(task.id)) {
65
+ return task;
66
+ }
67
+
68
+ const updatedTask = updater(task as unknown as Task) as unknown as T;
69
+ if (updatedTask !== task) {
70
+ changed = true;
71
+ }
72
+
73
+ return updatedTask;
74
+ });
75
+
76
+ return changed ? nextTasks : tasks;
77
+ }
78
+
79
+ function restoreTaskArrayFromSnapshot<T extends { id?: string }>(
80
+ currentTasks: T[] | undefined,
81
+ previousTasks: T[] | undefined,
82
+ taskIds: Set<string>
83
+ ): T[] | undefined {
84
+ if (!currentTasks || !previousTasks) return currentTasks;
85
+
86
+ const previousById = new Map(
87
+ previousTasks
88
+ .filter((task) => task.id && taskIds.has(task.id))
89
+ .map((task) => [task.id, task])
90
+ );
91
+ if (previousById.size === 0) return currentTasks;
92
+
93
+ let changed = false;
94
+ const restoredTasks = currentTasks.map((task) => {
95
+ if (!task.id) return task;
96
+ const previousTask = previousById.get(task.id);
97
+ if (!previousTask) return task;
98
+
99
+ changed = true;
100
+ return previousTask;
101
+ });
102
+
103
+ return changed ? restoredTasks : currentTasks;
104
+ }
105
+
106
+ function patchMyTasksCache(
107
+ cache: MyTasksCache | undefined,
108
+ taskIds: Set<string>,
109
+ updater: (task: Task) => Task
110
+ ) {
111
+ if (!cache) return cache;
112
+
113
+ return {
114
+ ...cache,
115
+ overdue: patchTaskArray(cache.overdue, taskIds, updater) ?? cache.overdue,
116
+ today: patchTaskArray(cache.today, taskIds, updater) ?? cache.today,
117
+ upcoming:
118
+ patchTaskArray(cache.upcoming, taskIds, updater) ?? cache.upcoming,
119
+ completed:
120
+ patchTaskArray(cache.completed, taskIds, updater) ?? cache.completed,
121
+ };
122
+ }
123
+
124
+ function patchMyCompletedTasksCache(
125
+ cache: MyCompletedTasksCache | undefined,
126
+ taskIds: Set<string>,
127
+ updater: (task: Task) => Task
128
+ ) {
129
+ if (!cache?.pages) return cache;
130
+
131
+ return {
132
+ ...cache,
133
+ pages: cache.pages.map((page) => ({
134
+ ...page,
135
+ completed:
136
+ patchTaskArray(page.completed, taskIds, updater) ?? page.completed,
137
+ })),
138
+ };
139
+ }
140
+
141
+ function restoreMyTasksCacheFromSnapshot(
142
+ current: MyTasksCache | undefined,
143
+ previous: MyTasksCache | undefined,
144
+ taskIds: Set<string>
145
+ ) {
146
+ if (!current || !previous) return current;
147
+
148
+ return {
149
+ ...current,
150
+ overdue:
151
+ restoreTaskArrayFromSnapshot(
152
+ current.overdue,
153
+ previous.overdue,
154
+ taskIds
155
+ ) ?? current.overdue,
156
+ today:
157
+ restoreTaskArrayFromSnapshot(current.today, previous.today, taskIds) ??
158
+ current.today,
159
+ upcoming:
160
+ restoreTaskArrayFromSnapshot(
161
+ current.upcoming,
162
+ previous.upcoming,
163
+ taskIds
164
+ ) ?? current.upcoming,
165
+ completed:
166
+ restoreTaskArrayFromSnapshot(
167
+ current.completed,
168
+ previous.completed,
169
+ taskIds
170
+ ) ?? current.completed,
171
+ };
172
+ }
173
+
174
+ function restoreMyCompletedTasksCacheFromSnapshot(
175
+ current: MyCompletedTasksCache | undefined,
176
+ previous: MyCompletedTasksCache | undefined,
177
+ taskIds: Set<string>
178
+ ) {
179
+ if (!current?.pages || !previous?.pages) return current;
180
+
181
+ return {
182
+ ...current,
183
+ pages: current.pages.map((page, index) => ({
184
+ ...page,
185
+ completed:
186
+ restoreTaskArrayFromSnapshot(
187
+ page.completed,
188
+ previous.pages?.[index]?.completed,
189
+ taskIds
190
+ ) ?? page.completed,
191
+ })),
192
+ };
193
+ }
194
+
195
+ export function getTaskFromVisibleCaches({
196
+ queryClient,
197
+ boardId,
198
+ taskId,
199
+ fallback,
200
+ }: {
201
+ queryClient: QueryClient;
202
+ boardId: string;
203
+ taskId: string;
204
+ fallback?: Task;
205
+ }): Task | undefined {
206
+ const taskDetail = queryClient.getQueryData<Task>(['task', taskId]);
207
+ if (taskDetail) return taskDetail;
208
+
209
+ const workspaceTaskEntries = queryClient.getQueriesData<WorkspaceTaskCache>({
210
+ predicate: (query) =>
211
+ isWorkspaceTaskQuery(query.queryKey, new Set([taskId])),
212
+ });
213
+ for (const [, entry] of workspaceTaskEntries) {
214
+ if (entry?.task) return entry.task;
215
+ }
216
+
217
+ const boardTaskEntries = queryClient.getQueriesData<Task[]>({
218
+ predicate: (query) => isBoardTaskQuery(query.queryKey, boardId),
219
+ });
220
+ for (const [, tasks] of boardTaskEntries) {
221
+ const task = tasks?.find((entry) => entry.id === taskId);
222
+ if (task) return task;
223
+ }
224
+
225
+ return fallback;
226
+ }
227
+
228
+ export function patchTasksInVisibleCaches({
229
+ queryClient,
230
+ boardId,
231
+ taskIds,
232
+ updater,
233
+ }: {
234
+ queryClient: QueryClient;
235
+ boardId: string;
236
+ taskIds: string[];
237
+ updater: (task: Task) => Task;
238
+ }) {
239
+ const taskIdSet = new Set(taskIds);
240
+ if (taskIdSet.size === 0) return;
241
+
242
+ queryClient.setQueriesData<Task[]>(
243
+ {
244
+ predicate: (query) => isBoardTaskQuery(query.queryKey, boardId),
245
+ },
246
+ (old) => patchTaskArray(old, taskIdSet, updater)
247
+ );
248
+
249
+ queryClient.setQueriesData<Task>(
250
+ {
251
+ predicate: (query) => isTaskDetailQuery(query.queryKey, taskIdSet),
252
+ },
253
+ (old) => (old ? updater(old) : old)
254
+ );
255
+
256
+ queryClient.setQueriesData<WorkspaceTaskCache>(
257
+ {
258
+ predicate: (query) => isWorkspaceTaskQuery(query.queryKey, taskIdSet),
259
+ },
260
+ (old) =>
261
+ old?.task
262
+ ? {
263
+ ...old,
264
+ task: updater(old.task),
265
+ }
266
+ : old
267
+ );
268
+
269
+ queryClient.setQueriesData<MyTasksCache>({ queryKey: ['my-tasks'] }, (old) =>
270
+ patchMyTasksCache(old, taskIdSet, updater)
271
+ );
272
+
273
+ queryClient.setQueriesData<MyCompletedTasksCache>(
274
+ { queryKey: ['my-completed-tasks'] },
275
+ (old) => patchMyCompletedTasksCache(old, taskIdSet, updater)
276
+ );
277
+ }
278
+
279
+ export function patchTaskInVisibleCaches({
280
+ queryClient,
281
+ boardId,
282
+ taskId,
283
+ updater,
284
+ }: {
285
+ queryClient: QueryClient;
286
+ boardId: string;
287
+ taskId: string;
288
+ updater: (task: Task) => Task;
289
+ }) {
290
+ patchTasksInVisibleCaches({
291
+ queryClient,
292
+ boardId,
293
+ taskIds: [taskId],
294
+ updater,
295
+ });
296
+ }
297
+
298
+ export function snapshotVisibleTaskCaches(
299
+ queryClient: QueryClient,
300
+ boardId: string,
301
+ taskIds: string[]
302
+ ): VisibleTaskCacheSnapshot {
303
+ const taskIdSet = new Set(taskIds);
304
+
305
+ return {
306
+ boardTaskEntries: queryClient.getQueriesData<Task[]>({
307
+ predicate: (query) => isBoardTaskQuery(query.queryKey, boardId),
308
+ }),
309
+ taskEntries: queryClient.getQueriesData<Task>({
310
+ predicate: (query) => isTaskDetailQuery(query.queryKey, taskIdSet),
311
+ }),
312
+ workspaceTaskEntries: queryClient.getQueriesData<WorkspaceTaskCache>({
313
+ predicate: (query) => isWorkspaceTaskQuery(query.queryKey, taskIdSet),
314
+ }),
315
+ myTasksEntries: queryClient.getQueriesData<MyTasksCache>({
316
+ queryKey: ['my-tasks'],
317
+ }),
318
+ myCompletedTaskEntries: queryClient.getQueriesData<MyCompletedTasksCache>({
319
+ queryKey: ['my-completed-tasks'],
320
+ }),
321
+ };
322
+ }
323
+
324
+ export function restoreVisibleTaskCaches(
325
+ queryClient: QueryClient,
326
+ snapshot: VisibleTaskCacheSnapshot
327
+ ) {
328
+ for (const [queryKey, data] of snapshot.boardTaskEntries) {
329
+ queryClient.setQueryData(queryKey, data);
330
+ }
331
+
332
+ for (const [queryKey, data] of snapshot.taskEntries) {
333
+ queryClient.setQueryData(queryKey, data);
334
+ }
335
+
336
+ for (const [queryKey, data] of snapshot.workspaceTaskEntries) {
337
+ queryClient.setQueryData(queryKey, data);
338
+ }
339
+
340
+ for (const [queryKey, data] of snapshot.myTasksEntries) {
341
+ queryClient.setQueryData(queryKey, data);
342
+ }
343
+
344
+ for (const [queryKey, data] of snapshot.myCompletedTaskEntries) {
345
+ queryClient.setQueryData(queryKey, data);
346
+ }
347
+ }
348
+
349
+ export function restoreTasksFromVisibleCacheSnapshot({
350
+ queryClient,
351
+ snapshot,
352
+ taskIds,
353
+ }: {
354
+ queryClient: QueryClient;
355
+ snapshot: VisibleTaskCacheSnapshot;
356
+ taskIds: string[];
357
+ }) {
358
+ const taskIdSet = new Set(taskIds);
359
+ if (taskIdSet.size === 0) return;
360
+
361
+ for (const [queryKey, previousTasks] of snapshot.boardTaskEntries) {
362
+ queryClient.setQueryData<Task[]>(queryKey, (currentTasks) =>
363
+ restoreTaskArrayFromSnapshot(currentTasks, previousTasks, taskIdSet)
364
+ );
365
+ }
366
+
367
+ for (const [queryKey, previousTask] of snapshot.taskEntries) {
368
+ if (!Array.isArray(queryKey) || typeof queryKey[1] !== 'string') continue;
369
+ if (!taskIdSet.has(queryKey[1])) continue;
370
+ queryClient.setQueryData(queryKey, previousTask);
371
+ }
372
+
373
+ for (const [queryKey, previousEntry] of snapshot.workspaceTaskEntries) {
374
+ if (!Array.isArray(queryKey) || typeof queryKey[2] !== 'string') continue;
375
+ if (!taskIdSet.has(queryKey[2])) continue;
376
+ queryClient.setQueryData(queryKey, previousEntry);
377
+ }
378
+
379
+ for (const [queryKey, previousEntry] of snapshot.myTasksEntries) {
380
+ queryClient.setQueryData<MyTasksCache>(queryKey, (currentEntry) =>
381
+ restoreMyTasksCacheFromSnapshot(currentEntry, previousEntry, taskIdSet)
382
+ );
383
+ }
384
+
385
+ for (const [queryKey, previousEntry] of snapshot.myCompletedTaskEntries) {
386
+ queryClient.setQueryData<MyCompletedTasksCache>(queryKey, (currentEntry) =>
387
+ restoreMyCompletedTasksCacheFromSnapshot(
388
+ currentEntry,
389
+ previousEntry,
390
+ taskIdSet
391
+ )
392
+ );
393
+ }
394
+ }
@@ -189,6 +189,8 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
189
189
  taskWsId: state.taskWsId,
190
190
  taskWorkspacePersonal: state.taskWorkspacePersonal,
191
191
  taskWorkspaceTier: state.taskWorkspaceTier,
192
+ canUseBoardAssignees: state.canUseBoardAssignees,
193
+ assigneeMemberSource: state.assigneeMemberSource,
192
194
  });
193
195
  return;
194
196
  }
@@ -210,6 +212,8 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
210
212
  taskWsId: currentTaskWsId,
211
213
  taskWorkspacePersonal: state.taskWorkspacePersonal,
212
214
  taskWorkspaceTier: state.taskWorkspaceTier,
215
+ canUseBoardAssignees: state.canUseBoardAssignees,
216
+ assigneeMemberSource: state.assigneeMemberSource,
213
217
  });
214
218
  return;
215
219
  }
@@ -223,8 +227,10 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
223
227
  openTask,
224
228
  openTaskById,
225
229
  queryClient,
230
+ state.assigneeMemberSource,
226
231
  state.availableLists,
227
232
  state.boardId,
233
+ state.canUseBoardAssignees,
228
234
  state.task?.id,
229
235
  state.taskWorkspacePersonal,
230
236
  state.taskWorkspaceTier,
@@ -238,9 +244,17 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
238
244
  return openTaskById(taskId, {
239
245
  taskWsId: wsId,
240
246
  taskWorkspacePersonal: isPersonalWorkspace,
247
+ canUseBoardAssignees: state.canUseBoardAssignees,
248
+ assigneeMemberSource: state.assigneeMemberSource,
241
249
  });
242
250
  },
243
- [isPersonalWorkspace, openTaskById, wsId]
251
+ [
252
+ isPersonalWorkspace,
253
+ openTaskById,
254
+ state.assigneeMemberSource,
255
+ state.canUseBoardAssignees,
256
+ wsId,
257
+ ]
244
258
  );
245
259
 
246
260
  useEffect(() => {
@@ -388,10 +402,14 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
388
402
  taskWsId: state.taskWsId,
389
403
  taskWorkspacePersonal: state.taskWorkspacePersonal,
390
404
  taskWorkspaceTier: state.taskWorkspaceTier,
405
+ canUseBoardAssignees: state.canUseBoardAssignees,
406
+ assigneeMemberSource: state.assigneeMemberSource,
391
407
  initialSharedContext: state.initialSharedContext,
392
408
  });
393
409
  }, [
394
410
  openTaskById,
411
+ state.assigneeMemberSource,
412
+ state.canUseBoardAssignees,
395
413
  state.initialSharedContext,
396
414
  state.availableLists,
397
415
  state.boardId,
@@ -539,6 +557,8 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
539
557
  taskLoadError={state.taskLoadError}
540
558
  taskHydrationVersion={state.taskHydrationVersion}
541
559
  isPersonalWorkspace={isPersonalWorkspace}
560
+ canUseBoardAssignees={state.canUseBoardAssignees}
561
+ assigneeMemberSource={state.assigneeMemberSource}
542
562
  parentTaskId={state.parentTaskId}
543
563
  parentTaskName={state.parentTaskName}
544
564
  pendingRelationship={state.pendingRelationship}
@@ -28,6 +28,8 @@ function updateBodyFadeAttribute(enabled: boolean) {
28
28
  interface QuickSettingsPopoverProps {
29
29
  /** Whether the workspace is personal (forces auto-assign to true) */
30
30
  isPersonalWorkspace?: boolean;
31
+ open?: boolean;
32
+ onOpenChange?: (open: boolean) => void;
31
33
  }
32
34
 
33
35
  /**
@@ -36,6 +38,8 @@ interface QuickSettingsPopoverProps {
36
38
  */
37
39
  export function QuickSettingsPopover({
38
40
  isPersonalWorkspace = false,
41
+ open,
42
+ onOpenChange,
39
43
  }: QuickSettingsPopoverProps) {
40
44
  const t = useTranslations('settings.tasks');
41
45
  const tCommon = useTranslations('common');
@@ -96,7 +100,7 @@ export function QuickSettingsPopover({
96
100
  : (settings?.task_auto_assign_to_self ?? false);
97
101
 
98
102
  return (
99
- <Popover>
103
+ <Popover open={open} onOpenChange={onOpenChange}>
100
104
  <Tooltip>
101
105
  <TooltipTrigger asChild>
102
106
  <PopoverTrigger asChild>
@@ -54,6 +54,7 @@ export interface TaskDescriptionEditorProps {
54
54
  onImageUpload?: (file: File) => Promise<string>;
55
55
  onEditorReady: (editor: Editor) => void;
56
56
  onConvertToTask?: () => void | Promise<void>;
57
+ onDescriptionSnapshotChange?: (description: JSONContent | null) => void;
57
58
  onDescriptionStorageLengthChange: (storageLength: number) => void;
58
59
  descriptionStorageLength: number;
59
60
  descriptionPercentLeft: number;
@@ -109,6 +110,7 @@ export function TaskDescriptionEditor({
109
110
  onImageUpload,
110
111
  onEditorReady,
111
112
  onConvertToTask,
113
+ onDescriptionSnapshotChange,
112
114
  onDescriptionStorageLengthChange,
113
115
  descriptionStorageLength,
114
116
  descriptionPercentLeft,
@@ -290,6 +292,7 @@ export function TaskDescriptionEditor({
290
292
  content={description}
291
293
  onChange={setDescription}
292
294
  onImmediateChange={(nextDescription) => {
295
+ onDescriptionSnapshotChange?.(nextDescription);
293
296
  if (allowYjsSync) {
294
297
  setDescription(nextDescription);
295
298
  }
@@ -16,7 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
16
16
  import { cn } from '@tuturuuu/utils/format';
17
17
  import { getTicketIdentifier } from '@tuturuuu/utils/task-helper';
18
18
  import { useTranslations } from 'next-intl';
19
- import type { ReactNode } from 'react';
19
+ import { type ReactNode, useState } from 'react';
20
20
  import { TaskViewerAvatarsComponent } from '../../user-presence-avatars';
21
21
  import { TaskDialogActions } from '../task-dialog-actions';
22
22
  import type {
@@ -268,6 +268,19 @@ export function TaskDialogHeader({
268
268
  onScrollToUserCursor,
269
269
  }: TaskDialogHeaderProps) {
270
270
  const t = useTranslations();
271
+ const [activeHeaderOverlay, setActiveHeaderOverlay] = useState<
272
+ 'quick-settings' | 'more-menu' | null
273
+ >(null);
274
+
275
+ const setHeaderOverlayOpen = (
276
+ overlay: 'quick-settings' | 'more-menu',
277
+ open: boolean
278
+ ) => {
279
+ setActiveHeaderOverlay((currentOverlay) => {
280
+ if (open) return overlay;
281
+ return currentOverlay === overlay ? null : currentOverlay;
282
+ });
283
+ };
271
284
 
272
285
  // Use custom headerInfo if provided, otherwise generate from task context
273
286
  const resolvedHeaderInfo =
@@ -452,7 +465,13 @@ export function TaskDialogHeader({
452
465
 
453
466
  {/* Quick Settings */}
454
467
  {!controlsDisabled && (
455
- <QuickSettingsPopover isPersonalWorkspace={isPersonalWorkspace} />
468
+ <QuickSettingsPopover
469
+ isPersonalWorkspace={isPersonalWorkspace}
470
+ open={activeHeaderOverlay === 'quick-settings'}
471
+ onOpenChange={(open) =>
472
+ setHeaderOverlayOpen('quick-settings', open)
473
+ }
474
+ />
456
475
  )}
457
476
 
458
477
  <TaskDialogActions
@@ -471,6 +490,10 @@ export function TaskDialogHeader({
471
490
  onOpenShareDialog={onOpenShareDialog}
472
491
  disabled={disabled}
473
492
  controlsDisabled={controlsDisabled}
493
+ moreMenuOpen={activeHeaderOverlay === 'more-menu'}
494
+ onMoreMenuOpenChange={(open) =>
495
+ setHeaderOverlayOpen('more-menu', open)
496
+ }
474
497
  />
475
498
 
476
499
  {/* Hide save button in edit mode when realtime is enabled (either cursors or Yjs sync) */}
@@ -21,6 +21,8 @@ interface TaskListSelectorProps {
21
21
  availableLists: TaskList[];
22
22
  disabled?: boolean;
23
23
  compact?: boolean;
24
+ open?: boolean;
25
+ onOpenChange?: (open: boolean) => void;
24
26
  onListChange: (listId: string) => void;
25
27
  }
26
28
 
@@ -31,11 +33,15 @@ export function TaskListSelector({
31
33
  availableLists,
32
34
  disabled = false,
33
35
  compact = false,
36
+ open,
37
+ onOpenChange,
34
38
  onListChange,
35
39
  }: TaskListSelectorProps) {
36
40
  const t = useTranslations();
37
- const [isPopoverOpen, setIsPopoverOpen] = useState(false);
41
+ const [uncontrolledPopoverOpen, setUncontrolledPopoverOpen] = useState(false);
38
42
  const [isCreateListDialogOpen, setIsCreateListDialogOpen] = useState(false);
43
+ const isPopoverOpen = open ?? uncontrolledPopoverOpen;
44
+ const setIsPopoverOpen = onOpenChange ?? setUncontrolledPopoverOpen;
39
45
 
40
46
  const statusLabels = useMemo(
41
47
  () => ({
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildRecoverableTaskDescriptionVersions,
4
+ extractRecoverableTaskDescriptionValue,
5
+ } from './description-versions';
6
+
7
+ const makeDoc = (text: string) =>
8
+ JSON.stringify({
9
+ type: 'doc',
10
+ content: [
11
+ {
12
+ type: 'paragraph',
13
+ content: [{ type: 'text', text }],
14
+ },
15
+ ],
16
+ });
17
+
18
+ describe('task description tracked versions', () => {
19
+ it('does not treat null, empty docs, or legacy placeholders as recoverable', () => {
20
+ expect(extractRecoverableTaskDescriptionValue(null)).toBeNull();
21
+ expect(extractRecoverableTaskDescriptionValue('')).toBeNull();
22
+ expect(extractRecoverableTaskDescriptionValue('has_content')).toBeNull();
23
+ expect(
24
+ extractRecoverableTaskDescriptionValue(
25
+ JSON.stringify({
26
+ type: 'doc',
27
+ content: [{ type: 'paragraph' }],
28
+ })
29
+ )
30
+ ).toBeNull();
31
+ });
32
+
33
+ it('normalizes plain text and serialized TipTap content', () => {
34
+ const plain = extractRecoverableTaskDescriptionValue('Plain description');
35
+ const serialized = extractRecoverableTaskDescriptionValue(
36
+ makeDoc('Serialized description')
37
+ );
38
+
39
+ expect(plain?.description).toContain('Plain description');
40
+ expect(plain?.previewText).toBe('Plain description');
41
+ expect(serialized?.description).toContain('Serialized description');
42
+ expect(serialized?.previewText).toBe('Serialized description');
43
+ });
44
+
45
+ it('prefers the previous value from the latest wipe entry', () => {
46
+ const versions = buildRecoverableTaskDescriptionVersions([
47
+ {
48
+ id: 'older',
49
+ changed_at: '2026-06-25T10:00:00.000Z',
50
+ change_type: 'field_updated',
51
+ field_name: 'description',
52
+ old_value: makeDoc('Older version'),
53
+ new_value: makeDoc('Intermediate version'),
54
+ },
55
+ {
56
+ id: 'wipe',
57
+ changed_at: '2026-06-26T10:00:00.000Z',
58
+ change_type: 'field_updated',
59
+ field_name: 'description',
60
+ old_value: makeDoc('Latest real version'),
61
+ new_value: null,
62
+ },
63
+ ]);
64
+
65
+ expect(versions[0]?.historyId).toBe('wipe');
66
+ expect(versions[0]?.source).toBe('old_value');
67
+ expect(versions[0]?.reason).toBe('before_clear');
68
+ expect(versions[0]?.previewText).toBe('Latest real version');
69
+ });
70
+
71
+ it('dedupes identical canonical descriptions while keeping newest first', () => {
72
+ const duplicated = makeDoc('Same description');
73
+ const versions = buildRecoverableTaskDescriptionVersions([
74
+ {
75
+ id: 'newer',
76
+ changed_at: '2026-06-26T10:00:00.000Z',
77
+ change_type: 'field_updated',
78
+ field_name: 'description',
79
+ old_value: makeDoc('Different description'),
80
+ new_value: duplicated,
81
+ },
82
+ {
83
+ id: 'older',
84
+ changed_at: '2026-06-25T10:00:00.000Z',
85
+ change_type: 'field_updated',
86
+ field_name: 'description',
87
+ old_value: duplicated,
88
+ new_value: duplicated,
89
+ },
90
+ ]);
91
+
92
+ expect(versions.map((version) => version.previewText)).toEqual([
93
+ 'Same description',
94
+ 'Different description',
95
+ ]);
96
+ });
97
+ });