@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
@@ -30,6 +30,7 @@ import {
30
30
  } from '@tuturuuu/icons';
31
31
  import {
32
32
  getWorkspaceTask,
33
+ listWorkspaceTaskBoardViewableMembers,
33
34
  listWorkspaceTaskLists,
34
35
  listWorkspaceTaskProjects,
35
36
  removeCurrentUserTaskPersonalPlacement,
@@ -50,7 +51,10 @@ import {
50
51
  import { useCalendarPreferences } from '@tuturuuu/ui/hooks/use-calendar-preferences';
51
52
  import { useTaskActions } from '@tuturuuu/ui/hooks/use-task-actions';
52
53
  import { useUserBooleanConfig } from '@tuturuuu/ui/hooks/use-user-config';
53
- import { useWorkspaceMembers } from '@tuturuuu/ui/hooks/use-workspace-members';
54
+ import {
55
+ useWorkspaceMembers,
56
+ type WorkspaceMember,
57
+ } from '@tuturuuu/ui/hooks/use-workspace-members';
54
58
  import {
55
59
  HoverCard,
56
60
  HoverCardContent,
@@ -152,6 +156,11 @@ import {
152
156
  import { getTaskCardVisibilityState } from './task-card-visibility';
153
157
  import { TaskSchedulingBadge } from './task-scheduling-badge';
154
158
 
159
+ export type TaskCardAssigneeMemberSource =
160
+ | 'workspace'
161
+ | 'board'
162
+ | 'workspace-and-board';
163
+
155
164
  export interface TaskCardProps {
156
165
  task: Task;
157
166
  boardId: string;
@@ -163,6 +172,8 @@ export interface TaskCardProps {
163
172
  isSelected?: boolean;
164
173
  isMultiSelectMode?: boolean;
165
174
  isPersonalWorkspace?: boolean;
175
+ canUseBoardAssignees?: boolean;
176
+ assigneeMemberSource?: TaskCardAssigneeMemberSource;
166
177
  onSelect?: (taskId: string, event: React.MouseEvent) => void;
167
178
  onClearSelection?: () => void;
168
179
  dragDisabled?: boolean;
@@ -307,6 +318,8 @@ function TaskCardInner({
307
318
  isSelected = false,
308
319
  isMultiSelectMode = false,
309
320
  isPersonalWorkspace = false,
321
+ canUseBoardAssignees,
322
+ assigneeMemberSource,
310
323
  onSelect,
311
324
  onClearSelection,
312
325
  dragDisabled: dragDisabledProp = false,
@@ -444,12 +457,59 @@ function TaskCardInner({
444
457
  isMultiSelectMode,
445
458
  onClearSelection,
446
459
  });
460
+ const shouldUseBoardAssignees = canUseBoardAssignees ?? !isPersonalWorkspace;
461
+ const effectiveAssigneeMemberSource =
462
+ assigneeMemberSource ?? (isPersonalWorkspace ? 'board' : 'workspace');
463
+ const shouldLoadWorkspaceMembers =
464
+ shouldUseBoardAssignees && effectiveAssigneeMemberSource !== 'board';
465
+ const shouldLoadBoardViewableMembers =
466
+ shouldUseBoardAssignees && effectiveAssigneeMemberSource !== 'workspace';
447
467
 
448
468
  // Fetch workspace members
449
- const { data: workspaceMembers = [], isLoading: membersLoading } =
450
- useWorkspaceMembers(effectiveWorkspaceId, {
451
- enabled: !!effectiveWorkspaceId && !isPersonalWorkspace,
452
- });
469
+ const normalMembersQuery = useWorkspaceMembers(effectiveWorkspaceId, {
470
+ enabled: !!effectiveWorkspaceId && shouldLoadWorkspaceMembers,
471
+ });
472
+ const boardViewableMembersQuery = useQuery({
473
+ queryKey: ['task-board-viewable-members', effectiveWorkspaceId, boardId],
474
+ queryFn: async (): Promise<WorkspaceMember[]> => {
475
+ if (!effectiveWorkspaceId || !boardId) return [];
476
+
477
+ const payload = await listWorkspaceTaskBoardViewableMembers(
478
+ effectiveWorkspaceId,
479
+ boardId
480
+ );
481
+ const members = Array.isArray(payload?.members) ? payload.members : [];
482
+
483
+ return members.map((member) => ({
484
+ id: member.user_id,
485
+ user_id: member.user_id,
486
+ workspace_id: effectiveWorkspaceId,
487
+ display_name: member.display_name ?? member.email ?? member.user_id,
488
+ email: member.email ?? undefined,
489
+ avatar_url: member.avatar_url ?? undefined,
490
+ }));
491
+ },
492
+ enabled:
493
+ !!effectiveWorkspaceId && !!boardId && shouldLoadBoardViewableMembers,
494
+ staleTime: 5 * 60 * 1000,
495
+ });
496
+ const normalWorkspaceMembers = normalMembersQuery.data ?? [];
497
+ const boardViewableMembers = boardViewableMembersQuery.data ?? [];
498
+ const workspaceMembers = useMemo(() => {
499
+ const seen = new Set<string>();
500
+ const merged: WorkspaceMember[] = [];
501
+
502
+ for (const member of [...normalWorkspaceMembers, ...boardViewableMembers]) {
503
+ const memberId = member.user_id ?? member.id;
504
+ if (!memberId || seen.has(memberId)) continue;
505
+ seen.add(memberId);
506
+ merged.push(member);
507
+ }
508
+
509
+ return merged;
510
+ }, [boardViewableMembers, normalWorkspaceMembers]);
511
+ const membersLoading =
512
+ normalMembersQuery.isLoading || boardViewableMembersQuery.isLoading;
453
513
 
454
514
  const relationshipSummary =
455
515
  task.relationship_summary ??
@@ -1034,6 +1094,12 @@ function TaskCardInner({
1034
1094
  task,
1035
1095
  boardId,
1036
1096
  availableLists,
1097
+ canUseBoardAssignees: task.source_workspace_id
1098
+ ? true
1099
+ : shouldUseBoardAssignees,
1100
+ assigneeMemberSource: task.source_workspace_id
1101
+ ? 'workspace'
1102
+ : effectiveAssigneeMemberSource,
1037
1103
  effectiveWorkspaceId,
1038
1104
  isPersonalWorkspace,
1039
1105
  })
@@ -1042,6 +1108,8 @@ function TaskCardInner({
1042
1108
  task,
1043
1109
  boardId,
1044
1110
  availableLists,
1111
+ shouldUseBoardAssignees,
1112
+ effectiveAssigneeMemberSource,
1045
1113
  effectiveWorkspaceId,
1046
1114
  isPersonalWorkspace,
1047
1115
  openTaskById,
@@ -1075,6 +1143,8 @@ function TaskCardInner({
1075
1143
  openTask(task, boardId, availableLists, false, {
1076
1144
  taskWsId: effectiveWorkspaceId,
1077
1145
  taskWorkspacePersonal: isPersonalWorkspace,
1146
+ canUseBoardAssignees: shouldUseBoardAssignees,
1147
+ assigneeMemberSource: effectiveAssigneeMemberSource,
1078
1148
  });
1079
1149
  }
1080
1150
  },
@@ -1082,7 +1152,9 @@ function TaskCardInner({
1082
1152
  task,
1083
1153
  boardId,
1084
1154
  effectiveWorkspaceId,
1155
+ effectiveAssigneeMemberSource,
1085
1156
  isPersonalWorkspace,
1157
+ shouldUseBoardAssignees,
1086
1158
  isPersonalExternalTask,
1087
1159
  isMultiSelectMode,
1088
1160
  availableLists,
@@ -2274,8 +2346,8 @@ function TaskCardInner({
2274
2346
  </>
2275
2347
  )}
2276
2348
 
2277
- {/* Assignee Actions - Show if not personal workspace */}
2278
- {!isPersonalWorkspace && (
2349
+ {/* Assignee Actions */}
2350
+ {shouldUseBoardAssignees && (
2279
2351
  <TaskAssigneesMenu
2280
2352
  taskAssignees={displayAssignees}
2281
2353
  availableMembers={workspaceMembers}
@@ -2345,7 +2417,7 @@ function TaskCardInner({
2345
2417
  )}
2346
2418
  </div>
2347
2419
  {/* Assignee: left, not cut off */}
2348
- {!isPersonalWorkspace && (
2420
+ {shouldUseBoardAssignees && (
2349
2421
  <div className="flex flex-none items-start justify-start">
2350
2422
  <AssigneeSelect
2351
2423
  taskId={task.id}
@@ -10,6 +10,7 @@ import { useTranslations } from 'next-intl';
10
10
  import React, { useCallback, useEffect, useRef, useState } from 'react';
11
11
  import type { DragPreviewPosition } from './kanban/dnd/use-kanban-dnd';
12
12
  import { MeasuredTaskCard } from './task';
13
+ import type { TaskCardAssigneeMemberSource } from './task-card/task-card';
13
14
 
14
15
  const VIRTUALIZE_THRESHOLD = 60; // only virtualize for fairly large lists
15
16
  const ESTIMATED_ITEM_HEIGHT = 96; // px including margin (space-y-2 gap)
@@ -33,6 +34,8 @@ interface VirtualizedTaskListProps {
33
34
  isMultiSelectMode?: boolean;
34
35
  selectedTasks?: Set<string>;
35
36
  isPersonalWorkspace?: boolean;
37
+ canUseBoardAssignees?: boolean;
38
+ assigneeMemberSource?: TaskCardAssigneeMemberSource;
36
39
  onTaskSelect?: (taskId: string, event: React.MouseEvent) => void;
37
40
  onClearSelection?: () => void;
38
41
  dragPreviewPosition?: DragPreviewPosition | null;
@@ -56,6 +59,8 @@ interface TaskListContentProps {
56
59
  isMultiSelectMode?: boolean;
57
60
  selectedTasks?: Set<string>;
58
61
  isPersonalWorkspace?: boolean;
62
+ canUseBoardAssignees?: boolean;
63
+ assigneeMemberSource?: TaskCardAssigneeMemberSource;
59
64
  onTaskSelect?: (taskId: string, event: React.MouseEvent) => void;
60
65
  onClearSelection?: () => void;
61
66
  dragPreviewPosition?: DragPreviewPosition | null;
@@ -125,6 +130,8 @@ function TaskListContent({
125
130
  isMultiSelectMode,
126
131
  selectedTasks,
127
132
  isPersonalWorkspace,
133
+ canUseBoardAssignees,
134
+ assigneeMemberSource,
128
135
  onTaskSelect,
129
136
  onClearSelection,
130
137
  dragPreviewPosition,
@@ -165,6 +172,8 @@ function TaskListContent({
165
172
  )}
166
173
  isMultiSelectMode={isMultiSelectMode}
167
174
  isPersonalWorkspace={isPersonalWorkspace}
175
+ canUseBoardAssignees={canUseBoardAssignees}
176
+ assigneeMemberSource={assigneeMemberSource}
168
177
  onSelect={onTaskSelect}
169
178
  onClearSelection={onClearSelection}
170
179
  suppressSortableTransform={suppressSortableTransform}
@@ -232,6 +241,8 @@ function VirtualizedTaskListInner({
232
241
  isMultiSelectMode,
233
242
  selectedTasks,
234
243
  isPersonalWorkspace,
244
+ canUseBoardAssignees,
245
+ assigneeMemberSource,
235
246
  onTaskSelect,
236
247
  onClearSelection,
237
248
  dragPreviewPosition,
@@ -470,6 +481,8 @@ function VirtualizedTaskListInner({
470
481
  isMultiSelectMode={isMultiSelectMode}
471
482
  selectedTasks={selectedTasks}
472
483
  isPersonalWorkspace={isPersonalWorkspace}
484
+ canUseBoardAssignees={canUseBoardAssignees}
485
+ assigneeMemberSource={assigneeMemberSource}
473
486
  onTaskSelect={onTaskSelect}
474
487
  onClearSelection={onClearSelection}
475
488
  dragPreviewPosition={dragPreviewPosition}
@@ -500,6 +513,8 @@ function VirtualizedTaskListInner({
500
513
  isMultiSelectMode={isMultiSelectMode}
501
514
  selectedTasks={selectedTasks}
502
515
  isPersonalWorkspace={isPersonalWorkspace}
516
+ canUseBoardAssignees={canUseBoardAssignees}
517
+ assigneeMemberSource={assigneeMemberSource}
503
518
  onTaskSelect={onTaskSelect}
504
519
  onClearSelection={onClearSelection}
505
520
  dragPreviewPosition={dragPreviewPosition}
@@ -28,6 +28,9 @@ interface TimelineGridProps {
28
28
  localTasks: Task[];
29
29
  boardId?: string;
30
30
  wsId?: string;
31
+ isPersonalWorkspace?: boolean;
32
+ canUseBoardAssignees?: boolean;
33
+ assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
31
34
  dayWidth: number;
32
35
  sidebarWidth: number;
33
36
  timelineWidth: number;
@@ -71,6 +74,9 @@ export function TimelineGrid({
71
74
  localTasks,
72
75
  boardId,
73
76
  wsId,
77
+ isPersonalWorkspace,
78
+ canUseBoardAssignees,
79
+ assigneeMemberSource,
74
80
  dayWidth,
75
81
  sidebarWidth,
76
82
  timelineWidth,
@@ -223,6 +229,9 @@ export function TimelineGrid({
223
229
  )}
224
230
  boardId={boardId}
225
231
  wsId={wsId}
232
+ isPersonalWorkspace={isPersonalWorkspace}
233
+ canUseBoardAssignees={canUseBoardAssignees}
234
+ assigneeMemberSource={assigneeMemberSource}
226
235
  dayWidth={dayWidth}
227
236
  timelineWidth={timelineWidth}
228
237
  sidebarWidth={sidebarWidth}
@@ -39,6 +39,9 @@ interface TimelineTaskRowProps {
39
39
  lists: TaskList[];
40
40
  boardId?: string;
41
41
  wsId?: string;
42
+ isPersonalWorkspace?: boolean;
43
+ canUseBoardAssignees?: boolean;
44
+ assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
42
45
  dayWidth: number;
43
46
  timelineWidth: number;
44
47
  sidebarWidth: number;
@@ -75,6 +78,9 @@ export function TimelineTaskRow({
75
78
  lists,
76
79
  boardId,
77
80
  wsId,
81
+ isPersonalWorkspace,
82
+ canUseBoardAssignees,
83
+ assigneeMemberSource,
78
84
  dayWidth,
79
85
  timelineWidth,
80
86
  sidebarWidth,
@@ -235,6 +241,9 @@ export function TimelineTaskRow({
235
241
  boardId={boardId}
236
242
  workspaceId={wsId}
237
243
  lists={lists}
244
+ isPersonalWorkspace={isPersonalWorkspace}
245
+ canUseBoardAssignees={canUseBoardAssignees}
246
+ assigneeMemberSource={assigneeMemberSource}
238
247
  onUpdate={onActionsUpdate ?? (() => undefined)}
239
248
  open={actionsMenu.open}
240
249
  onOpenChange={(open) =>
@@ -34,6 +34,9 @@ interface TimelineToolbarProps {
34
34
  lists: TaskList[];
35
35
  boardId?: string;
36
36
  wsId?: string;
37
+ isPersonalWorkspace?: boolean;
38
+ canUseBoardAssignees?: boolean;
39
+ assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
37
40
  primaryCreateListId: string | null;
38
41
  dayWidth: number;
39
42
  setDayWidth: (value: number) => void;
@@ -66,6 +69,9 @@ export function TimelineToolbar({
66
69
  lists,
67
70
  boardId,
68
71
  wsId,
72
+ isPersonalWorkspace,
73
+ canUseBoardAssignees,
74
+ assigneeMemberSource,
69
75
  primaryCreateListId,
70
76
  dayWidth,
71
77
  setDayWidth,
@@ -221,6 +227,9 @@ export function TimelineToolbar({
221
227
  boardId={boardId}
222
228
  workspaceId={wsId}
223
229
  lists={lists}
230
+ isPersonalWorkspace={isPersonalWorkspace}
231
+ canUseBoardAssignees={canUseBoardAssignees}
232
+ assigneeMemberSource={assigneeMemberSource}
224
233
  onUpdate={onActionsUpdate ?? (() => undefined)}
225
234
  open={openTaskMenu?.taskId === task.id}
226
235
  onOpenChange={(open) =>
@@ -65,6 +65,9 @@ export interface TimelineProps {
65
65
  lists: TaskList[];
66
66
  boardId?: string;
67
67
  wsId?: string;
68
+ isPersonalWorkspace?: boolean;
69
+ canUseBoardAssignees?: boolean;
70
+ assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
68
71
  className?: string;
69
72
  onTaskPartialUpdate?: (taskId: string, updates: Partial<Task>) => void;
70
73
  }
@@ -110,6 +113,9 @@ export function TimelineBoard({
110
113
  lists,
111
114
  boardId,
112
115
  wsId,
116
+ isPersonalWorkspace = false,
117
+ canUseBoardAssignees,
118
+ assigneeMemberSource,
113
119
  className,
114
120
  onTaskPartialUpdate,
115
121
  }: TimelineProps) {
@@ -227,6 +233,9 @@ export function TimelineBoard({
227
233
  (task: Task) => task.source_workspace_id ?? wsId ?? null,
228
234
  [wsId]
229
235
  );
236
+ const showAssignees = canUseBoardAssignees ?? !isPersonalWorkspace;
237
+ const effectiveAssigneeMemberSource =
238
+ assigneeMemberSource ?? (isPersonalWorkspace ? 'board' : 'workspace');
230
239
 
231
240
  const openTimelineTask = useCallback(
232
241
  (task: Task) => {
@@ -240,7 +249,13 @@ export function TimelineBoard({
240
249
  boardId,
241
250
  availableLists: lists,
242
251
  effectiveWorkspaceId: wsId,
243
- isPersonalWorkspace: Boolean(wsId),
252
+ isPersonalWorkspace,
253
+ canUseBoardAssignees: task.source_workspace_id
254
+ ? true
255
+ : showAssignees,
256
+ assigneeMemberSource: task.source_workspace_id
257
+ ? 'workspace'
258
+ : effectiveAssigneeMemberSource,
244
259
  })
245
260
  );
246
261
  return;
@@ -248,10 +263,21 @@ export function TimelineBoard({
248
263
 
249
264
  openTask(task, boardId, lists, false, {
250
265
  taskWsId: wsId,
251
- taskWorkspacePersonal: Boolean(wsId),
266
+ taskWorkspacePersonal: isPersonalWorkspace,
267
+ canUseBoardAssignees: showAssignees,
268
+ assigneeMemberSource: effectiveAssigneeMemberSource,
252
269
  });
253
270
  },
254
- [boardId, lists, openTask, openTaskById, wsId]
271
+ [
272
+ boardId,
273
+ effectiveAssigneeMemberSource,
274
+ isPersonalWorkspace,
275
+ lists,
276
+ openTask,
277
+ openTaskById,
278
+ showAssignees,
279
+ wsId,
280
+ ]
255
281
  );
256
282
 
257
283
  const clearDraft = useCallback((taskId: string) => {
@@ -674,6 +700,9 @@ export function TimelineBoard({
674
700
  <TimelineToolbar
675
701
  boardId={boardId}
676
702
  wsId={wsId}
703
+ isPersonalWorkspace={isPersonalWorkspace}
704
+ canUseBoardAssignees={showAssignees}
705
+ assigneeMemberSource={effectiveAssigneeMemberSource}
677
706
  dayWidth={dayWidth}
678
707
  density={density}
679
708
  formatLongDate={formatLongDate}
@@ -703,6 +732,9 @@ export function TimelineBoard({
703
732
  barHeight={densityConfig.barHeight}
704
733
  boardId={boardId}
705
734
  wsId={wsId}
735
+ isPersonalWorkspace={isPersonalWorkspace}
736
+ canUseBoardAssignees={showAssignees}
737
+ assigneeMemberSource={effectiveAssigneeMemberSource}
706
738
  dayWidth={dayWidth}
707
739
  draggedUnscheduledTaskId={draggedUnscheduledTaskId}
708
740
  dropPreview={dropPreview}
@@ -209,7 +209,7 @@ export function TaskBoardForm({
209
209
  autoComplete="off"
210
210
  autoFocus
211
211
  {...field}
212
- className="h-10"
212
+ className="h-10 bg-background"
213
213
  />
214
214
  </FormControl>
215
215
  <FormMessage />
@@ -190,6 +190,46 @@ describe('useTaskLabelManagement', () => {
190
190
  );
191
191
  });
192
192
 
193
+ it('updates full-board and task detail caches without invalidating visible board queries', async () => {
194
+ queryClient.setQueryData(['tasks', 'board-1'], [mockTask]);
195
+ queryClient.setQueryData(['tasks-full', 'board-1', 'all'], [mockTask]);
196
+ queryClient.setQueryData(['task', 'task-1'], mockTask);
197
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
198
+
199
+ const { result } = renderHook(
200
+ () =>
201
+ useTaskLabelManagement({
202
+ task: mockTask,
203
+ boardId: 'board-1',
204
+ workspaceLabels: mockWorkspaceLabels,
205
+ workspaceId: 'ws-1',
206
+ taskId: 'task-1',
207
+ }),
208
+ { wrapper }
209
+ );
210
+
211
+ await act(async () => {
212
+ await result.current.toggleTaskLabel('label-3');
213
+ });
214
+
215
+ expect(
216
+ queryClient
217
+ .getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])?.[0]
218
+ ?.labels?.some((label) => label.id === 'label-3')
219
+ ).toBe(true);
220
+ expect(
221
+ queryClient
222
+ .getQueryData<Task>(['task', 'task-1'])
223
+ ?.labels?.some((label) => label.id === 'label-3')
224
+ ).toBe(true);
225
+ expect(invalidateSpy).not.toHaveBeenCalledWith({
226
+ queryKey: ['tasks', 'board-1'],
227
+ });
228
+ expect(invalidateSpy).not.toHaveBeenCalledWith({
229
+ queryKey: ['tasks-full', 'board-1'],
230
+ });
231
+ });
232
+
193
233
  it('should rollback on error and show toast', async () => {
194
234
  mockRemoveWorkspaceTaskLabel.mockRejectedValueOnce(
195
235
  new Error('Database error')
@@ -197,6 +237,8 @@ describe('useTaskLabelManagement', () => {
197
237
 
198
238
  const originalTasks = [mockTask];
199
239
  queryClient.setQueryData(['tasks', 'board-1'], originalTasks);
240
+ queryClient.setQueryData(['tasks-full', 'board-1', 'all'], originalTasks);
241
+ queryClient.setQueryData(['task', 'task-1'], mockTask);
200
242
 
201
243
  const { result } = renderHook(
202
244
  () =>
@@ -219,6 +261,12 @@ describe('useTaskLabelManagement', () => {
219
261
  'board-1',
220
262
  ]);
221
263
  expect(cachedTasks).toEqual(originalTasks);
264
+ expect(
265
+ queryClient.getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])
266
+ ).toEqual(originalTasks);
267
+ expect(queryClient.getQueryData<Task>(['task', 'task-1'])).toEqual(
268
+ mockTask
269
+ );
222
270
 
223
271
  // Verify error toast was shown with the correct format
224
272
  expect(mockToast.error).toHaveBeenCalledWith('Error', {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+ import { act, renderHook } from '@testing-library/react';
7
+ import type { Task } from '@tuturuuu/types/primitives/Task';
8
+ import type { ReactNode } from 'react';
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { useTaskProjectManagement } from '../useTaskProjectManagement';
11
+
12
+ vi.mock('@tuturuuu/ui/sonner', () => ({
13
+ toast: {
14
+ error: vi.fn(),
15
+ success: vi.fn(),
16
+ warning: vi.fn(),
17
+ },
18
+ }));
19
+
20
+ vi.mock('@tuturuuu/internal-api/tasks', () => ({
21
+ createWorkspaceTaskProject: vi.fn(),
22
+ updateWorkspaceTask: vi.fn(),
23
+ }));
24
+
25
+ describe('useTaskProjectManagement', () => {
26
+ let queryClient: QueryClient;
27
+ let mockUpdateWorkspaceTask: any;
28
+
29
+ const project = {
30
+ id: 'project-1',
31
+ name: 'Roadmap',
32
+ status: 'active',
33
+ };
34
+
35
+ const mockTask = {
36
+ id: 'task-1',
37
+ name: 'Test Task',
38
+ list_id: 'list-1',
39
+ display_number: 1,
40
+ created_at: '2026-01-01T00:00:00.000Z',
41
+ projects: [],
42
+ } satisfies Task;
43
+
44
+ const wrapper = ({ children }: { children: ReactNode }) => (
45
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
46
+ );
47
+
48
+ beforeEach(async () => {
49
+ queryClient = new QueryClient({
50
+ defaultOptions: {
51
+ queries: { retry: false },
52
+ mutations: { retry: false },
53
+ },
54
+ });
55
+
56
+ const { updateWorkspaceTask } = await import(
57
+ '@tuturuuu/internal-api/tasks'
58
+ );
59
+ mockUpdateWorkspaceTask = updateWorkspaceTask as any;
60
+
61
+ vi.clearAllMocks();
62
+ mockUpdateWorkspaceTask.mockResolvedValue({ task: { id: 'task-1' } });
63
+ });
64
+
65
+ it('updates full-board and task detail caches without invalidating visible board queries', async () => {
66
+ queryClient.setQueryData(['tasks', 'board-1'], [mockTask]);
67
+ queryClient.setQueryData(['tasks-full', 'board-1', 'all'], [mockTask]);
68
+ queryClient.setQueryData(['task', 'task-1'], mockTask);
69
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
70
+
71
+ const { result } = renderHook(
72
+ () =>
73
+ useTaskProjectManagement({
74
+ task: mockTask,
75
+ boardId: 'board-1',
76
+ workspaceProjects: [project],
77
+ workspaceId: 'ws-1',
78
+ taskId: 'task-1',
79
+ }),
80
+ { wrapper }
81
+ );
82
+
83
+ await act(async () => {
84
+ await result.current.toggleTaskProject('project-1');
85
+ });
86
+
87
+ expect(
88
+ queryClient
89
+ .getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])?.[0]
90
+ ?.projects?.some((entry) => entry.id === 'project-1')
91
+ ).toBe(true);
92
+ expect(
93
+ queryClient
94
+ .getQueryData<Task>(['task', 'task-1'])
95
+ ?.projects?.some((entry) => entry.id === 'project-1')
96
+ ).toBe(true);
97
+ expect(invalidateSpy).not.toHaveBeenCalledWith({
98
+ queryKey: ['tasks', 'board-1'],
99
+ });
100
+ expect(invalidateSpy).not.toHaveBeenCalledWith({
101
+ queryKey: ['tasks-full', 'board-1'],
102
+ });
103
+ });
104
+
105
+ it('rolls back every visible cache when the project update fails', async () => {
106
+ const taskWithProject = {
107
+ ...mockTask,
108
+ projects: [project],
109
+ } as Task;
110
+ mockUpdateWorkspaceTask.mockRejectedValueOnce(new Error('Database error'));
111
+ queryClient.setQueryData(['tasks', 'board-1'], [taskWithProject]);
112
+ queryClient.setQueryData(
113
+ ['tasks-full', 'board-1', 'all'],
114
+ [taskWithProject]
115
+ );
116
+ queryClient.setQueryData(['task', 'task-1'], taskWithProject);
117
+
118
+ const { result } = renderHook(
119
+ () =>
120
+ useTaskProjectManagement({
121
+ task: taskWithProject,
122
+ boardId: 'board-1',
123
+ workspaceProjects: [project],
124
+ workspaceId: 'ws-1',
125
+ taskId: 'task-1',
126
+ }),
127
+ { wrapper }
128
+ );
129
+
130
+ await act(async () => {
131
+ await result.current.toggleTaskProject('project-1');
132
+ });
133
+
134
+ expect(queryClient.getQueryData<Task[]>(['tasks', 'board-1'])).toEqual([
135
+ taskWithProject,
136
+ ]);
137
+ expect(
138
+ queryClient.getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])
139
+ ).toEqual([taskWithProject]);
140
+ expect(queryClient.getQueryData<Task>(['task', 'task-1'])).toEqual(
141
+ taskWithProject
142
+ );
143
+ });
144
+ });
@@ -4,6 +4,7 @@ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
4
4
  import type { TaskFilters } from '@tuturuuu/ui/tu-do/boards/boardId/task-filter';
5
5
  import {
6
6
  type PendingRelationshipType,
7
+ type TaskAssigneeMemberSource,
7
8
  useTaskDialogContext,
8
9
  } from '../providers/task-dialog-provider';
9
10
  import type { SharedTaskContext } from '../shared/task-edit-dialog/hooks/use-task-data';
@@ -50,6 +51,10 @@ export function useTaskDialog(): {
50
51
  taskWsId?: string;
51
52
  /** Whether the task's workspace is personal (affects realtime features) */
52
53
  taskWorkspacePersonal?: boolean;
54
+ /** Whether the board context should expose assignee controls */
55
+ canUseBoardAssignees?: boolean;
56
+ /** Where assignee candidates should be loaded from */
57
+ assigneeMemberSource?: TaskAssigneeMemberSource;
53
58
  }
54
59
  ) => void;
55
60
  openTaskById: (
@@ -62,6 +67,8 @@ export function useTaskDialog(): {
62
67
  taskWsId?: string;
63
68
  taskWorkspacePersonal?: boolean;
64
69
  taskWorkspaceTier?: WorkspaceProductTier;
70
+ canUseBoardAssignees?: boolean;
71
+ assigneeMemberSource?: TaskAssigneeMemberSource;
65
72
  initialSharedContext?: SharedTaskContext;
66
73
  }
67
74
  ) => Promise<boolean>;