@tuturuuu/ui 0.2.0 → 0.3.2
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 +60 -0
- package/package.json +79 -67
- package/src/components/ui/__tests__/avatar.test.tsx +8 -5
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
- package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
- package/src/components/ui/chart.test.tsx +29 -0
- package/src/components/ui/chart.tsx +12 -3
- package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +396 -2
- package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +36 -8
- package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +14 -0
- package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +5 -0
- package/src/components/ui/chat/chat-agent-details-sidebar.tsx +21 -7
- package/src/components/ui/chat/chat-agent-details-utils.test.ts +73 -0
- package/src/components/ui/chat/chat-agent-details-utils.tsx +100 -26
- package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +517 -0
- package/src/components/ui/chat/chat-workspace.tsx +31 -1
- package/src/components/ui/chat/hooks-messages.test.tsx +45 -1
- package/src/components/ui/chat/hooks-messages.ts +1 -1
- package/src/components/ui/chat/hooks-realtime.ts +13 -16
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
- package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
- package/src/components/ui/custom/common-footer.tsx +16 -1
- package/src/components/ui/custom/production-indicator.tsx +1 -1
- package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
- package/src/components/ui/custom/settings/task-settings.tsx +18 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
- package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
- package/src/components/ui/custom/sidebar-context.tsx +61 -61
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
- package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
- package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
- package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
- package/src/components/ui/custom/workspace-select.tsx +33 -12
- package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
- package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
- package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
- package/src/components/ui/finance/invoices/hooks.ts +75 -20
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
- package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
- package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
- package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
- package/src/components/ui/finance/invoices/utils.test.ts +50 -0
- package/src/components/ui/finance/invoices/utils.ts +75 -17
- package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
- package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
- package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
- package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
- package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
- package/src/components/ui/finance/transactions/form-types.ts +2 -0
- package/src/components/ui/finance/transactions/form.test.tsx +43 -0
- package/src/components/ui/finance/transactions/form.tsx +60 -0
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
- package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
- package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
- package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
- package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
- package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
- package/src/components/ui/legacy/meet/page.test.ts +180 -0
- package/src/components/ui/legacy/meet/page.tsx +87 -39
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
- package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
- package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
- package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
- package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
- package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
- package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
- package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
- package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
- package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
- package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
- package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
- package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
- package/src/hooks/use-calendar-sync.tsx +22 -277
- package/src/hooks/use-calendar.tsx +95 -525
- package/src/hooks/use-semantic-task-search.ts +10 -33
- package/src/hooks/use-task-actions.ts +43 -117
- package/src/hooks/use-user-config.ts +1 -1
- package/src/hooks/use-workspace-config.ts +6 -2
- package/src/hooks/use-workspace-presence.ts +1 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
|
@@ -1,8 +1,325 @@
|
|
|
1
|
-
import type { QueryClient } from '@tanstack/react-query';
|
|
2
|
-
import {
|
|
1
|
+
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
|
2
|
+
import {
|
|
3
|
+
type BulkWorkspaceTaskOperation,
|
|
4
|
+
type BulkWorkspaceTasksResponse,
|
|
5
|
+
bulkWorkspaceTasks,
|
|
6
|
+
getWorkspaceTask,
|
|
7
|
+
} from '@tuturuuu/internal-api/tasks';
|
|
3
8
|
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
4
9
|
import { calculateDaysUntilEndOfWeek } from '../../../../utils/weekDateUtils';
|
|
5
10
|
|
|
11
|
+
type BoardTaskCacheSnapshot = {
|
|
12
|
+
previousTasks: Task[] | undefined;
|
|
13
|
+
previousFullTasks: [QueryKey, Task[] | undefined][];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type CachedTaskWithWorkspace = Task & {
|
|
17
|
+
source_workspace_id?: string | null;
|
|
18
|
+
ws_id?: string | null;
|
|
19
|
+
task_lists?: {
|
|
20
|
+
workspace_boards?: {
|
|
21
|
+
ws_id?: string | null;
|
|
22
|
+
} | null;
|
|
23
|
+
} | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type BulkWorkspaceTasksOptions = Parameters<typeof bulkWorkspaceTasks>[2];
|
|
27
|
+
|
|
28
|
+
export type BulkTaskWorkspaceGroup = {
|
|
29
|
+
workspaceId: string;
|
|
30
|
+
taskIds: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function getTaskMutationWorkspaceId(
|
|
34
|
+
task: CachedTaskWithWorkspace | undefined,
|
|
35
|
+
defaultWorkspaceId: string
|
|
36
|
+
) {
|
|
37
|
+
return (
|
|
38
|
+
task?.source_workspace_id ??
|
|
39
|
+
task?.ws_id ??
|
|
40
|
+
task?.task_lists?.workspace_boards?.ws_id ??
|
|
41
|
+
defaultWorkspaceId
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getCachedBoardTasks(queryClient: QueryClient, boardId: string) {
|
|
46
|
+
const tasks = queryClient.getQueryData<Task[]>(['tasks', boardId]) ?? [];
|
|
47
|
+
const fullTaskEntries = queryClient.getQueriesData<Task[]>({
|
|
48
|
+
queryKey: ['tasks-full', boardId],
|
|
49
|
+
});
|
|
50
|
+
const byId = new Map<string, CachedTaskWithWorkspace>();
|
|
51
|
+
|
|
52
|
+
for (const task of tasks as CachedTaskWithWorkspace[]) {
|
|
53
|
+
byId.set(task.id, task);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const [, fullTasks] of fullTaskEntries) {
|
|
57
|
+
for (const task of (fullTasks ?? []) as CachedTaskWithWorkspace[]) {
|
|
58
|
+
if (!byId.has(task.id)) {
|
|
59
|
+
byId.set(task.id, task);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return byId;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getBulkTaskWorkspaceGroups({
|
|
68
|
+
queryClient,
|
|
69
|
+
boardId,
|
|
70
|
+
defaultWorkspaceId,
|
|
71
|
+
taskIds,
|
|
72
|
+
}: {
|
|
73
|
+
queryClient: QueryClient;
|
|
74
|
+
boardId: string;
|
|
75
|
+
defaultWorkspaceId: string;
|
|
76
|
+
taskIds: string[];
|
|
77
|
+
}) {
|
|
78
|
+
const cachedTasksById = getCachedBoardTasks(queryClient, boardId);
|
|
79
|
+
const groupsByWorkspaceId = new Map<string, string[]>();
|
|
80
|
+
|
|
81
|
+
for (const taskId of taskIds) {
|
|
82
|
+
const task = cachedTasksById.get(taskId);
|
|
83
|
+
const workspaceId = getTaskMutationWorkspaceId(task, defaultWorkspaceId);
|
|
84
|
+
const group = groupsByWorkspaceId.get(workspaceId) ?? [];
|
|
85
|
+
group.push(taskId);
|
|
86
|
+
groupsByWorkspaceId.set(workspaceId, group);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return Array.from(
|
|
90
|
+
groupsByWorkspaceId,
|
|
91
|
+
([workspaceId, groupTaskIds]): BulkTaskWorkspaceGroup => ({
|
|
92
|
+
workspaceId,
|
|
93
|
+
taskIds: groupTaskIds,
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function bulkWorkspaceTasksByEffectiveWorkspace({
|
|
99
|
+
queryClient,
|
|
100
|
+
boardId,
|
|
101
|
+
defaultWorkspaceId,
|
|
102
|
+
taskIds,
|
|
103
|
+
operation,
|
|
104
|
+
options,
|
|
105
|
+
workspaceGroups,
|
|
106
|
+
}: {
|
|
107
|
+
queryClient: QueryClient;
|
|
108
|
+
boardId: string;
|
|
109
|
+
defaultWorkspaceId: string;
|
|
110
|
+
taskIds: string[];
|
|
111
|
+
operation: BulkWorkspaceTaskOperation;
|
|
112
|
+
options?: BulkWorkspaceTasksOptions;
|
|
113
|
+
workspaceGroups?: BulkTaskWorkspaceGroup[];
|
|
114
|
+
}): Promise<BulkWorkspaceTasksResponse> {
|
|
115
|
+
const groups =
|
|
116
|
+
workspaceGroups ??
|
|
117
|
+
getBulkTaskWorkspaceGroups({
|
|
118
|
+
queryClient,
|
|
119
|
+
boardId,
|
|
120
|
+
defaultWorkspaceId,
|
|
121
|
+
taskIds,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const aggregate: BulkWorkspaceTasksResponse = {
|
|
125
|
+
successCount: 0,
|
|
126
|
+
failCount: 0,
|
|
127
|
+
taskIds,
|
|
128
|
+
succeededTaskIds: [],
|
|
129
|
+
failures: [],
|
|
130
|
+
taskMetaById: {},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const group of groups) {
|
|
134
|
+
try {
|
|
135
|
+
const result = await bulkWorkspaceTasks(
|
|
136
|
+
group.workspaceId,
|
|
137
|
+
{
|
|
138
|
+
taskIds: group.taskIds,
|
|
139
|
+
operation,
|
|
140
|
+
},
|
|
141
|
+
options
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
aggregate.successCount += result.successCount;
|
|
145
|
+
aggregate.failCount += result.failCount;
|
|
146
|
+
aggregate.succeededTaskIds.push(...result.succeededTaskIds);
|
|
147
|
+
aggregate.failures.push(...result.failures);
|
|
148
|
+
|
|
149
|
+
if (result.taskMetaById) {
|
|
150
|
+
aggregate.taskMetaById = {
|
|
151
|
+
...aggregate.taskMetaById,
|
|
152
|
+
...result.taskMetaById,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const message =
|
|
157
|
+
error instanceof Error ? error.message : 'Bulk task update failed';
|
|
158
|
+
|
|
159
|
+
aggregate.failCount += group.taskIds.length;
|
|
160
|
+
aggregate.failures.push(
|
|
161
|
+
...group.taskIds.map((taskId) => ({
|
|
162
|
+
taskId,
|
|
163
|
+
error: message,
|
|
164
|
+
}))
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return aggregate;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function snapshotBoardTaskCaches(
|
|
173
|
+
queryClient: QueryClient,
|
|
174
|
+
boardId: string
|
|
175
|
+
): BoardTaskCacheSnapshot {
|
|
176
|
+
return {
|
|
177
|
+
previousTasks: queryClient.getQueryData<Task[]>(['tasks', boardId]),
|
|
178
|
+
previousFullTasks: queryClient.getQueriesData<Task[]>({
|
|
179
|
+
queryKey: ['tasks-full', boardId],
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function restoreBoardTaskCaches(
|
|
185
|
+
queryClient: QueryClient,
|
|
186
|
+
boardId: string,
|
|
187
|
+
snapshot: BoardTaskCacheSnapshot
|
|
188
|
+
) {
|
|
189
|
+
queryClient.setQueryData(['tasks', boardId], snapshot.previousTasks);
|
|
190
|
+
|
|
191
|
+
for (const [queryKey, tasks] of snapshot.previousFullTasks) {
|
|
192
|
+
queryClient.setQueryData(queryKey, tasks);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function updateBoardTaskCaches(
|
|
197
|
+
queryClient: QueryClient,
|
|
198
|
+
boardId: string,
|
|
199
|
+
updater: (old: Task[] | undefined) => Task[] | undefined
|
|
200
|
+
) {
|
|
201
|
+
queryClient.setQueryData(['tasks', boardId], updater);
|
|
202
|
+
queryClient.setQueriesData<Task[]>(
|
|
203
|
+
{ queryKey: ['tasks-full', boardId] },
|
|
204
|
+
updater
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function restoreFailedBoardTasks({
|
|
209
|
+
queryClient,
|
|
210
|
+
boardId,
|
|
211
|
+
previousTasks,
|
|
212
|
+
previousFullTasks,
|
|
213
|
+
failedTaskIds,
|
|
214
|
+
}: {
|
|
215
|
+
queryClient: QueryClient;
|
|
216
|
+
boardId: string;
|
|
217
|
+
previousTasks: Task[] | undefined;
|
|
218
|
+
previousFullTasks?: [QueryKey, Task[] | undefined][];
|
|
219
|
+
failedTaskIds: Iterable<string>;
|
|
220
|
+
}) {
|
|
221
|
+
if (!Array.isArray(previousTasks) && !previousFullTasks?.length) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const failedTaskIdSet = new Set(failedTaskIds);
|
|
226
|
+
if (failedTaskIdSet.size === 0) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const previousTaskMap = new Map<string, Task>();
|
|
231
|
+
|
|
232
|
+
for (const [, tasks] of previousFullTasks ?? []) {
|
|
233
|
+
for (const task of tasks ?? []) {
|
|
234
|
+
previousTaskMap.set(task.id, task);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const task of previousTasks ?? []) {
|
|
239
|
+
previousTaskMap.set(task.id, task);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
updateBoardTaskCaches(queryClient, boardId, (old) => {
|
|
243
|
+
if (!old) return old;
|
|
244
|
+
return old.map((task) => {
|
|
245
|
+
if (!failedTaskIdSet.has(task.id)) return task;
|
|
246
|
+
return previousTaskMap.get(task.id) ?? task;
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function restoreDeletedBoardTasks({
|
|
252
|
+
queryClient,
|
|
253
|
+
boardId,
|
|
254
|
+
previousTasks,
|
|
255
|
+
previousFullTasks,
|
|
256
|
+
failedTaskIds,
|
|
257
|
+
}: {
|
|
258
|
+
queryClient: QueryClient;
|
|
259
|
+
boardId: string;
|
|
260
|
+
previousTasks: Task[] | undefined;
|
|
261
|
+
previousFullTasks?: [QueryKey, Task[] | undefined][];
|
|
262
|
+
failedTaskIds: Iterable<string>;
|
|
263
|
+
}) {
|
|
264
|
+
if (!Array.isArray(previousTasks) && !previousFullTasks?.length) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const failedTaskIdSet = new Set(failedTaskIds);
|
|
269
|
+
if (failedTaskIdSet.size === 0) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const previousTaskMap = new Map<string, Task>();
|
|
274
|
+
const previousOrder = new Map<string, number>();
|
|
275
|
+
|
|
276
|
+
for (const [, tasks] of previousFullTasks ?? []) {
|
|
277
|
+
for (const task of tasks ?? []) {
|
|
278
|
+
if (!previousOrder.has(task.id)) {
|
|
279
|
+
previousOrder.set(task.id, previousOrder.size);
|
|
280
|
+
}
|
|
281
|
+
previousTaskMap.set(task.id, task);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const task of previousTasks ?? []) {
|
|
286
|
+
if (!previousOrder.has(task.id)) {
|
|
287
|
+
previousOrder.set(task.id, previousOrder.size);
|
|
288
|
+
}
|
|
289
|
+
previousTaskMap.set(task.id, task);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
updateBoardTaskCaches(queryClient, boardId, (old) => {
|
|
293
|
+
const existingById = new Map((old ?? []).map((task) => [task.id, task]));
|
|
294
|
+
|
|
295
|
+
for (const failedTaskId of failedTaskIdSet) {
|
|
296
|
+
const previousTask = previousTaskMap.get(failedTaskId);
|
|
297
|
+
if (previousTask) {
|
|
298
|
+
existingById.set(failedTaskId, previousTask);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return Array.from(existingById.values()).sort((a, b) => {
|
|
303
|
+
const aIndex = previousOrder.get(a.id);
|
|
304
|
+
const bIndex = previousOrder.get(b.id);
|
|
305
|
+
|
|
306
|
+
if (typeof aIndex === 'number' && typeof bIndex === 'number') {
|
|
307
|
+
return aIndex - bIndex;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (typeof aIndex === 'number') {
|
|
311
|
+
return -1;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (typeof bIndex === 'number') {
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return 0;
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
6
323
|
export function getInternalApiOptions() {
|
|
7
324
|
if (typeof window === 'undefined') {
|
|
8
325
|
return undefined;
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from './bulk-mutations-updates';
|
|
30
30
|
import { useBulkOperationI18n } from './bulk-operation-i18n';
|
|
31
31
|
import type { BulkOperationsConfig } from './bulk-operation-types';
|
|
32
|
+
import { getBulkTaskWorkspaceGroups } from './bulk-operation-utils';
|
|
32
33
|
|
|
33
34
|
export function useBulkOperations(config: BulkOperationsConfig) {
|
|
34
35
|
const i18n = useBulkOperationI18n();
|
|
@@ -290,7 +291,13 @@ export function useBulkOperations(config: BulkOperationsConfig) {
|
|
|
290
291
|
bulkDeleteTasks: async () => {
|
|
291
292
|
const taskIds = Array.from(selectedTasks);
|
|
292
293
|
if (!taskIds.length) return;
|
|
293
|
-
|
|
294
|
+
const workspaceGroups = getBulkTaskWorkspaceGroups({
|
|
295
|
+
queryClient,
|
|
296
|
+
boardId,
|
|
297
|
+
defaultWorkspaceId: wsId,
|
|
298
|
+
taskIds,
|
|
299
|
+
});
|
|
300
|
+
await deleteMutation.mutateAsync({ taskIds, workspaceGroups });
|
|
294
301
|
},
|
|
295
302
|
bulkMoveToBoard: async (targetBoardId: string, targetListId: string) => {
|
|
296
303
|
const taskIds = Array.from(selectedTasks);
|
|
@@ -463,11 +463,13 @@ export function useKanbanDnd({
|
|
|
463
463
|
'tasks-full',
|
|
464
464
|
boardId,
|
|
465
465
|
]);
|
|
466
|
+
const previousTask = previousTasks?.find((item) => item.id === task.id);
|
|
467
|
+
const previousFullTask = previousFullTasks?.find(
|
|
468
|
+
(item) => item.id === task.id
|
|
469
|
+
);
|
|
466
470
|
|
|
467
471
|
setBoardTaskCache(queryClient, boardId, nextTask);
|
|
468
472
|
|
|
469
|
-
setOptimisticUpdateInProgress((prev) => new Set(prev).add(task.id));
|
|
470
|
-
|
|
471
473
|
try {
|
|
472
474
|
const response = isStagingTarget
|
|
473
475
|
? null
|
|
@@ -494,19 +496,34 @@ export function useKanbanDnd({
|
|
|
494
496
|
|
|
495
497
|
return savedTask;
|
|
496
498
|
} catch (error) {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
499
|
+
const restoreTaskInCache = (
|
|
500
|
+
queryKey: unknown[],
|
|
501
|
+
previousTaskValue: Task | undefined,
|
|
502
|
+
previousCache: Task[] | undefined
|
|
503
|
+
) => {
|
|
504
|
+
queryClient.setQueryData<Task[]>(queryKey, (currentTasks) => {
|
|
505
|
+
if (!currentTasks) return previousCache;
|
|
506
|
+
|
|
507
|
+
if (!previousTaskValue) {
|
|
508
|
+
return currentTasks.filter((item) => item.id !== task.id);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const hasTask = currentTasks.some((item) => item.id === task.id);
|
|
512
|
+
if (!hasTask) return [...currentTasks, previousTaskValue];
|
|
513
|
+
|
|
514
|
+
return currentTasks.map((item) =>
|
|
515
|
+
item.id === task.id ? previousTaskValue : item
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
restoreTaskInCache(['tasks', boardId], previousTask, previousTasks);
|
|
521
|
+
restoreTaskInCache(
|
|
522
|
+
['tasks-full', boardId],
|
|
523
|
+
previousFullTask,
|
|
524
|
+
previousFullTasks
|
|
525
|
+
);
|
|
503
526
|
throw error;
|
|
504
|
-
} finally {
|
|
505
|
-
setOptimisticUpdateInProgress((prev) => {
|
|
506
|
-
const next = new Set(prev);
|
|
507
|
-
next.delete(task.id);
|
|
508
|
-
return next;
|
|
509
|
-
});
|
|
510
527
|
}
|
|
511
528
|
},
|
|
512
529
|
[boardId, queryClient]
|
|
@@ -1143,6 +1160,31 @@ export function useKanbanDnd({
|
|
|
1143
1160
|
(activeTaskForDrop.sort_key ?? MAX_SAFE_INTEGER_SORT) !== newSortKey);
|
|
1144
1161
|
|
|
1145
1162
|
let shouldPreservePendingAfterDragReset = false;
|
|
1163
|
+
const persistPersonalPlacementMove = (
|
|
1164
|
+
task: Task,
|
|
1165
|
+
sortKey: number | null,
|
|
1166
|
+
order?: {
|
|
1167
|
+
previousTaskId?: string | null;
|
|
1168
|
+
nextTaskId?: string | null;
|
|
1169
|
+
}
|
|
1170
|
+
) => {
|
|
1171
|
+
const pendingTaskIds = [task.id];
|
|
1172
|
+
markTaskIdsPending(pendingTaskIds);
|
|
1173
|
+
shouldPreservePendingAfterDragReset = true;
|
|
1174
|
+
|
|
1175
|
+
void movePersonalPlacementTask(task, targetListId, sortKey, order)
|
|
1176
|
+
.catch((error) => {
|
|
1177
|
+
console.error('Failed to update personal task placement:', error);
|
|
1178
|
+
rollbackOptimisticDropPreview();
|
|
1179
|
+
toast.error(
|
|
1180
|
+
personalPlacementUpdateFailedMessage ??
|
|
1181
|
+
'Failed to update personal task placement'
|
|
1182
|
+
);
|
|
1183
|
+
})
|
|
1184
|
+
.finally(() => {
|
|
1185
|
+
clearPendingTaskIds(pendingTaskIds);
|
|
1186
|
+
});
|
|
1187
|
+
};
|
|
1146
1188
|
|
|
1147
1189
|
if (needsUpdate) {
|
|
1148
1190
|
if (isMultiSelectMode && selectedTasks.size > 1) {
|
|
@@ -1206,7 +1248,7 @@ export function useKanbanDnd({
|
|
|
1206
1248
|
try {
|
|
1207
1249
|
for (const task of sortedTasksToMove) {
|
|
1208
1250
|
if (targetIsExternalStaging) {
|
|
1209
|
-
|
|
1251
|
+
persistPersonalPlacementMove(task, null);
|
|
1210
1252
|
continue;
|
|
1211
1253
|
}
|
|
1212
1254
|
|
|
@@ -1327,11 +1369,7 @@ export function useKanbanDnd({
|
|
|
1327
1369
|
}
|
|
1328
1370
|
|
|
1329
1371
|
if (usesPersonalPlacement(task)) {
|
|
1330
|
-
|
|
1331
|
-
task,
|
|
1332
|
-
targetListId,
|
|
1333
|
-
batchSortKey
|
|
1334
|
-
);
|
|
1372
|
+
persistPersonalPlacementMove(task, batchSortKey);
|
|
1335
1373
|
} else {
|
|
1336
1374
|
const pendingTaskIds = [task.id];
|
|
1337
1375
|
markTaskIdsPending(pendingTaskIds);
|
|
@@ -1374,23 +1412,11 @@ export function useKanbanDnd({
|
|
|
1374
1412
|
clearSelection();
|
|
1375
1413
|
} else {
|
|
1376
1414
|
if (activeUsesPersonalPlacement || targetIsExternalStaging) {
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
personalPlacementOrder
|
|
1383
|
-
);
|
|
1384
|
-
} catch (error) {
|
|
1385
|
-
console.error('Failed to update personal task placement:', error);
|
|
1386
|
-
rollbackOptimisticDropPreview();
|
|
1387
|
-
toast.error(
|
|
1388
|
-
personalPlacementUpdateFailedMessage ??
|
|
1389
|
-
'Failed to update personal task placement'
|
|
1390
|
-
);
|
|
1391
|
-
resetDragState(true);
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1415
|
+
persistPersonalPlacementMove(
|
|
1416
|
+
activeTaskForDrop,
|
|
1417
|
+
newSortKey,
|
|
1418
|
+
personalPlacementOrder
|
|
1419
|
+
);
|
|
1394
1420
|
} else {
|
|
1395
1421
|
const repairedTaskSortKeys =
|
|
1396
1422
|
optimisticDropPreview?.repairedTaskSortKeys ?? [];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
2
|
+
|
|
3
|
+
export function isClosedTaskListColumnCollapsed(column: TaskList) {
|
|
4
|
+
return (
|
|
5
|
+
column.is_external_staging !== true &&
|
|
6
|
+
column.status === 'closed' &&
|
|
7
|
+
column.is_collapsed === true
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isKanbanColumnCollapsed(column: TaskList) {
|
|
12
|
+
return (
|
|
13
|
+
column.is_external_collapsed === true ||
|
|
14
|
+
isClosedTaskListColumnCollapsed(column)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -85,6 +85,20 @@ const externalList: TaskList = {
|
|
|
85
85
|
status: 'active',
|
|
86
86
|
};
|
|
87
87
|
|
|
88
|
+
const collapsedClosedList: TaskList = {
|
|
89
|
+
archived: false,
|
|
90
|
+
board_id: 'board-1',
|
|
91
|
+
color: 'PURPLE',
|
|
92
|
+
created_at: '2026-05-07T00:00:00.000Z',
|
|
93
|
+
creator_id: 'user-1',
|
|
94
|
+
deleted: false,
|
|
95
|
+
id: 'closed-list',
|
|
96
|
+
is_collapsed: true,
|
|
97
|
+
name: 'Closed',
|
|
98
|
+
position: 3,
|
|
99
|
+
status: 'closed',
|
|
100
|
+
};
|
|
101
|
+
|
|
88
102
|
function task(overrides: Partial<Task>): Task {
|
|
89
103
|
return {
|
|
90
104
|
created_at: '2026-05-07T00:00:00.000Z',
|
|
@@ -133,6 +147,38 @@ describe('KanbanColumns', () => {
|
|
|
133
147
|
).toBe(DEFAULT_KANBAN_COLUMN_WIDTH);
|
|
134
148
|
});
|
|
135
149
|
|
|
150
|
+
it('counts collapsed closed columns in dynamic width calculation', () => {
|
|
151
|
+
const { container } = render(
|
|
152
|
+
<KanbanColumns
|
|
153
|
+
columns={[...lists, collapsedClosedList]}
|
|
154
|
+
tasks={[]}
|
|
155
|
+
boardId="board-1"
|
|
156
|
+
workspaceId="ws-1"
|
|
157
|
+
isPersonalWorkspace={false}
|
|
158
|
+
disableSort={false}
|
|
159
|
+
selectedTasks={new Set()}
|
|
160
|
+
isMultiSelectMode={false}
|
|
161
|
+
setIsMultiSelectMode={vi.fn()}
|
|
162
|
+
onTaskSelect={vi.fn()}
|
|
163
|
+
onClearSelection={vi.fn()}
|
|
164
|
+
onUpdate={vi.fn()}
|
|
165
|
+
createTask={vi.fn()}
|
|
166
|
+
taskHeightsRef={{ current: new Map() }}
|
|
167
|
+
optimisticUpdateInProgress={new Set()}
|
|
168
|
+
listStatusFilter="all"
|
|
169
|
+
bulkUpdateCustomDueDate={vi.fn()}
|
|
170
|
+
boardRef={{ current: null }}
|
|
171
|
+
columnsId={[...lists, collapsedClosedList].map((list) => list.id)}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(
|
|
176
|
+
(container.firstElementChild as HTMLElement).style.getPropertyValue(
|
|
177
|
+
'--kanban-column-width'
|
|
178
|
+
)
|
|
179
|
+
).toContain('3.5rem');
|
|
180
|
+
});
|
|
181
|
+
|
|
136
182
|
it('uses mandatory snapping on the measured scroll container', () => {
|
|
137
183
|
const { container } = render(
|
|
138
184
|
<KanbanColumns
|
|
@@ -12,6 +12,7 @@ import { BoardColumn } from '../../board-column';
|
|
|
12
12
|
import type { TaskFilters } from '../../task-filter';
|
|
13
13
|
import { TaskListForm } from '../../task-list-form';
|
|
14
14
|
import type { DragPreviewPosition } from '../dnd/use-kanban-dnd';
|
|
15
|
+
import { isKanbanColumnCollapsed } from '../kanban-column-collapse';
|
|
15
16
|
import { MAX_SAFE_INTEGER_SORT } from '../kanban-constants';
|
|
16
17
|
import { getKanbanColumnWidth } from './kanban-column-width';
|
|
17
18
|
import {
|
|
@@ -50,6 +51,7 @@ interface KanbanColumnsProps {
|
|
|
50
51
|
boardRef: React.RefObject<HTMLDivElement | null>;
|
|
51
52
|
columnsId: string[];
|
|
52
53
|
onExternalTasksCollapsedChange?: (collapsed: boolean) => void;
|
|
54
|
+
onTaskListCollapsedChange?: (listId: string, collapsed: boolean) => void;
|
|
53
55
|
deadlineLabels?: KanbanDeadlineLabels;
|
|
54
56
|
deadlineSections?: KanbanDeadlineSections;
|
|
55
57
|
}
|
|
@@ -79,14 +81,13 @@ export function KanbanColumns({
|
|
|
79
81
|
boardRef,
|
|
80
82
|
columnsId,
|
|
81
83
|
onExternalTasksCollapsedChange,
|
|
84
|
+
onTaskListCollapsedChange,
|
|
82
85
|
deadlineLabels,
|
|
83
86
|
deadlineSections,
|
|
84
87
|
}: KanbanColumnsProps) {
|
|
85
88
|
const realColumns = columns.filter((column) => !column.is_external_staging);
|
|
86
89
|
const snapEdgePadding = columns.length > 0 ? '0.5rem' : '0px';
|
|
87
|
-
const collapsedColumnCount = columns.filter(
|
|
88
|
-
(column) => column.is_external_collapsed
|
|
89
|
-
).length;
|
|
90
|
+
const collapsedColumnCount = columns.filter(isKanbanColumnCollapsed).length;
|
|
90
91
|
const dynamicColumnWidth = getKanbanColumnWidth({
|
|
91
92
|
columnCount: columns.length,
|
|
92
93
|
collapsedColumnCount,
|
|
@@ -207,6 +208,7 @@ export function KanbanColumns({
|
|
|
207
208
|
workspaceId={workspaceId}
|
|
208
209
|
wsId={workspaceId}
|
|
209
210
|
onExternalTasksCollapsedChange={onExternalTasksCollapsedChange}
|
|
211
|
+
onTaskListCollapsedChange={onTaskListCollapsedChange}
|
|
210
212
|
/>
|
|
211
213
|
);
|
|
212
214
|
})}
|
|
@@ -19,18 +19,17 @@ import type { Workspace, WorkspaceProductTier } from '@tuturuuu/types';
|
|
|
19
19
|
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
20
20
|
import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
21
21
|
import { useCalendarPreferences } from '@tuturuuu/ui/hooks/use-calendar-preferences';
|
|
22
|
-
import { usePlatform } from '@tuturuuu/utils/hooks/use-platform';
|
|
23
22
|
import { coordinateGetter } from '@tuturuuu/utils/keyboard-preset';
|
|
24
23
|
import { useBoardConfig, useReorderTask } from '@tuturuuu/utils/task-helper';
|
|
25
24
|
import { useTranslations } from 'next-intl';
|
|
26
|
-
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
25
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
27
26
|
import { useTaskDialog } from '../../hooks/useTaskDialog';
|
|
28
27
|
import { useOptionalWorkspacePresenceContext } from '../../providers/workspace-presence-provider';
|
|
29
28
|
import { useBoardBroadcast } from '../../shared/board-broadcast-context';
|
|
30
29
|
import type { ListStatusFilter } from '../../shared/board-header';
|
|
31
30
|
import { buildEstimationIndices } from '../../shared/estimation-mapping';
|
|
32
31
|
import { BoardSelector } from '../board-selector';
|
|
33
|
-
import {
|
|
32
|
+
import { BulkActionsIsland } from './kanban/bulk/bulk-actions-island';
|
|
34
33
|
import { BulkCustomDateDialog } from './kanban/bulk/bulk-custom-date-dialog';
|
|
35
34
|
import { BulkDeleteDialog } from './kanban/bulk/bulk-delete-dialog';
|
|
36
35
|
import { useBulkOperations } from './kanban/bulk/bulk-operations';
|
|
@@ -76,6 +75,8 @@ interface Props {
|
|
|
76
75
|
isMultiSelectMode: boolean;
|
|
77
76
|
setIsMultiSelectMode: (enabled: boolean) => void;
|
|
78
77
|
onExternalTasksCollapsedChange?: (collapsed: boolean) => void;
|
|
78
|
+
onTaskListCollapsedChange?: (listId: string, collapsed: boolean) => void;
|
|
79
|
+
onBulkSelectionActiveChange?: (active: boolean) => void;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
export function KanbanBoard({
|
|
@@ -91,13 +92,14 @@ export function KanbanBoard({
|
|
|
91
92
|
isMultiSelectMode,
|
|
92
93
|
setIsMultiSelectMode,
|
|
93
94
|
onExternalTasksCollapsedChange,
|
|
95
|
+
onTaskListCollapsedChange,
|
|
96
|
+
onBulkSelectionActiveChange,
|
|
94
97
|
}: Props) {
|
|
95
98
|
const tLayout = useTranslations('ws-task-boards.layout_settings');
|
|
96
99
|
const tTasks = useTranslations('ws-tasks');
|
|
97
100
|
const invalidColumnMoveMessage = tLayout.has('cannot_reorder_across_statuses')
|
|
98
101
|
? tLayout('cannot_reorder_across_statuses')
|
|
99
102
|
: 'Task lists can only be reordered within the same status group';
|
|
100
|
-
const { modKey } = usePlatform();
|
|
101
103
|
const [boardSelectorOpen, setBoardSelectorOpen] = useState(false);
|
|
102
104
|
const [bulkWorking, setBulkWorking] = useState(false);
|
|
103
105
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
|
@@ -189,6 +191,17 @@ export function KanbanBoard({
|
|
|
189
191
|
setIsMultiSelectMode
|
|
190
192
|
);
|
|
191
193
|
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
onBulkSelectionActiveChange?.(selectedTasks.size > 0);
|
|
196
|
+
}, [onBulkSelectionActiveChange, selectedTasks.size]);
|
|
197
|
+
|
|
198
|
+
useEffect(
|
|
199
|
+
() => () => {
|
|
200
|
+
onBulkSelectionActiveChange?.(false);
|
|
201
|
+
},
|
|
202
|
+
[onBulkSelectionActiveChange]
|
|
203
|
+
);
|
|
204
|
+
|
|
192
205
|
// Resources Hooks
|
|
193
206
|
const { workspaceLabels, workspaceProjects, workspaceMembers } =
|
|
194
207
|
useBulkResources({
|
|
@@ -352,11 +365,9 @@ export function KanbanBoard({
|
|
|
352
365
|
|
|
353
366
|
return (
|
|
354
367
|
<div className="flex h-full flex-col">
|
|
355
|
-
<
|
|
368
|
+
<BulkActionsIsland
|
|
356
369
|
selectedCount={selectedTasks.size}
|
|
357
|
-
isMultiSelectMode={isMultiSelectMode}
|
|
358
370
|
bulkWorking={bulkWorking}
|
|
359
|
-
modKey={modKey}
|
|
360
371
|
onClearSelection={clearSelection}
|
|
361
372
|
onOpenBoardSelector={() => setBoardSelectorOpen(true)}
|
|
362
373
|
menuProps={{
|
|
@@ -440,6 +451,7 @@ export function KanbanBoard({
|
|
|
440
451
|
deadlineLabels={deadlineLabels}
|
|
441
452
|
deadlineSections={deadlineSections}
|
|
442
453
|
onExternalTasksCollapsedChange={onExternalTasksCollapsedChange}
|
|
454
|
+
onTaskListCollapsedChange={onTaskListCollapsedChange}
|
|
443
455
|
/>
|
|
444
456
|
|
|
445
457
|
<DragOverlay dropAnimation={null}>
|