@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.
- package/CHANGELOG.md +29 -0
- package/package.json +6 -5
- package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
- package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
- package/src/components/ui/custom/nav-link.test.tsx +165 -0
- package/src/components/ui/custom/nav-link.tsx +69 -11
- package/src/components/ui/custom/navigation.tsx +1 -0
- package/src/components/ui/custom/settings/task-settings.tsx +104 -0
- package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
- package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
- package/src/components/ui/custom/settings-dialog-search.ts +75 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
- package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
- package/src/components/ui/custom/workspace-select.tsx +17 -16
- package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
- package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
- package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
- package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
- package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
- package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
- package/src/components/ui/tu-do/boards/form.tsx +1 -1
- package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
- package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
- package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
- package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
- package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
- package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +116 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
- package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +21 -0
- package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
- package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +14 -4
- package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
- package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
- package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
- package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
- package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
- package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +9 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
- package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
- package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
- package/src/hooks/useBoardPresence.ts +364 -0
- package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
- 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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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<
|
|
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
|
|
226
|
+
const cacheSnapshot = boardId
|
|
227
|
+
? snapshotVisibleTaskCaches(queryClient, boardId, [taskId])
|
|
228
|
+
: undefined;
|
|
177
229
|
|
|
178
230
|
// Optimistically update the cache
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 {
|
|
276
|
+
return { cacheSnapshot, previousLocalAssignees };
|
|
227
277
|
},
|
|
228
278
|
onError: (err, _, context) => {
|
|
229
279
|
// Rollback optimistic update on error
|
|
230
|
-
if (context?.
|
|
231
|
-
queryClient
|
|
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
|
-
{
|
|
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,
|
|
2
|
+
import { Archive, LayoutGrid, Trash2 } from '@tuturuuu/icons';
|
|
3
3
|
import {
|
|
4
|
+
type AccessibleWorkspaceTaskBoard,
|
|
4
5
|
createWorkspaceTaskBoard,
|
|
5
|
-
|
|
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: ['
|
|
114
|
+
queryKey: ['accessible-task-boards'],
|
|
111
115
|
queryFn: async () => {
|
|
112
|
-
const payload = await
|
|
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
|
-
|
|
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(`/${
|
|
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: ['
|
|
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
|
|
225
|
-
|
|
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}
|