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