@tuturuuu/ui 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/package.json +79 -67
  3. package/src/components/ui/__tests__/avatar.test.tsx +8 -5
  4. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
  8. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
  9. package/src/components/ui/chart.test.tsx +29 -0
  10. package/src/components/ui/chart.tsx +12 -3
  11. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
  12. package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
  13. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
  14. package/src/components/ui/custom/common-footer.tsx +16 -1
  15. package/src/components/ui/custom/production-indicator.tsx +1 -1
  16. package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
  17. package/src/components/ui/custom/settings/task-settings.tsx +18 -0
  18. package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
  19. package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
  20. package/src/components/ui/custom/sidebar-context.tsx +61 -61
  21. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
  22. package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
  23. package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
  24. package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
  25. package/src/components/ui/custom/workspace-select.tsx +33 -12
  26. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
  27. package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
  28. package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
  29. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
  30. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
  31. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
  32. package/src/components/ui/finance/invoices/hooks.ts +75 -20
  33. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
  34. package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
  35. package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
  36. package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
  37. package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
  38. package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
  39. package/src/components/ui/finance/invoices/utils.test.ts +50 -0
  40. package/src/components/ui/finance/invoices/utils.ts +75 -17
  41. package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
  42. package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
  43. package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
  44. package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
  45. package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
  46. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  47. package/src/components/ui/finance/transactions/form.test.tsx +43 -0
  48. package/src/components/ui/finance/transactions/form.tsx +60 -0
  49. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
  50. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
  51. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
  52. package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
  53. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  54. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
  55. package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
  56. package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
  57. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
  58. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
  59. package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
  60. package/src/components/ui/legacy/meet/page.test.ts +180 -0
  61. package/src/components/ui/legacy/meet/page.tsx +87 -39
  62. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
  63. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
  64. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
  65. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
  66. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
  67. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
  68. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
  69. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
  70. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
  71. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
  72. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
  73. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
  74. package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
  75. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
  76. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
  77. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
  78. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
  79. package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
  80. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
  81. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
  82. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
  83. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
  84. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
  85. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
  86. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
  87. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
  88. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
  89. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
  90. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
  91. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
  92. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
  93. package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
  94. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
  95. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
  96. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
  97. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
  98. package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
  99. package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
  100. package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
  101. package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
  102. package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
  103. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
  104. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
  105. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
  107. package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
  108. package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
  109. package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
  110. package/src/hooks/use-calendar-sync.tsx +22 -277
  111. package/src/hooks/use-calendar.tsx +95 -525
  112. package/src/hooks/use-task-actions.ts +43 -117
  113. package/src/hooks/use-user-config.ts +1 -1
  114. package/src/hooks/use-workspace-config.ts +6 -2
  115. package/src/hooks/use-workspace-presence.ts +1 -1
  116. 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 { getWorkspaceTask } from '@tuturuuu/internal-api/tasks';
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
- await deleteMutation.mutateAsync({ taskIds });
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
- if (previousTasks) {
498
- queryClient.setQueryData(['tasks', boardId], previousTasks);
499
- }
500
- if (previousFullTasks) {
501
- queryClient.setQueryData(['tasks-full', boardId], previousFullTasks);
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
- await movePersonalPlacementTask(task, targetListId, null);
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
- await movePersonalPlacementTask(
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
- try {
1378
- await movePersonalPlacementTask(
1379
- activeTaskForDrop,
1380
- targetListId,
1381
- newSortKey,
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 { BulkActionsBar } from './kanban/bulk/bulk-actions-bar';
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
- <BulkActionsBar
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}>