@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,147 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import type { Task } from '@tuturuuu/types/primitives/Task';
3
+ import { describe, expect, it } from 'vitest';
4
+ import {
5
+ patchTaskInVisibleCaches,
6
+ restoreTasksFromVisibleCacheSnapshot,
7
+ snapshotVisibleTaskCaches,
8
+ } from '../task-cache-patches';
9
+
10
+ function createQueryClient() {
11
+ return new QueryClient({
12
+ defaultOptions: {
13
+ queries: { retry: false },
14
+ mutations: { retry: false },
15
+ },
16
+ });
17
+ }
18
+
19
+ function createTask(id: string, name = id): Task {
20
+ return {
21
+ id,
22
+ name,
23
+ list_id: 'list-1',
24
+ display_number: 1,
25
+ created_at: '2026-01-01T00:00:00.000Z',
26
+ labels: [],
27
+ projects: [],
28
+ assignees: [],
29
+ };
30
+ }
31
+
32
+ describe('task-cache-patches', () => {
33
+ it('patches every visible cache that can render a task card or detail view', () => {
34
+ const queryClient = createQueryClient();
35
+ const task = createTask('task-1');
36
+
37
+ queryClient.setQueryData(['tasks', 'board-1'], [task]);
38
+ queryClient.setQueryData(['tasks-full', 'board-1', 'all'], [task]);
39
+ queryClient.setQueryData(['task', 'task-1'], task);
40
+ queryClient.setQueryData(['workspaceTask', 'ws-1', 'task-1'], { task });
41
+ queryClient.setQueryData(['my-tasks', 'ws-1'], {
42
+ overdue: [],
43
+ today: [task],
44
+ upcoming: [],
45
+ completed: [],
46
+ });
47
+ queryClient.setQueryData(['my-completed-tasks', 'ws-1'], {
48
+ pages: [{ completed: [task] }],
49
+ });
50
+ const patchedLabel = {
51
+ id: 'label-1',
52
+ name: 'Bug',
53
+ color: '#ef4444',
54
+ created_at: '2026-01-01T00:00:00.000Z',
55
+ };
56
+
57
+ patchTaskInVisibleCaches({
58
+ queryClient,
59
+ boardId: 'board-1',
60
+ taskId: 'task-1',
61
+ updater: (cachedTask) => ({
62
+ ...cachedTask,
63
+ labels: [patchedLabel],
64
+ }),
65
+ });
66
+
67
+ expect(
68
+ queryClient.getQueryData<Task[]>(['tasks', 'board-1'])?.[0]?.labels
69
+ ).toEqual([patchedLabel]);
70
+ expect(
71
+ queryClient.getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])?.[0]
72
+ ?.labels
73
+ ).toEqual([patchedLabel]);
74
+ expect(queryClient.getQueryData<Task>(['task', 'task-1'])?.labels).toEqual([
75
+ patchedLabel,
76
+ ]);
77
+ expect(
78
+ queryClient.getQueryData<{ task?: Task }>([
79
+ 'workspaceTask',
80
+ 'ws-1',
81
+ 'task-1',
82
+ ])?.task?.labels
83
+ ).toEqual([patchedLabel]);
84
+ expect(
85
+ queryClient.getQueryData<{ today?: Task[] }>(['my-tasks', 'ws-1'])
86
+ ?.today?.[0]?.labels
87
+ ).toEqual([patchedLabel]);
88
+ expect(
89
+ queryClient.getQueryData<{ pages?: Array<{ completed?: Task[] }> }>([
90
+ 'my-completed-tasks',
91
+ 'ws-1',
92
+ ])?.pages?.[0]?.completed?.[0]?.labels
93
+ ).toEqual([patchedLabel]);
94
+ });
95
+
96
+ it('restores only failed task ids from a snapshot', () => {
97
+ const queryClient = createQueryClient();
98
+ const taskOne = createTask('task-1', 'One');
99
+ const taskTwo = createTask('task-2', 'Two');
100
+
101
+ queryClient.setQueryData(['tasks', 'board-1'], [taskOne, taskTwo]);
102
+ queryClient.setQueryData(
103
+ ['tasks-full', 'board-1', 'all'],
104
+ [taskOne, taskTwo]
105
+ );
106
+ const snapshot = snapshotVisibleTaskCaches(queryClient, 'board-1', [
107
+ 'task-1',
108
+ 'task-2',
109
+ ]);
110
+
111
+ for (const taskId of ['task-1', 'task-2']) {
112
+ patchTaskInVisibleCaches({
113
+ queryClient,
114
+ boardId: 'board-1',
115
+ taskId,
116
+ updater: (cachedTask) => ({
117
+ ...cachedTask,
118
+ projects: [{ id: 'project-1', name: 'Roadmap', status: 'active' }],
119
+ }),
120
+ });
121
+ }
122
+
123
+ restoreTasksFromVisibleCacheSnapshot({
124
+ queryClient,
125
+ snapshot,
126
+ taskIds: ['task-2'],
127
+ });
128
+
129
+ const tasks = queryClient.getQueryData<Task[]>(['tasks', 'board-1']);
130
+ const fullTasks = queryClient.getQueryData<Task[]>([
131
+ 'tasks-full',
132
+ 'board-1',
133
+ 'all',
134
+ ]);
135
+
136
+ expect(tasks?.find((task) => task.id === 'task-1')?.projects).toEqual([
137
+ { id: 'project-1', name: 'Roadmap', status: 'active' },
138
+ ]);
139
+ expect(tasks?.find((task) => task.id === 'task-2')?.projects).toEqual([]);
140
+ expect(fullTasks?.find((task) => task.id === 'task-1')?.projects).toEqual([
141
+ { id: 'project-1', name: 'Roadmap', status: 'active' },
142
+ ]);
143
+ expect(fullTasks?.find((task) => task.id === 'task-2')?.projects).toEqual(
144
+ []
145
+ );
146
+ });
147
+ });
@@ -71,6 +71,7 @@ describe('useProgressiveBoardLoader', () => {
71
71
  limit: 50,
72
72
  offset: 0,
73
73
  includeCount: true,
74
+ includeRelationshipSummary: false,
74
75
  });
75
76
  expect(queryClient.getQueryData<Task[]>(['tasks', 'board-1'])).toEqual([
76
77
  {
@@ -160,6 +161,7 @@ describe('useProgressiveBoardLoader', () => {
160
161
  externalIncludeDocuments: true,
161
162
  externalIncludeDoneClosed: true,
162
163
  externalSortBy: 'due-asc',
164
+ includeRelationshipSummary: false,
163
165
  });
164
166
 
165
167
  vi.mocked(listWorkspaceTasks).mockResolvedValueOnce({
@@ -179,6 +181,7 @@ describe('useProgressiveBoardLoader', () => {
179
181
  externalIncludeDocuments: true,
180
182
  externalIncludeDoneClosed: true,
181
183
  externalSortBy: 'due-asc',
184
+ includeRelationshipSummary: false,
182
185
  });
183
186
  });
184
187
 
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
4
  import {
5
5
  Search,
6
6
  UserCircle,
@@ -9,7 +9,10 @@ import {
9
9
  UserX,
10
10
  X,
11
11
  } from '@tuturuuu/icons';
12
- import { updateWorkspaceTask } from '@tuturuuu/internal-api/tasks';
12
+ import {
13
+ listWorkspaceTaskBoardViewableMembers,
14
+ updateWorkspaceTask,
15
+ } from '@tuturuuu/internal-api/tasks';
13
16
  import { Avatar, AvatarFallback, AvatarImage } from '@tuturuuu/ui/avatar';
14
17
  import { Button } from '@tuturuuu/ui/button';
15
18
  import { useWorkspaceMembers } from '@tuturuuu/ui/hooks/use-workspace-members';
@@ -26,7 +29,15 @@ import {
26
29
  useMemo,
27
30
  useState,
28
31
  } from 'react';
29
- import { useBoardBroadcast } from './board-broadcast-context';
32
+ import {
33
+ getActiveBoardRefresh,
34
+ useBoardBroadcast,
35
+ } from './board-broadcast-context';
36
+ import {
37
+ patchTaskInVisibleCaches,
38
+ restoreVisibleTaskCaches,
39
+ snapshotVisibleTaskCaches,
40
+ } from './task-cache-patches';
30
41
 
31
42
  interface Member {
32
43
  id: string;
@@ -36,7 +47,7 @@ interface Member {
36
47
  avatar_url?: string;
37
48
  }
38
49
 
39
- interface Task {
50
+ interface TaskWithAssignees {
40
51
  id: string;
41
52
  assignees?: Member[];
42
53
  }
@@ -66,7 +77,8 @@ export const AssigneeSelect = forwardRef<AssigneeSelectHandle, Props>(
66
77
  const params = useParams();
67
78
  const rawWsId = params.wsId;
68
79
  const wsId = Array.isArray(rawWsId) ? rawWsId[0] : rawWsId;
69
- const boardId = params.boardId as string;
80
+ const rawBoardId = params.boardId;
81
+ const boardId = Array.isArray(rawBoardId) ? rawBoardId[0] : rawBoardId;
70
82
  const queryClient = useQueryClient();
71
83
  const broadcast = useBoardBroadcast();
72
84
 
@@ -104,11 +116,41 @@ export const AssigneeSelect = forwardRef<AssigneeSelectHandle, Props>(
104
116
  }, [uniqueAssignees]);
105
117
 
106
118
  // Fetch workspace members with React Query
107
- const {
108
- data: fetchedMembers = [],
109
- isLoading: isFetchingMembers,
110
- error: membersError,
111
- } = useWorkspaceMembers(wsId);
119
+ const shouldFetchBoardMembers = Boolean(wsId && boardId);
120
+ const workspaceMembersQuery = useWorkspaceMembers(wsId, {
121
+ enabled: Boolean(wsId) && !shouldFetchBoardMembers,
122
+ });
123
+ const boardMembersQuery = useQuery({
124
+ queryKey: ['task-board-viewable-members', wsId, boardId],
125
+ queryFn: async (): Promise<Member[]> => {
126
+ if (!wsId || !boardId) return [];
127
+
128
+ const payload = await listWorkspaceTaskBoardViewableMembers(
129
+ wsId,
130
+ boardId
131
+ );
132
+ const members = Array.isArray(payload?.members) ? payload.members : [];
133
+
134
+ return members.map((member) => ({
135
+ id: member.user_id,
136
+ user_id: member.user_id,
137
+ display_name: member.display_name || member.email || member.user_id,
138
+ email: member.email ?? undefined,
139
+ avatar_url: member.avatar_url ?? undefined,
140
+ }));
141
+ },
142
+ enabled: shouldFetchBoardMembers,
143
+ staleTime: 5 * 60 * 1000,
144
+ });
145
+ const fetchedMembers = shouldFetchBoardMembers
146
+ ? (boardMembersQuery.data ?? [])
147
+ : (workspaceMembersQuery.data ?? []);
148
+ const isFetchingMembers = shouldFetchBoardMembers
149
+ ? boardMembersQuery.isLoading
150
+ : workspaceMembersQuery.isLoading;
151
+ const membersError = shouldFetchBoardMembers
152
+ ? boardMembersQuery.error
153
+ : workspaceMembersQuery.error;
112
154
 
113
155
  // Deduplicate members by ID using O(n) Map approach
114
156
  // Also ensure user_id is set for consistency with task creation flow
@@ -148,7 +190,10 @@ export const AssigneeSelect = forwardRef<AssigneeSelectHandle, Props>(
148
190
  throw new Error(t('please_try_again_later'));
149
191
  }
150
192
 
151
- const boardTasks = queryClient.getQueryData<Task[]>(['tasks', boardId]);
193
+ const boardTasks = queryClient.getQueryData<TaskWithAssignees[]>([
194
+ 'tasks',
195
+ boardId,
196
+ ]);
152
197
  const currentTask = boardTasks?.find((task) => task.id === taskId);
153
198
  const existingIds =
154
199
  currentTask?.assignees
@@ -171,19 +216,24 @@ export const AssigneeSelect = forwardRef<AssigneeSelectHandle, Props>(
171
216
  onMutate: async ({ memberId, action }) => {
172
217
  // Cancel any outgoing refetches
173
218
  await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
219
+ if (boardId) {
220
+ await queryClient.cancelQueries({
221
+ queryKey: ['tasks-full', boardId],
222
+ });
223
+ }
174
224
 
175
225
  // Snapshot the previous value
176
- const previousTasks = queryClient.getQueryData(['tasks', boardId]);
226
+ const cacheSnapshot = boardId
227
+ ? snapshotVisibleTaskCaches(queryClient, boardId, [taskId])
228
+ : undefined;
177
229
 
178
230
  // Optimistically update the cache
179
- queryClient.setQueryData(
180
- ['tasks', boardId],
181
- (old: Task[] | undefined) => {
182
- if (!old) return old;
183
-
184
- return old.map((task: Task) => {
185
- if (task.id !== taskId) return task;
186
-
231
+ if (boardId) {
232
+ patchTaskInVisibleCaches({
233
+ queryClient,
234
+ boardId,
235
+ taskId,
236
+ updater: (task) => {
187
237
  const currentAssignees = task.assignees || [];
188
238
  let newAssignees: Member[];
189
239
 
@@ -204,9 +254,9 @@ export const AssigneeSelect = forwardRef<AssigneeSelectHandle, Props>(
204
254
  }
205
255
 
206
256
  return { ...task, assignees: newAssignees };
207
- });
208
- }
209
- );
257
+ },
258
+ });
259
+ }
210
260
 
211
261
  const previousLocalAssignees = localAssigneesState;
212
262
 
@@ -223,12 +273,12 @@ export const AssigneeSelect = forwardRef<AssigneeSelectHandle, Props>(
223
273
  return old.filter((assignee) => assignee.id !== memberId);
224
274
  });
225
275
 
226
- return { previousTasks, previousLocalAssignees };
276
+ return { cacheSnapshot, previousLocalAssignees };
227
277
  },
228
278
  onError: (err, _, context) => {
229
279
  // Rollback optimistic update on error
230
- if (context?.previousTasks) {
231
- queryClient.setQueryData(['tasks', boardId], context.previousTasks);
280
+ if (context?.cacheSnapshot) {
281
+ restoreVisibleTaskCaches(queryClient, context.cacheSnapshot);
232
282
  }
233
283
 
234
284
  if (context?.previousLocalAssignees) {
@@ -242,6 +292,7 @@ export const AssigneeSelect = forwardRef<AssigneeSelectHandle, Props>(
242
292
  },
243
293
  onSuccess: () => {
244
294
  broadcast?.('task:relations-changed', { taskId });
295
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
245
296
  },
246
297
  // Note: Removed onSettled invalidation to prevent flicker
247
298
  // Optimistic updates handle immediate UI feedback
@@ -21,7 +21,7 @@ import {
21
21
  setActiveBoardRefresh,
22
22
  setActiveBroadcast,
23
23
  } from './board-broadcast-context';
24
- import { BoardViews } from './board-views';
24
+ import { BoardViews, type ViewType } from './board-views';
25
25
  import { ProgressiveLoaderProvider } from './progressive-loader-context';
26
26
  import { dispatchRecentSidebarVisit } from './recent-sidebar-events';
27
27
  import { TaskBoardLoadingState } from './task-board-loading-state';
@@ -35,16 +35,20 @@ interface Props {
35
35
  workspaceTier?: WorkspaceProductTier | null;
36
36
  currentUserId?: string;
37
37
  routePrefix?: string;
38
+ defaultView?: ViewType;
38
39
  idleBottomIsland?: ReactNode;
40
+ rootLoading?: boolean;
39
41
  }
40
42
 
41
43
  export function BoardClient({
42
44
  boardId,
45
+ defaultView,
43
46
  idleBottomIsland,
44
47
  workspace,
45
48
  workspaceTier,
46
49
  currentUserId,
47
50
  routePrefix = '/tasks',
51
+ rootLoading = false,
48
52
  }: Props) {
49
53
  const router = useRouter();
50
54
  const queryClient = useQueryClient();
@@ -81,6 +85,7 @@ export function BoardClient({
81
85
  queryFn: async () => {
82
86
  const result = await listWorkspaceTasks(boardWorkspaceId, {
83
87
  boardId,
88
+ includeRelationshipSummary: false,
84
89
  });
85
90
  return result.tasks;
86
91
  },
@@ -143,8 +148,6 @@ export function BoardClient({
143
148
  // Fetch workspace labels once at the board level
144
149
  const { data: workspaceLabels = [] } = useWorkspaceLabels(boardWorkspaceId);
145
150
 
146
- const { broadcast } = useBoardRealtime(boardId);
147
-
148
151
  const refreshActiveBoard = useCallback(
149
152
  (options?: BoardRefreshOptions) => {
150
153
  const invalidateTasks = options?.invalidateTasks ?? true;
@@ -181,6 +184,12 @@ export function BoardClient({
181
184
  ]
182
185
  );
183
186
 
187
+ const { broadcast } = useBoardRealtime(boardId, {
188
+ onTaskRelationsChange: () => {
189
+ refreshActiveBoard({ invalidateTasks: false });
190
+ },
191
+ });
192
+
184
193
  // Register broadcast at module level so components outside the
185
194
  // BoardBroadcastProvider tree (e.g. task dialog) can access it.
186
195
  useEffect(() => {
@@ -238,7 +247,7 @@ export function BoardClient({
238
247
  ]);
239
248
 
240
249
  if (boardLoading && !board) {
241
- return <TaskBoardLoadingState />;
250
+ return <TaskBoardLoadingState root={rootLoading} />;
242
251
  }
243
252
 
244
253
  if (!board?.id) {
@@ -262,6 +271,7 @@ export function BoardClient({
262
271
  lists={lists}
263
272
  workspaceLabels={workspaceLabels}
264
273
  currentUserId={currentUserId}
274
+ defaultView={defaultView}
265
275
  canManageBoard={canManageBoard}
266
276
  idleBottomIsland={idleBottomIsland}
267
277
  />
@@ -53,9 +53,11 @@ interface Props {
53
53
  WorkspaceTaskBoard,
54
54
  'id' | 'name' | 'ticket_prefix' | 'archived_at'
55
55
  > & {
56
+ access_type?: 'member' | 'guest';
56
57
  ws_id?: WorkspaceTaskBoard['ws_id'] | null;
57
58
  icon?: WorkspaceTaskBoard['icon'];
58
59
  default_list_id?: WorkspaceTaskBoard['default_list_id'] | null;
60
+ has_guest_access?: boolean;
59
61
  };
60
62
  currentUserId?: string;
61
63
  currentView: ViewType;
@@ -155,6 +157,11 @@ export function BoardHeader({
155
157
  !publicView &&
156
158
  isPersonalWorkspace &&
157
159
  currentView === 'kanban';
160
+ const hasSharedBoardGuests =
161
+ board.access_type === 'guest' || board.has_guest_access === true;
162
+ const presenceVisible =
163
+ interactiveControlsVisible &&
164
+ (!isPersonalWorkspace || hasSharedBoardGuests);
158
165
 
159
166
  // Stable refs for callbacks and values to avoid effect re-runs
160
167
  const onFiltersChangeRef = useRef(onFiltersChange);
@@ -552,7 +559,7 @@ export function BoardHeader({
552
559
  {/* Controls - Compact Row */}
553
560
  <div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
554
561
  {/* Online Users */}
555
- {interactiveControlsVisible && !isPersonalWorkspace && (
562
+ {presenceVisible && (
556
563
  <BoardUserPresenceAvatarsComponent
557
564
  boardId={board.id}
558
565
  currentMetadata={presenceMetadata}
@@ -1,8 +1,9 @@
1
1
  import { useQuery, useQueryClient } from '@tanstack/react-query';
2
- import { Archive, CheckCircle2, LayoutGrid, Trash2 } from '@tuturuuu/icons';
2
+ import { Archive, LayoutGrid, Trash2 } from '@tuturuuu/icons';
3
3
  import {
4
+ type AccessibleWorkspaceTaskBoard,
4
5
  createWorkspaceTaskBoard,
5
- listWorkspaceTaskBoards,
6
+ listCurrentUserTaskBoards,
6
7
  } from '@tuturuuu/internal-api/tasks';
7
8
  import {
8
9
  isTaskRememberLastBoardEnabled,
@@ -50,12 +51,15 @@ interface BoardSwitcherProps {
50
51
  }
51
52
 
52
53
  type BoardWithStatus = {
54
+ access_type?: 'member' | 'guest';
53
55
  id: string;
54
56
  name: string | null;
55
57
  icon: string | null;
56
58
  archived_at: string | null;
57
59
  deleted_at: string | null;
58
60
  created_at: string | null;
61
+ workspace?: AccessibleWorkspaceTaskBoard['workspace'];
62
+ ws_id: string;
59
63
  };
60
64
 
61
65
  function getDaysRemaining(deletedAt: string) {
@@ -107,22 +111,27 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
107
111
  );
108
112
 
109
113
  const { data: boards = [], isLoading: isFetchingBoards } = useQuery({
110
- queryKey: ['other-boards', board.ws_id, board.id],
114
+ queryKey: ['accessible-task-boards'],
111
115
  queryFn: async () => {
112
- const payload = await listWorkspaceTaskBoards(board.ws_id);
116
+ const payload = await listCurrentUserTaskBoards();
113
117
  return (payload.boards || []) as BoardWithStatus[];
114
118
  },
115
- enabled: !!board.ws_id,
116
119
  });
117
120
  const rememberLastBoard =
118
121
  isTaskRememberLastBoardEnabled(rememberLastBoardRaw);
122
+ const boardsById = useMemo(() => {
123
+ return new Map(boards.map((item) => [item.id, item] as const));
124
+ }, [boards]);
119
125
 
120
126
  const selectBoard = useCallback(
121
127
  (value: string | string[]) => {
122
128
  const boardId = Array.isArray(value) ? value[0] : value;
123
129
  if (!boardId || boardId === board.id) return;
124
130
 
125
- if (rememberLastBoard) {
131
+ const selectedBoard = boardsById.get(boardId);
132
+ const targetWorkspaceId = selectedBoard?.ws_id ?? board.ws_id;
133
+
134
+ if (rememberLastBoard && targetWorkspaceId === board.ws_id) {
126
135
  updateUserWorkspaceConfig.mutate({
127
136
  configId: TASK_DEFAULT_BOARD_ID_CONFIG_ID,
128
137
  value: boardId,
@@ -130,11 +139,12 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
130
139
  });
131
140
  }
132
141
 
133
- router.push(`/${board.ws_id}${tasksHref(`/boards/${boardId}`)}`);
142
+ router.push(`/${targetWorkspaceId}${tasksHref(`/boards/${boardId}`)}`);
134
143
  },
135
144
  [
136
145
  board.id,
137
146
  board.ws_id,
147
+ boardsById,
138
148
  rememberLastBoard,
139
149
  router,
140
150
  tasksHref,
@@ -150,7 +160,7 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
150
160
  try {
151
161
  const payload = await createWorkspaceTaskBoard(board.ws_id, { name });
152
162
  await queryClient.invalidateQueries({
153
- queryKey: ['other-boards', board.ws_id],
163
+ queryKey: ['accessible-task-boards'],
154
164
  });
155
165
 
156
166
  return {
@@ -165,6 +175,9 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
165
175
  [board.ws_id, queryClient, t.createBoardError, translateBoardName]
166
176
  );
167
177
 
178
+ const currentBoardFromAccessible = boardsById.get(board.id);
179
+ const canCreateBoard = currentBoardFromAccessible?.access_type !== 'guest';
180
+
168
181
  const boardOptions = useMemo(() => {
169
182
  const byId = new Map<string, BoardWithStatus>();
170
183
  for (const item of boards) byId.set(item.id, item);
@@ -176,10 +189,24 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
176
189
  archived_at: null,
177
190
  deleted_at: null,
178
191
  created_at: null,
192
+ ws_id: board.ws_id,
179
193
  });
180
194
  }
181
195
 
182
196
  const orderedBoards = [...byId.values()].sort((a, b) => {
197
+ const workspaceDelta =
198
+ (a.ws_id === board.ws_id ? 0 : 1) - (b.ws_id === board.ws_id ? 0 : 1);
199
+ if (workspaceDelta !== 0) return workspaceDelta;
200
+
201
+ const currentBoardDelta =
202
+ (a.id === board.id ? 0 : 1) - (b.id === board.id ? 0 : 1);
203
+ if (currentBoardDelta !== 0) return currentBoardDelta;
204
+
205
+ const workspaceNameDelta = (a.workspace?.name ?? a.ws_id).localeCompare(
206
+ b.workspace?.name ?? b.ws_id
207
+ );
208
+ if (workspaceNameDelta !== 0) return workspaceNameDelta;
209
+
183
210
  const statusWeight = (item: BoardWithStatus) =>
184
211
  item.deleted_at ? 2 : item.archived_at ? 1 : 0;
185
212
  const statusDelta = statusWeight(a) - statusWeight(b);
@@ -200,6 +227,7 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
200
227
  : isArchived
201
228
  ? t.archived
202
229
  : t.active;
230
+ const workspaceLabel = item.workspace?.name ?? item.ws_id;
203
231
  const groupLabel = isDeleted
204
232
  ? t.deletedBoards
205
233
  : isArchived
@@ -207,12 +235,39 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
207
235
  : t.activeBoards;
208
236
  const daysRemaining =
209
237
  item.deleted_at && getDaysRemaining(item.deleted_at);
238
+ const description = daysRemaining
239
+ ? `${groupLabel} · ${t.daysLeft.replace(
240
+ '{count}',
241
+ String(daysRemaining)
242
+ )}`
243
+ : isArchived || isDeleted
244
+ ? groupLabel
245
+ : undefined;
246
+ const badge =
247
+ isArchived || isDeleted ? (
248
+ <Badge
249
+ key={`${item.id}-status`}
250
+ className={cn(
251
+ 'shrink-0 gap-1 px-2 py-0.5 text-[10px]',
252
+ isDeleted && 'bg-dynamic-red/10 text-dynamic-red',
253
+ isArchived && 'bg-muted text-foreground'
254
+ )}
255
+ >
256
+ {isDeleted ? (
257
+ <Trash2 className="h-3 w-3 text-dynamic-red/50" />
258
+ ) : (
259
+ <Archive className="h-3 w-3 text-foreground/50" />
260
+ )}
261
+ {statusLabel}
262
+ </Badge>
263
+ ) : undefined;
210
264
 
211
265
  return {
212
266
  value: item.id,
213
267
  label: translateBoardName(item.name),
214
268
  searchValue: [
215
269
  translateBoardName(item.name),
270
+ workspaceLabel,
216
271
  statusLabel,
217
272
  groupLabel,
218
273
  daysRemaining
@@ -221,41 +276,18 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
221
276
  ]
222
277
  .filter(Boolean)
223
278
  .join(' '),
224
- description: daysRemaining
225
- ? `${groupLabel} · ${t.daysLeft.replace(
226
- '{count}',
227
- String(daysRemaining)
228
- )}`
229
- : groupLabel,
279
+ description,
280
+ group: workspaceLabel,
230
281
  icon: <BoardIcon className="h-4 w-4" />,
231
282
  muted: isArchived || isDeleted,
232
- badge: (
233
- <Badge
234
- className={cn(
235
- 'shrink-0 gap-1 px-2 py-0.5 text-[10px]',
236
- isDeleted && 'bg-dynamic-red/10 text-dynamic-red',
237
- isArchived && 'bg-muted text-foreground',
238
- !isDeleted &&
239
- !isArchived &&
240
- 'bg-dynamic-green/10 text-dynamic-green'
241
- )}
242
- >
243
- {isDeleted ? (
244
- <Trash2 className="h-3 w-3 text-dynamic-red/50" />
245
- ) : isArchived ? (
246
- <Archive className="h-3 w-3 text-foreground/50" />
247
- ) : (
248
- <CheckCircle2 className="h-3 w-3 text-dynamic-green/50" />
249
- )}
250
- {statusLabel}
251
- </Badge>
252
- ),
283
+ badge,
253
284
  };
254
285
  });
255
286
  }, [
256
287
  board.icon,
257
288
  board.id,
258
289
  board.name,
290
+ board.ws_id,
259
291
  boards,
260
292
  t.active,
261
293
  t.activeBoards,
@@ -270,8 +302,8 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
270
302
  return (
271
303
  <Combobox
272
304
  className="w-[min(22rem,70vw)] [&_button]:h-7 [&_button]:min-h-7 [&_button]:px-2 sm:[&_button]:h-8 sm:[&_button]:min-h-8"
273
- createText={t.createBoard}
274
- creatingText={t.creatingBoard}
305
+ createText={canCreateBoard ? t.createBoard : undefined}
306
+ creatingText={canCreateBoard ? t.creatingBoard : undefined}
275
307
  disabled={isFetchingBoards}
276
308
  emptyText={isFetchingBoards ? t.loadingBoards : t.noOtherBoards}
277
309
  label={
@@ -280,7 +312,7 @@ export function BoardSwitcher({ board, translations }: BoardSwitcherProps) {
280
312
  </span>
281
313
  }
282
314
  onChange={selectBoard}
283
- onCreate={createBoard}
315
+ onCreate={canCreateBoard ? createBoard : undefined}
284
316
  options={boardOptions}
285
317
  placeholder={translateBoardName(board.name)}
286
318
  searchPlaceholder={t.searchBoards}