@tuturuuu/ui 0.9.0 → 0.10.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 (94) hide show
  1. package/CHANGELOG.md +29 -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/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  17. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  18. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  19. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  20. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  21. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  22. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  23. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  24. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  25. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  26. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  27. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  28. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  29. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  30. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  31. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  33. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  34. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  35. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  36. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  37. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  38. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  39. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  40. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  41. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  42. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  43. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  44. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  45. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  46. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  47. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  51. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  52. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  53. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +116 -1
  54. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  55. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  56. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  57. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +21 -0
  58. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  59. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  60. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  61. package/src/components/ui/tu-do/shared/board-client.tsx +14 -4
  62. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  63. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  64. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  65. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  66. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  69. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  87. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +9 -0
  88. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  89. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  90. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  91. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  92. package/src/hooks/useBoardPresence.ts +364 -0
  93. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  94. 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>
@@ -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
  () => ({
@@ -1,5 +1,8 @@
1
1
  import { useQuery } from '@tanstack/react-query';
2
- import { listWorkspaceTaskProjects } from '@tuturuuu/internal-api/tasks';
2
+ import {
3
+ listWorkspaceTaskBoardViewableMembers,
4
+ listWorkspaceTaskProjects,
5
+ } from '@tuturuuu/internal-api/tasks';
3
6
  import { createClient } from '@tuturuuu/supabase/next/client';
4
7
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
5
8
  import {
@@ -89,10 +92,19 @@ interface UseTaskDataProps {
89
92
  isOpen: boolean;
90
93
  propAvailableLists?: TaskList[];
91
94
  taskSearchQuery?: string;
95
+ canUseBoardAssignees?: boolean;
96
+ assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
92
97
  /** Pre-loaded data for shared task context - bypasses internal fetches when provided */
93
98
  sharedContext?: SharedTaskContext;
94
99
  }
95
100
 
101
+ type TaskDialogWorkspaceMember = {
102
+ id: string;
103
+ user_id: string;
104
+ display_name: string;
105
+ avatar_url?: string | null;
106
+ };
107
+
96
108
  // UUID validation regex
97
109
  const UUID_REGEX =
98
110
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -107,6 +119,8 @@ export function useTaskData({
107
119
  isOpen,
108
120
  propAvailableLists,
109
121
  taskSearchQuery = '',
122
+ canUseBoardAssignees,
123
+ assigneeMemberSource,
110
124
  sharedContext,
111
125
  }: UseTaskDataProps) {
112
126
  // If sharedContext is provided, use pre-loaded data and skip fetches
@@ -174,17 +188,19 @@ export function useTaskData({
174
188
  );
175
189
  const workspaceLabels =
176
190
  sharedContext?.workspaceLabels || fetchedWorkspaceLabels;
191
+ const effectiveAssigneeMemberSource = assigneeMemberSource ?? 'workspace';
192
+ const shouldLoadWorkspaceMembers =
193
+ canUseBoardAssignees !== false && effectiveAssigneeMemberSource !== 'board';
194
+ const shouldLoadBoardViewableMembers =
195
+ canUseBoardAssignees !== false &&
196
+ effectiveAssigneeMemberSource !== 'workspace' &&
197
+ !!boardId;
177
198
 
178
199
  // Workspace members
179
200
  const { data: fetchedWorkspaceMembers = [] } = useQuery<
180
- Array<{
181
- id: string;
182
- user_id: string;
183
- display_name: string;
184
- avatar_url?: string | null;
185
- }>
201
+ TaskDialogWorkspaceMember[]
186
202
  >({
187
- queryKey: ['workspace-members', realWorkspaceId],
203
+ queryKey: ['workspace-members', realWorkspaceId, 'workspace'],
188
204
  queryFn: async () => {
189
205
  if (!realWorkspaceId || !isValidWsId) return [];
190
206
 
@@ -234,11 +250,64 @@ export function useTaskData({
234
250
  (a.display_name || '').localeCompare(b.display_name || '')
235
251
  );
236
252
  },
237
- enabled: !!realWorkspaceId && isOpen && isValidWsId && !hasSharedContext,
253
+ enabled:
254
+ !!realWorkspaceId &&
255
+ isOpen &&
256
+ isValidWsId &&
257
+ !hasSharedContext &&
258
+ shouldLoadWorkspaceMembers,
259
+ staleTime: 5 * 60 * 1000, // 5 minutes
260
+ });
261
+ const { data: fetchedBoardViewableMembers = [] } = useQuery<
262
+ TaskDialogWorkspaceMember[]
263
+ >({
264
+ queryKey: ['task-board-viewable-members', realWorkspaceId, boardId],
265
+ queryFn: async () => {
266
+ if (!realWorkspaceId || !isValidWsId || !boardId) return [];
267
+
268
+ const payload = await listWorkspaceTaskBoardViewableMembers(
269
+ realWorkspaceId,
270
+ boardId
271
+ );
272
+ const members = Array.isArray(payload?.members) ? payload.members : [];
273
+
274
+ return members
275
+ .map((member) => ({
276
+ id: member.user_id,
277
+ user_id: member.user_id,
278
+ display_name: member.display_name || member.email || member.user_id,
279
+ avatar_url: member.avatar_url,
280
+ }))
281
+ .sort((a, b) =>
282
+ (a.display_name || '').localeCompare(b.display_name || '')
283
+ );
284
+ },
285
+ enabled:
286
+ !!realWorkspaceId &&
287
+ isOpen &&
288
+ isValidWsId &&
289
+ !hasSharedContext &&
290
+ shouldLoadBoardViewableMembers,
238
291
  staleTime: 5 * 60 * 1000, // 5 minutes
239
292
  });
293
+ const mergedFetchedWorkspaceMembers = useMemo(() => {
294
+ const seen = new Set<string>();
295
+ const merged: TaskDialogWorkspaceMember[] = [];
296
+
297
+ for (const member of [
298
+ ...fetchedWorkspaceMembers,
299
+ ...fetchedBoardViewableMembers,
300
+ ]) {
301
+ const memberId = member.user_id || member.id;
302
+ if (!memberId || seen.has(memberId)) continue;
303
+ seen.add(memberId);
304
+ merged.push(member);
305
+ }
306
+
307
+ return merged;
308
+ }, [fetchedBoardViewableMembers, fetchedWorkspaceMembers]);
240
309
  const workspaceMembers =
241
- sharedContext?.workspaceMembers || fetchedWorkspaceMembers;
310
+ sharedContext?.workspaceMembers || mergedFetchedWorkspaceMembers;
242
311
 
243
312
  // Task projects
244
313
  const { data: fetchedTaskProjects = [] } = useQuery({