@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
@@ -0,0 +1,392 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+ import { act, renderHook } from '@testing-library/react';
7
+ import type { Task } from '@tuturuuu/types/primitives/Task';
8
+ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
9
+ import type { ReactElement, ReactNode } from 'react';
10
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { useBulkOperations } from '../bulk-operations';
12
+
13
+ type MockBulkPayload = {
14
+ taskIds: string[];
15
+ operation: unknown;
16
+ };
17
+
18
+ function getEndOfTodayIso() {
19
+ const date = new Date();
20
+ date.setHours(23, 59, 59, 999);
21
+ return date.toISOString();
22
+ }
23
+
24
+ vi.mock('next-intl', () => ({
25
+ useTranslations: () => {
26
+ const translate = (key: string, values?: Record<string, unknown>) =>
27
+ values ? `${key}:${JSON.stringify(values)}` : key;
28
+ translate.has = () => false;
29
+ translate.raw = (key: string) => key;
30
+ return translate;
31
+ },
32
+ }));
33
+
34
+ vi.mock('@tuturuuu/internal-api/tasks', () => ({
35
+ bulkWorkspaceTasks: vi.fn((_workspaceId, payload) =>
36
+ Promise.resolve({
37
+ successCount: payload.taskIds.length,
38
+ failCount: 0,
39
+ taskIds: payload.taskIds,
40
+ succeededTaskIds: payload.taskIds,
41
+ failures: [],
42
+ taskMetaById: {},
43
+ })
44
+ ),
45
+ getWorkspaceTask: vi.fn(() => Promise.resolve({ task: null })),
46
+ listWorkspaceTaskLists: vi.fn(() => Promise.resolve({ lists: [] })),
47
+ updateWorkspaceTask: vi.fn(() => Promise.resolve({ task: { id: 'task-1' } })),
48
+ upsertCurrentUserTaskPersonalPlacement: vi.fn(() =>
49
+ Promise.resolve({ task: { id: 'external-task' } })
50
+ ),
51
+ }));
52
+
53
+ vi.mock('@tuturuuu/ui/sonner', () => ({
54
+ toast: {
55
+ error: vi.fn(),
56
+ success: vi.fn(),
57
+ warning: vi.fn(),
58
+ },
59
+ }));
60
+
61
+ describe('bulk mutations with personal external tasks', () => {
62
+ let queryClient: QueryClient;
63
+ let wrapper: ({ children }: { children: ReactNode }) => ReactElement;
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ let mockBulkWorkspaceTasks: any;
66
+
67
+ const localTask = {
68
+ id: 'local-task',
69
+ name: 'Local task',
70
+ ws_id: 'personal-ws',
71
+ list_id: 'list-1',
72
+ priority: 'normal',
73
+ estimation_points: 1,
74
+ assignees: [],
75
+ labels: [],
76
+ projects: [],
77
+ created_at: '2026-01-01T00:00:00.000Z',
78
+ closed_at: null,
79
+ completed_at: null,
80
+ } as unknown as Task;
81
+
82
+ const externalTask = {
83
+ id: 'external-task',
84
+ name: 'External task',
85
+ ws_id: 'source-ws',
86
+ list_id: 'list-1',
87
+ priority: 'low',
88
+ estimation_points: 2,
89
+ assignees: [],
90
+ labels: [],
91
+ projects: [],
92
+ personal_board_id: 'board-1',
93
+ personal_list_id: 'list-1',
94
+ source_workspace_id: 'source-ws',
95
+ source_board_id: 'source-board',
96
+ source_list_id: 'source-list',
97
+ source_list_status: 'active',
98
+ is_personal_external: true,
99
+ is_personal_external_default: false,
100
+ created_at: '2026-01-01T00:00:00.000Z',
101
+ closed_at: null,
102
+ completed_at: null,
103
+ } as unknown as Task;
104
+
105
+ const columns = [
106
+ {
107
+ id: 'list-1',
108
+ name: 'Active',
109
+ board_id: 'board-1',
110
+ status: 'active',
111
+ created_at: '2026-01-01T00:00:00.000Z',
112
+ deleted: false,
113
+ },
114
+ ] as unknown as TaskList[];
115
+
116
+ async function renderBulkOperations() {
117
+ const { result } = renderHook(
118
+ () =>
119
+ useBulkOperations({
120
+ queryClient,
121
+ wsId: 'personal-ws',
122
+ boardId: 'board-1',
123
+ selectedTasks: new Set(['local-task', 'external-task']),
124
+ columns,
125
+ workspaceLabels: [
126
+ {
127
+ id: 'label-1',
128
+ ws_id: 'personal-ws',
129
+ name: 'Label',
130
+ color: '#111111',
131
+ created_at: '2026-01-01T00:00:00.000Z',
132
+ },
133
+ ],
134
+ workspaceProjects: [
135
+ {
136
+ id: 'project-1',
137
+ name: 'Project',
138
+ status: 'active',
139
+ },
140
+ ],
141
+ workspaceMembers: [
142
+ {
143
+ id: 'user-1',
144
+ user_id: 'user-1',
145
+ display_name: 'User One',
146
+ email: 'user@example.com',
147
+ avatar_url: null,
148
+ },
149
+ ],
150
+ setBulkWorking: vi.fn(),
151
+ clearSelection: vi.fn(),
152
+ setBulkDeleteOpen: vi.fn(),
153
+ }),
154
+ { wrapper }
155
+ );
156
+
157
+ return result;
158
+ }
159
+
160
+ beforeEach(async () => {
161
+ vi.useFakeTimers();
162
+ vi.setSystemTime(new Date('2026-04-09T10:00:00.000Z'));
163
+
164
+ queryClient = new QueryClient({
165
+ defaultOptions: {
166
+ queries: { retry: false },
167
+ mutations: { retry: false },
168
+ },
169
+ });
170
+ wrapper = ({ children }: { children: ReactNode }) => (
171
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
172
+ );
173
+
174
+ queryClient.setQueryData(['tasks', 'board-1'], [localTask, externalTask]);
175
+ queryClient.setQueryData(
176
+ ['tasks-full', 'board-1', 'all'],
177
+ [localTask, externalTask]
178
+ );
179
+
180
+ const { bulkWorkspaceTasks } = await import('@tuturuuu/internal-api/tasks');
181
+ mockBulkWorkspaceTasks = bulkWorkspaceTasks as any;
182
+ vi.clearAllMocks();
183
+ });
184
+
185
+ it.each([
186
+ [
187
+ 'priority',
188
+ async (result: Awaited<ReturnType<typeof renderBulkOperations>>) =>
189
+ result.current.bulkUpdatePriority('high'),
190
+ {
191
+ type: 'update_fields',
192
+ updates: { priority: 'high' },
193
+ },
194
+ ],
195
+ [
196
+ 'due date',
197
+ async (result: Awaited<ReturnType<typeof renderBulkOperations>>) =>
198
+ result.current.bulkUpdateDueDate('today'),
199
+ () => ({
200
+ type: 'update_fields',
201
+ updates: { end_date: getEndOfTodayIso() },
202
+ }),
203
+ ],
204
+ [
205
+ 'estimation',
206
+ async (result: Awaited<ReturnType<typeof renderBulkOperations>>) =>
207
+ result.current.bulkUpdateEstimation(5),
208
+ {
209
+ type: 'update_fields',
210
+ updates: { estimation_points: 5 },
211
+ },
212
+ ],
213
+ ])('groups %s updates by each task source workspace', async (_, run, operation) => {
214
+ const result = await renderBulkOperations();
215
+ const expectedOperation =
216
+ typeof operation === 'function' ? operation() : operation;
217
+
218
+ await act(async () => {
219
+ await run(result);
220
+ });
221
+
222
+ expect(mockBulkWorkspaceTasks).toHaveBeenCalledTimes(2);
223
+ expect(mockBulkWorkspaceTasks).toHaveBeenNthCalledWith(
224
+ 1,
225
+ 'personal-ws',
226
+ {
227
+ taskIds: ['local-task'],
228
+ operation: expectedOperation,
229
+ },
230
+ expect.anything()
231
+ );
232
+ expect(mockBulkWorkspaceTasks).toHaveBeenNthCalledWith(
233
+ 2,
234
+ 'source-ws',
235
+ {
236
+ taskIds: ['external-task'],
237
+ operation: expectedOperation,
238
+ },
239
+ expect.anything()
240
+ );
241
+ });
242
+
243
+ it('updates mounted full-task caches during optimistic priority updates', async () => {
244
+ const result = await renderBulkOperations();
245
+
246
+ await act(async () => {
247
+ await result.current.bulkUpdatePriority('high');
248
+ });
249
+
250
+ const fullTasks = queryClient.getQueryData<Task[]>([
251
+ 'tasks-full',
252
+ 'board-1',
253
+ 'all',
254
+ ]);
255
+
256
+ expect(fullTasks?.map((task) => [task.id, task.priority])).toEqual([
257
+ ['local-task', 'high'],
258
+ ['external-task', 'high'],
259
+ ]);
260
+ });
261
+
262
+ it('rolls back only failed source-workspace ids on partial success', async () => {
263
+ mockBulkWorkspaceTasks.mockImplementation(
264
+ (workspaceId: string, payload: MockBulkPayload) =>
265
+ Promise.resolve(
266
+ workspaceId === 'source-ws'
267
+ ? {
268
+ successCount: 0,
269
+ failCount: payload.taskIds.length,
270
+ taskIds: payload.taskIds,
271
+ succeededTaskIds: [],
272
+ failures: payload.taskIds.map((taskId: string) => ({
273
+ taskId,
274
+ error: 'Task not found',
275
+ })),
276
+ taskMetaById: {},
277
+ }
278
+ : {
279
+ successCount: payload.taskIds.length,
280
+ failCount: 0,
281
+ taskIds: payload.taskIds,
282
+ succeededTaskIds: payload.taskIds,
283
+ failures: [],
284
+ taskMetaById: {},
285
+ }
286
+ )
287
+ );
288
+ const result = await renderBulkOperations();
289
+
290
+ await act(async () => {
291
+ await result.current.bulkUpdatePriority('high');
292
+ });
293
+
294
+ const tasks = queryClient.getQueryData<Task[]>(['tasks', 'board-1']);
295
+ expect(tasks?.map((task) => [task.id, task.priority])).toEqual([
296
+ ['local-task', 'high'],
297
+ ['external-task', 'low'],
298
+ ]);
299
+ });
300
+
301
+ it('groups assignee and delete mutations by each task source workspace', async () => {
302
+ const result = await renderBulkOperations();
303
+
304
+ await act(async () => {
305
+ await result.current.bulkAddAssignee('user-1');
306
+ });
307
+
308
+ expect(mockBulkWorkspaceTasks).toHaveBeenNthCalledWith(
309
+ 1,
310
+ 'personal-ws',
311
+ expect.objectContaining({
312
+ taskIds: ['local-task'],
313
+ operation: { type: 'add_assignee', assigneeId: 'user-1' },
314
+ }),
315
+ expect.anything()
316
+ );
317
+ expect(mockBulkWorkspaceTasks).toHaveBeenNthCalledWith(
318
+ 2,
319
+ 'source-ws',
320
+ expect.objectContaining({
321
+ taskIds: ['external-task'],
322
+ operation: { type: 'add_assignee', assigneeId: 'user-1' },
323
+ }),
324
+ expect.anything()
325
+ );
326
+
327
+ mockBulkWorkspaceTasks.mockClear();
328
+
329
+ await act(async () => {
330
+ await result.current.bulkDeleteTasks();
331
+ });
332
+
333
+ expect(mockBulkWorkspaceTasks).toHaveBeenNthCalledWith(
334
+ 1,
335
+ 'personal-ws',
336
+ expect.objectContaining({
337
+ taskIds: ['local-task'],
338
+ operation: {
339
+ type: 'update_fields',
340
+ updates: { deleted: true },
341
+ },
342
+ }),
343
+ expect.anything()
344
+ );
345
+ expect(mockBulkWorkspaceTasks).toHaveBeenNthCalledWith(
346
+ 2,
347
+ 'source-ws',
348
+ expect.objectContaining({
349
+ taskIds: ['external-task'],
350
+ operation: {
351
+ type: 'update_fields',
352
+ updates: { deleted: true },
353
+ },
354
+ }),
355
+ expect.anything()
356
+ );
357
+ });
358
+
359
+ it('keeps label and project overlays on the personal workspace route', async () => {
360
+ const result = await renderBulkOperations();
361
+
362
+ await act(async () => {
363
+ await result.current.bulkAddLabel('label-1');
364
+ });
365
+
366
+ expect(mockBulkWorkspaceTasks).toHaveBeenCalledTimes(1);
367
+ expect(mockBulkWorkspaceTasks).toHaveBeenCalledWith(
368
+ 'personal-ws',
369
+ expect.objectContaining({
370
+ taskIds: ['local-task', 'external-task'],
371
+ operation: { type: 'add_label', labelId: 'label-1' },
372
+ }),
373
+ expect.anything()
374
+ );
375
+
376
+ mockBulkWorkspaceTasks.mockClear();
377
+
378
+ await act(async () => {
379
+ await result.current.bulkAddProject('project-1');
380
+ });
381
+
382
+ expect(mockBulkWorkspaceTasks).toHaveBeenCalledTimes(1);
383
+ expect(mockBulkWorkspaceTasks).toHaveBeenCalledWith(
384
+ 'personal-ws',
385
+ expect.objectContaining({
386
+ taskIds: ['local-task', 'external-task'],
387
+ operation: { type: 'add_project', projectId: 'project-1' },
388
+ }),
389
+ expect.anything()
390
+ );
391
+ });
392
+ });
@@ -0,0 +1,57 @@
1
+ import '@testing-library/jest-dom';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import type { ComponentProps } from 'react';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { BulkActionsIsland } from './bulk-actions-island';
6
+ import type { BulkActionsMenu } from './bulk-actions-menu';
7
+
8
+ vi.mock('next-intl', () => ({
9
+ useTranslations: () => (key: string) => key,
10
+ }));
11
+
12
+ vi.mock('./bulk-actions-menu', () => ({
13
+ BulkActionsMenu: () => <div data-testid="bulk-actions-menu" />,
14
+ }));
15
+
16
+ const menuProps = {} as ComponentProps<typeof BulkActionsMenu>;
17
+
18
+ describe('BulkActionsIsland', () => {
19
+ it('is hidden until at least one task is selected', () => {
20
+ render(
21
+ <BulkActionsIsland
22
+ selectedCount={0}
23
+ bulkWorking={false}
24
+ onClearSelection={vi.fn()}
25
+ onOpenBoardSelector={vi.fn()}
26
+ menuProps={menuProps}
27
+ />
28
+ );
29
+
30
+ expect(screen.queryByTestId('kanban-bulk-actions-island')).toBeNull();
31
+ });
32
+
33
+ it('renders compact bulk controls without the old instruction copy', () => {
34
+ const onClearSelection = vi.fn();
35
+ const onOpenBoardSelector = vi.fn();
36
+
37
+ render(
38
+ <BulkActionsIsland
39
+ selectedCount={2}
40
+ bulkWorking={false}
41
+ onClearSelection={onClearSelection}
42
+ onOpenBoardSelector={onOpenBoardSelector}
43
+ menuProps={menuProps}
44
+ />
45
+ );
46
+
47
+ const island = screen.getByTestId('kanban-bulk-actions-island');
48
+ expect(island).toHaveTextContent('2 tasks_plural');
49
+ expect(island).not.toHaveTextContent('selection_instruction');
50
+
51
+ fireEvent.click(screen.getByRole('button', { name: 'common.move' }));
52
+ expect(onOpenBoardSelector).toHaveBeenCalledTimes(1);
53
+
54
+ fireEvent.click(screen.getByRole('button', { name: 'common.clear' }));
55
+ expect(onClearSelection).toHaveBeenCalledTimes(1);
56
+ });
57
+ });
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import {
4
+ ArrowRightLeft,
5
+ Check,
6
+ Loader2,
7
+ MoreHorizontal,
8
+ X,
9
+ } from '@tuturuuu/icons';
10
+ import { Button } from '@tuturuuu/ui/button';
11
+ import { DropdownMenu, DropdownMenuTrigger } from '@tuturuuu/ui/dropdown-menu';
12
+ import { cn } from '@tuturuuu/utils/format';
13
+ import { useTranslations } from 'next-intl';
14
+ import type { ComponentProps } from 'react';
15
+ import { BulkActionsMenu } from './bulk-actions-menu';
16
+
17
+ interface BulkActionsIslandProps {
18
+ selectedCount: number;
19
+ bulkWorking: boolean;
20
+ onClearSelection: () => void;
21
+ onOpenBoardSelector: () => void;
22
+ menuProps: ComponentProps<typeof BulkActionsMenu>;
23
+ }
24
+
25
+ export function BulkActionsIsland({
26
+ selectedCount,
27
+ bulkWorking,
28
+ onClearSelection,
29
+ onOpenBoardSelector,
30
+ menuProps,
31
+ }: BulkActionsIslandProps) {
32
+ const t = useTranslations();
33
+ const tc = useTranslations('common');
34
+
35
+ if (selectedCount <= 0) return null;
36
+
37
+ return (
38
+ <div
39
+ className="pointer-events-none fixed inset-x-3 bottom-4 z-40 flex justify-center sm:bottom-6"
40
+ data-testid="kanban-bulk-actions-island"
41
+ >
42
+ <div
43
+ className={cn(
44
+ 'pointer-events-auto flex max-w-[calc(100vw-1.5rem)] items-center gap-1.5 rounded-2xl border border-border/70 bg-background/95 p-1.5 shadow-xl backdrop-blur-xl',
45
+ 'transition-[opacity,transform] duration-200 ease-out'
46
+ )}
47
+ >
48
+ <div className="flex min-w-0 items-center gap-2 rounded-xl bg-muted/60 px-2.5 py-1.5">
49
+ <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
50
+ {bulkWorking ? (
51
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
52
+ ) : (
53
+ <Check className="h-3.5 w-3.5" />
54
+ )}
55
+ </span>
56
+ <span className="whitespace-nowrap font-semibold text-sm">
57
+ {selectedCount}{' '}
58
+ {selectedCount === 1 ? tc('task') : tc('tasks_plural')}
59
+ </span>
60
+ </div>
61
+
62
+ <DropdownMenu>
63
+ <DropdownMenuTrigger asChild>
64
+ <Button
65
+ variant="ghost"
66
+ size="sm"
67
+ className="h-9 gap-1.5 rounded-xl px-2.5"
68
+ disabled={bulkWorking}
69
+ aria-label={t('common.bulk_actions')}
70
+ >
71
+ <MoreHorizontal className="h-4 w-4" />
72
+ <span className="hidden sm:inline">
73
+ {t('common.bulk_actions')}
74
+ </span>
75
+ </Button>
76
+ </DropdownMenuTrigger>
77
+ <BulkActionsMenu {...menuProps} />
78
+ </DropdownMenu>
79
+
80
+ <Button
81
+ variant="ghost"
82
+ size="sm"
83
+ onClick={onOpenBoardSelector}
84
+ className="h-9 gap-1.5 rounded-xl px-2.5"
85
+ disabled={bulkWorking}
86
+ aria-label={t('common.move')}
87
+ >
88
+ <ArrowRightLeft className="h-4 w-4" />
89
+ <span className="hidden sm:inline">{t('common.move')}</span>
90
+ </Button>
91
+
92
+ <Button
93
+ variant="ghost"
94
+ size="sm"
95
+ onClick={onClearSelection}
96
+ className="h-9 gap-1.5 rounded-xl px-2.5"
97
+ disabled={bulkWorking}
98
+ aria-label={t('common.clear')}
99
+ >
100
+ <X className="h-4 w-4" />
101
+ <span className="hidden sm:inline">{t('common.clear')}</span>
102
+ </Button>
103
+ </div>
104
+ </div>
105
+ );
106
+ }