@tuturuuu/ui 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +126 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +146 -0
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  22. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  23. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  24. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  25. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +219 -0
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  28. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  29. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  30. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  31. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +197 -0
  32. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  33. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +541 -0
  34. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +362 -0
  35. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  36. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  37. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  38. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  39. package/src/components/ui/finance/wallets/form.tsx +41 -197
  40. package/src/components/ui/finance/wallets/query-invalidation.ts +3 -0
  41. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  42. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  43. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  48. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +71 -5
  49. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +52 -35
  50. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  51. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  52. package/src/components/ui/finance/wallets/wallets-page.test.tsx +117 -36
  53. package/src/components/ui/finance/wallets/wallets-page.tsx +40 -64
  54. package/src/components/ui/storefront/accent-button.tsx +33 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  56. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  58. package/src/components/ui/storefront/image-panel.tsx +40 -0
  59. package/src/components/ui/storefront/index.ts +12 -0
  60. package/src/components/ui/storefront/listing-card.tsx +129 -0
  61. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  62. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  63. package/src/components/ui/storefront/types.ts +99 -0
  64. package/src/components/ui/storefront/utils.ts +90 -0
  65. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  66. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  67. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  68. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  69. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  70. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  71. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  72. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  73. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  74. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  75. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +124 -7
  76. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  77. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  78. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  79. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  80. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  81. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  82. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  83. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +268 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +243 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +41 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +157 -102
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  100. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +959 -340
  101. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  102. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  103. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  104. package/src/hooks/use-task-actions.ts +45 -0
  105. package/src/hooks/useBoardRealtime.ts +54 -1
  106. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  107. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -10,9 +10,11 @@ import {
10
10
  } from '@tuturuuu/internal-api';
11
11
  import type { TaskWithRelations } from '@tuturuuu/types';
12
12
  import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
13
+ import { getActiveTaskUserBroadcast } from '@tuturuuu/ui/hooks/useTaskUserRealtime';
13
14
  import { toast } from '@tuturuuu/ui/sonner';
14
15
  import { useTranslations } from 'next-intl';
15
16
  import { useCallback, useState } from 'react';
17
+ import { dispatchTaskSoundCue } from '../shared/task-sound-effects';
16
18
  import {
17
19
  MY_COMPLETED_TASKS_QUERY_KEY,
18
20
  MY_TASKS_QUERY_KEY,
@@ -37,6 +39,7 @@ export function useTaskContextActions({
37
39
  const [isLoading, setIsLoading] = useState(false);
38
40
  const taskWorkspaceId = task.list?.board?.ws_id ?? null;
39
41
  const taskBoardId = task.list?.board?.id ?? null;
42
+ const taskListId = task.list_id ?? task.list?.id ?? null;
40
43
 
41
44
  const invalidateQueries = useCallback(() => {
42
45
  queryClient.invalidateQueries({ queryKey: [MY_TASKS_QUERY_KEY] });
@@ -65,6 +68,63 @@ export function useTaskContextActions({
65
68
  [queryClient, task.id]
66
69
  );
67
70
 
71
+ const broadcastTaskUpsert = useCallback(
72
+ (updates: Record<string, unknown>) => {
73
+ const broadcast = getActiveTaskUserBroadcast();
74
+ if (!broadcast) return;
75
+
76
+ const nextListId =
77
+ typeof updates.list_id === 'string' ? updates.list_id : taskListId;
78
+ broadcast('task:upsert', {
79
+ actor_user_id: userId,
80
+ actorUserId: userId,
81
+ boardId: taskBoardId,
82
+ listId: nextListId,
83
+ task: {
84
+ id: task.id,
85
+ name: task.name,
86
+ description: task.description ?? null,
87
+ priority: task.priority ?? null,
88
+ start_date: task.start_date ?? null,
89
+ end_date: task.end_date ?? null,
90
+ list_id: nextListId,
91
+ created_at: task.created_at ?? null,
92
+ list: task.list,
93
+ overrides: task.overrides,
94
+ ...updates,
95
+ },
96
+ });
97
+ },
98
+ [task, taskBoardId, taskListId, userId]
99
+ );
100
+
101
+ const broadcastTaskDelete = useCallback(() => {
102
+ const broadcast = getActiveTaskUserBroadcast();
103
+ if (!broadcast) return;
104
+
105
+ broadcast('task:delete', {
106
+ actor_user_id: userId,
107
+ actorUserId: userId,
108
+ boardId: taskBoardId,
109
+ listId: taskListId,
110
+ taskId: task.id,
111
+ });
112
+ }, [task.id, taskBoardId, taskListId, userId]);
113
+
114
+ const broadcastTaskRelationsChanged = useCallback(() => {
115
+ const broadcast = getActiveTaskUserBroadcast();
116
+ if (!broadcast) return;
117
+
118
+ broadcast('task:relations-changed', {
119
+ actor_user_id: userId,
120
+ actorUserId: userId,
121
+ boardId: taskBoardId,
122
+ listId: taskListId,
123
+ taskId: task.id,
124
+ taskIds: [task.id],
125
+ });
126
+ }, [task.id, taskBoardId, taskListId, userId]);
127
+
68
128
  const handlePriorityChange = useCallback(
69
129
  async (priority: TaskPriority | null) => {
70
130
  setIsLoading(true);
@@ -72,7 +132,9 @@ export function useTaskContextActions({
72
132
  try {
73
133
  if (!taskWorkspaceId) throw new Error('Task workspace not found');
74
134
  await updateWorkspaceTask(taskWorkspaceId, task.id, { priority });
135
+ broadcastTaskUpsert({ priority });
75
136
  invalidateQueries();
137
+ dispatchTaskSoundCue('update');
76
138
  } catch {
77
139
  toast.error(t('failed_to_update'));
78
140
  invalidateQueries();
@@ -80,7 +142,14 @@ export function useTaskContextActions({
80
142
  setIsLoading(false);
81
143
  }
82
144
  },
83
- [task.id, taskWorkspaceId, updateTaskInCache, invalidateQueries, t]
145
+ [
146
+ task.id,
147
+ taskWorkspaceId,
148
+ updateTaskInCache,
149
+ invalidateQueries,
150
+ broadcastTaskUpsert,
151
+ t,
152
+ ]
84
153
  );
85
154
 
86
155
  const handleDueDateChange = useCallback(
@@ -96,7 +165,9 @@ export function useTaskContextActions({
96
165
  await updateWorkspaceTask(taskWorkspaceId, task.id, {
97
166
  end_date: newDate,
98
167
  });
168
+ broadcastTaskUpsert({ end_date: newDate });
99
169
  invalidateQueries();
170
+ dispatchTaskSoundCue('update');
100
171
  } catch {
101
172
  toast.error(t('failed_to_update'));
102
173
  invalidateQueries();
@@ -104,7 +175,14 @@ export function useTaskContextActions({
104
175
  setIsLoading(false);
105
176
  }
106
177
  },
107
- [task.id, taskWorkspaceId, updateTaskInCache, invalidateQueries, t]
178
+ [
179
+ task.id,
180
+ taskWorkspaceId,
181
+ updateTaskInCache,
182
+ invalidateQueries,
183
+ broadcastTaskUpsert,
184
+ t,
185
+ ]
108
186
  );
109
187
 
110
188
  const handleToggleLabel = useCallback(
@@ -118,7 +196,9 @@ export function useTaskContextActions({
118
196
  } else {
119
197
  await addWorkspaceTaskLabel(taskWorkspaceId, task.id, labelId);
120
198
  }
199
+ broadcastTaskRelationsChanged();
121
200
  invalidateQueries();
201
+ dispatchTaskSoundCue('update');
122
202
  } catch {
123
203
  toast.error(t('failed_to_update'));
124
204
  invalidateQueries();
@@ -126,7 +206,14 @@ export function useTaskContextActions({
126
206
  setIsLoading(false);
127
207
  }
128
208
  },
129
- [task.id, task.labels, taskWorkspaceId, invalidateQueries, t]
209
+ [
210
+ task.id,
211
+ task.labels,
212
+ taskWorkspaceId,
213
+ invalidateQueries,
214
+ broadcastTaskRelationsChanged,
215
+ t,
216
+ ]
130
217
  );
131
218
 
132
219
  const handleComplete = useCallback(async () => {
@@ -147,6 +234,9 @@ export function useTaskContextActions({
147
234
  await updateWorkspaceTask(taskWorkspaceId, task.id, {
148
235
  list_id: doneList.id,
149
236
  });
237
+ broadcastTaskUpsert({
238
+ list_id: doneList.id,
239
+ });
150
240
 
151
241
  // Clear redundant personal overrides when task is actually done
152
242
  if (
@@ -167,6 +257,7 @@ export function useTaskContextActions({
167
257
 
168
258
  onTaskUpdate();
169
259
  onClose();
260
+ dispatchTaskSoundCue('complete');
170
261
  } catch {
171
262
  toast.error(t('failed_to_update'));
172
263
  } finally {
@@ -177,6 +268,7 @@ export function useTaskContextActions({
177
268
  taskBoardId,
178
269
  taskWorkspaceId,
179
270
  task.overrides,
271
+ broadcastTaskUpsert,
180
272
  onTaskUpdate,
181
273
  onClose,
182
274
  t,
@@ -194,14 +286,16 @@ export function useTaskContextActions({
194
286
  }
195
287
  );
196
288
  if (!response.ok) throw new Error('Failed');
289
+ broadcastTaskDelete();
197
290
  onTaskUpdate();
198
291
  onClose();
292
+ dispatchTaskSoundCue('complete');
199
293
  } catch {
200
294
  toast.error(t('failed_to_update'));
201
295
  } finally {
202
296
  setIsLoading(false);
203
297
  }
204
- }, [task.id, onTaskUpdate, onClose, t]);
298
+ }, [task.id, broadcastTaskDelete, onTaskUpdate, onClose, t]);
205
299
 
206
300
  const handleUndoDoneWithMyPart = useCallback(async () => {
207
301
  setIsLoading(true);
@@ -218,14 +312,22 @@ export function useTaskContextActions({
218
312
  }
219
313
  );
220
314
  if (!response.ok) throw new Error('Failed');
315
+ broadcastTaskUpsert({
316
+ overrides: {
317
+ ...task.overrides,
318
+ personally_unassigned: false,
319
+ completed_at: null,
320
+ },
321
+ });
221
322
  onTaskUpdate();
222
323
  onClose();
324
+ dispatchTaskSoundCue('update');
223
325
  } catch {
224
326
  toast.error(t('failed_to_update'));
225
327
  } finally {
226
328
  setIsLoading(false);
227
329
  }
228
- }, [task.id, onTaskUpdate, onClose, t]);
330
+ }, [task.id, task.overrides, broadcastTaskUpsert, onTaskUpdate, onClose, t]);
229
331
 
230
332
  const handleUndoComplete = useCallback(async () => {
231
333
  if (!taskBoardId || !taskWorkspaceId) return;
@@ -247,15 +349,25 @@ export function useTaskContextActions({
247
349
  await updateWorkspaceTask(taskWorkspaceId, task.id, {
248
350
  list_id: activeList.id,
249
351
  });
352
+ broadcastTaskUpsert({ list_id: activeList.id });
250
353
 
251
354
  onTaskUpdate();
252
355
  onClose();
356
+ dispatchTaskSoundCue('move');
253
357
  } catch {
254
358
  toast.error(t('failed_to_update'));
255
359
  } finally {
256
360
  setIsLoading(false);
257
361
  }
258
- }, [task.id, taskBoardId, taskWorkspaceId, onTaskUpdate, onClose, t]);
362
+ }, [
363
+ task.id,
364
+ taskBoardId,
365
+ taskWorkspaceId,
366
+ broadcastTaskUpsert,
367
+ onTaskUpdate,
368
+ onClose,
369
+ t,
370
+ ]);
259
371
 
260
372
  const handleUnassignMe = useCallback(async () => {
261
373
  setIsLoading(true);
@@ -283,8 +395,10 @@ export function useTaskContextActions({
283
395
  Boolean(assigneeId && assigneeId !== userId)
284
396
  ),
285
397
  });
398
+ broadcastTaskDelete();
286
399
  onTaskUpdate();
287
400
  onClose();
401
+ dispatchTaskSoundCue('update');
288
402
  } catch {
289
403
  toast.error(t('failed_to_update'));
290
404
  invalidateQueries();
@@ -301,6 +415,7 @@ export function useTaskContextActions({
301
415
  invalidateQueries,
302
416
  t,
303
417
  task.assignees,
418
+ broadcastTaskDelete,
304
419
  ]);
305
420
 
306
421
  const handleDelete = useCallback(async () => {
@@ -308,14 +423,16 @@ export function useTaskContextActions({
308
423
  try {
309
424
  if (!taskWorkspaceId) throw new Error('Task workspace not found');
310
425
  await deleteWorkspaceTask(taskWorkspaceId, task.id);
426
+ broadcastTaskDelete();
311
427
  onTaskUpdate();
312
428
  onClose();
429
+ dispatchTaskSoundCue('delete');
313
430
  } catch {
314
431
  toast.error(t('failed_to_update'));
315
432
  } finally {
316
433
  setIsLoading(false);
317
434
  }
318
- }, [task.id, taskWorkspaceId, onTaskUpdate, onClose, t]);
435
+ }, [task.id, taskWorkspaceId, broadcastTaskDelete, onTaskUpdate, onClose, t]);
319
436
 
320
437
  return {
321
438
  isLoading,
@@ -9,8 +9,8 @@ import {
9
9
  useTaskDialogContext,
10
10
  } from '../task-dialog-provider';
11
11
 
12
- const { mockGetCurrentUserTask } = vi.hoisted(() => ({
13
- mockGetCurrentUserTask: vi.fn(),
12
+ const { mockGetTaskDialogHydration } = vi.hoisted(() => ({
13
+ mockGetTaskDialogHydration: vi.fn(),
14
14
  }));
15
15
 
16
16
  vi.mock('@tuturuuu/internal-api/tasks', async () => {
@@ -20,7 +20,7 @@ vi.mock('@tuturuuu/internal-api/tasks', async () => {
20
20
 
21
21
  return {
22
22
  ...actual,
23
- getCurrentUserTask: mockGetCurrentUserTask,
23
+ getTaskDialogHydration: mockGetTaskDialogHydration,
24
24
  };
25
25
  });
26
26
 
@@ -59,13 +59,25 @@ const wrapper = ({ children }: { children: ReactNode }) => (
59
59
  <TaskDialogProvider>{children}</TaskDialogProvider>
60
60
  );
61
61
 
62
+ function createDeferred<T>() {
63
+ let resolve!: (value: T) => void;
64
+ let reject!: (reason?: unknown) => void;
65
+ const promise = new Promise<T>((promiseResolve, promiseReject) => {
66
+ resolve = promiseResolve;
67
+ reject = promiseReject;
68
+ });
69
+
70
+ return { promise, resolve, reject };
71
+ }
72
+
62
73
  describe('TaskDialogProvider', () => {
63
74
  afterEach(() => {
75
+ mockGetTaskDialogHydration.mockReset();
64
76
  vi.useRealTimers();
65
77
  });
66
78
 
67
79
  it('should enable collaborationMode for paid task workspaces opened by id', async () => {
68
- mockGetCurrentUserTask.mockResolvedValueOnce({
80
+ mockGetTaskDialogHydration.mockResolvedValueOnce({
69
81
  task: {
70
82
  ...mockTask,
71
83
  list: { board_id: 'board-1' },
@@ -87,7 +99,7 @@ describe('TaskDialogProvider', () => {
87
99
  });
88
100
 
89
101
  it('should disable collaborationMode for free task workspaces opened by id', async () => {
90
- mockGetCurrentUserTask.mockResolvedValueOnce({
102
+ mockGetTaskDialogHydration.mockResolvedValueOnce({
91
103
  task: {
92
104
  ...mockTask,
93
105
  list: { board_id: 'board-1' },
@@ -108,6 +120,206 @@ describe('TaskDialogProvider', () => {
108
120
  expect(result.current.state.taskWorkspaceTier).toBe('FREE');
109
121
  });
110
122
 
123
+ it('opens by id immediately from an initial snapshot before hydrating task details', async () => {
124
+ const initialSharedContext = {
125
+ boardConfig: {
126
+ id: 'board-1',
127
+ name: 'Visible board',
128
+ ws_id: 'workspace-1',
129
+ ticket_prefix: 'VIS',
130
+ },
131
+ availableLists: [mockList],
132
+ workspaceLabels: [],
133
+ workspaceMembers: [],
134
+ workspaceProjects: [],
135
+ };
136
+ const deferred = createDeferred<{
137
+ task: Task & { list?: { board_id?: string | null } | null };
138
+ availableLists: TaskList[];
139
+ taskWsId: string;
140
+ taskWorkspacePersonal: boolean;
141
+ taskWorkspaceTier: 'PRO';
142
+ }>();
143
+ mockGetTaskDialogHydration.mockReturnValueOnce(deferred.promise);
144
+
145
+ const { result } = renderHook(() => useTaskDialogContext(), { wrapper });
146
+ let openPromise!: Promise<boolean>;
147
+
148
+ act(() => {
149
+ openPromise = result.current.openTaskById(mockTask.id, {
150
+ initialTask: { ...mockTask, name: 'Visible snapshot' },
151
+ boardId: 'board-1',
152
+ availableLists: [mockList],
153
+ taskWsId: 'workspace-1',
154
+ initialSharedContext,
155
+ });
156
+ });
157
+
158
+ expect(result.current.state).toMatchObject({
159
+ isOpen: true,
160
+ isHydratingTask: true,
161
+ taskLoadError: false,
162
+ boardId: 'board-1',
163
+ realtimeEnabled: false,
164
+ initialSharedContext,
165
+ task: {
166
+ id: mockTask.id,
167
+ name: 'Visible snapshot',
168
+ },
169
+ });
170
+
171
+ await act(async () => {
172
+ deferred.resolve({
173
+ task: {
174
+ ...mockTask,
175
+ name: 'Hydrated task',
176
+ list: { board_id: 'board-1' },
177
+ },
178
+ availableLists: [mockList],
179
+ taskWsId: 'workspace-1',
180
+ taskWorkspacePersonal: false,
181
+ taskWorkspaceTier: 'PRO',
182
+ });
183
+ await openPromise;
184
+ });
185
+
186
+ expect(mockGetTaskDialogHydration).toHaveBeenCalledWith(
187
+ mockTask.id,
188
+ {
189
+ taskWsId: 'workspace-1',
190
+ taskWorkspacePersonal: undefined,
191
+ taskWorkspaceTier: undefined,
192
+ },
193
+ expect.objectContaining({
194
+ fetch: expect.any(Function),
195
+ })
196
+ );
197
+ expect(result.current.state).toMatchObject({
198
+ isOpen: true,
199
+ isHydratingTask: false,
200
+ taskLoadError: false,
201
+ collaborationMode: true,
202
+ realtimeEnabled: true,
203
+ taskHydrationVersion: 1,
204
+ taskWorkspaceTier: 'PRO',
205
+ task: {
206
+ id: mockTask.id,
207
+ name: 'Hydrated task',
208
+ },
209
+ });
210
+ expect(result.current.state.initialSharedContext).toBeUndefined();
211
+ });
212
+
213
+ it('keeps the dialog open in a non-editable error state when hydration fails', async () => {
214
+ const initialSharedContext = {
215
+ boardConfig: {
216
+ id: 'board-1',
217
+ name: 'Visible board',
218
+ ws_id: 'workspace-1',
219
+ ticket_prefix: 'VIS',
220
+ },
221
+ availableLists: [mockList],
222
+ workspaceLabels: [],
223
+ workspaceMembers: [],
224
+ workspaceProjects: [],
225
+ };
226
+ const deferred = createDeferred<never>();
227
+ mockGetTaskDialogHydration.mockReturnValueOnce(deferred.promise);
228
+
229
+ const { result } = renderHook(() => useTaskDialogContext(), { wrapper });
230
+ let openPromise!: Promise<boolean>;
231
+
232
+ act(() => {
233
+ openPromise = result.current.openTaskById(mockTask.id, {
234
+ initialTask: { ...mockTask, name: 'Visible snapshot' },
235
+ boardId: 'board-1',
236
+ initialSharedContext,
237
+ });
238
+ });
239
+
240
+ expect(result.current.state.isOpen).toBe(true);
241
+ expect(result.current.state.isHydratingTask).toBe(true);
242
+
243
+ await act(async () => {
244
+ deferred.reject(new Error('network failed'));
245
+ await openPromise;
246
+ });
247
+
248
+ expect(result.current.state).toMatchObject({
249
+ isOpen: true,
250
+ isHydratingTask: false,
251
+ taskLoadError: true,
252
+ initialSharedContext,
253
+ task: {
254
+ id: mockTask.id,
255
+ name: 'Visible snapshot',
256
+ },
257
+ });
258
+ });
259
+
260
+ it('ignores stale hydration responses after another task opens', async () => {
261
+ const firstDeferred = createDeferred<{
262
+ task: Task & { list?: { board_id?: string | null } | null };
263
+ availableLists: TaskList[];
264
+ taskWsId: string;
265
+ taskWorkspacePersonal: boolean;
266
+ taskWorkspaceTier: 'PRO';
267
+ }>();
268
+ mockGetTaskDialogHydration.mockReturnValueOnce(firstDeferred.promise);
269
+
270
+ const { result } = renderHook(() => useTaskDialogContext(), { wrapper });
271
+ let firstOpenPromise!: Promise<boolean>;
272
+
273
+ act(() => {
274
+ firstOpenPromise = result.current.openTaskById('task-1', {
275
+ initialTask: { ...mockTask, id: 'task-1', name: 'First snapshot' },
276
+ boardId: 'board-1',
277
+ });
278
+ });
279
+ act(() => {
280
+ result.current.closeDialog();
281
+ });
282
+ act(() => {
283
+ result.current.openTask(
284
+ { ...mockTask, id: 'task-2', name: 'Second task' },
285
+ 'board-1',
286
+ [mockList]
287
+ );
288
+ });
289
+
290
+ expect(result.current.state).toMatchObject({
291
+ isOpen: true,
292
+ task: {
293
+ id: 'task-2',
294
+ name: 'Second task',
295
+ },
296
+ });
297
+
298
+ await act(async () => {
299
+ firstDeferred.resolve({
300
+ task: {
301
+ ...mockTask,
302
+ id: 'task-1',
303
+ name: 'Stale hydrated task',
304
+ list: { board_id: 'board-1' },
305
+ },
306
+ availableLists: [mockList],
307
+ taskWsId: 'workspace-1',
308
+ taskWorkspacePersonal: false,
309
+ taskWorkspaceTier: 'PRO',
310
+ });
311
+ await firstOpenPromise;
312
+ });
313
+
314
+ expect(result.current.state).toMatchObject({
315
+ isOpen: true,
316
+ task: {
317
+ id: 'task-2',
318
+ name: 'Second task',
319
+ },
320
+ });
321
+ });
322
+
111
323
  it('should provide initial dialog state', () => {
112
324
  const { result } = renderHook(() => useTaskDialogContext(), { wrapper });
113
325