@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
@@ -14,6 +14,7 @@ import {
14
14
  useRef,
15
15
  useState,
16
16
  } from 'react';
17
+ import type { SharedTaskContext } from '../shared/task-edit-dialog/hooks/use-task-data';
17
18
  import type {
18
19
  PendingRelationship,
19
20
  PendingRelationshipType,
@@ -58,6 +59,26 @@ interface TaskDialogState {
58
59
  taskWorkspacePersonal?: boolean;
59
60
  /** The task workspace tier used to gate cursor tracking for edit mode */
60
61
  taskWorkspaceTier?: WorkspaceProductTier;
62
+ /** Initial board/list context used for immediate partial-task rendering. */
63
+ initialSharedContext?: SharedTaskContext;
64
+ /** True while an existing task was opened from a partial snapshot and is hydrating. */
65
+ isHydratingTask?: boolean;
66
+ /** True when the latest hydration request failed after the dialog already opened. */
67
+ taskLoadError?: boolean;
68
+ /** Bumps when async task hydration replaces a partial snapshot. */
69
+ taskHydrationVersion?: number;
70
+ taskOpenRequestId?: number;
71
+ }
72
+
73
+ interface OpenTaskByIdOptions {
74
+ initialTask?: Partial<Task>;
75
+ boardId?: string;
76
+ availableLists?: TaskList[];
77
+ fakeTaskUrl?: boolean;
78
+ taskWsId?: string;
79
+ taskWorkspacePersonal?: boolean;
80
+ taskWorkspaceTier?: WorkspaceProductTier;
81
+ initialSharedContext?: SharedTaskContext;
61
82
  }
62
83
 
63
84
  interface TaskDialogContextValue {
@@ -85,7 +106,10 @@ interface TaskDialogContextValue {
85
106
  ) => void;
86
107
 
87
108
  // Open task by ID (fetches task data first)
88
- openTaskById: (taskId: string) => Promise<boolean>;
109
+ openTaskById: (
110
+ taskId: string,
111
+ options?: OpenTaskByIdOptions
112
+ ) => Promise<boolean>;
89
113
 
90
114
  // Open dialog for creating new task
91
115
  createTask: (
@@ -178,9 +202,11 @@ export function TaskDialogProvider({
178
202
  const [state, setState] = useState<TaskDialogState>({
179
203
  isOpen: false,
180
204
  });
205
+ const stateRef = useRef(state);
181
206
  const isDialogOpenRef = useRef(state.isOpen);
182
207
  const queuedDialogStatesRef = useRef<TaskDialogState[]>([]);
183
208
  const queuedOpenTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
209
+ const taskOpenRequestIdRef = useRef(0);
184
210
  const closeRequestHandlerRef = useRef<
185
211
  (() => boolean | undefined | Promise<boolean | undefined>) | null
186
212
  >(null);
@@ -283,7 +309,52 @@ export function TaskDialogProvider({
283
309
  [closeDialog]
284
310
  );
285
311
 
312
+ const replaceHydratingDialogState = useCallback(
313
+ (requestId: number, nextDialogState: TaskDialogState) => {
314
+ queuedDialogStatesRef.current = queuedDialogStatesRef.current.map(
315
+ (queuedDialogState) =>
316
+ queuedDialogState.taskOpenRequestId === requestId
317
+ ? nextDialogState
318
+ : queuedDialogState
319
+ );
320
+
321
+ setState((currentState) =>
322
+ currentState.taskOpenRequestId === requestId
323
+ ? nextDialogState
324
+ : currentState
325
+ );
326
+ },
327
+ []
328
+ );
329
+
330
+ const markHydratingDialogFailed = useCallback((requestId: number) => {
331
+ queuedDialogStatesRef.current = queuedDialogStatesRef.current.map(
332
+ (queuedDialogState) =>
333
+ queuedDialogState.taskOpenRequestId === requestId
334
+ ? {
335
+ ...queuedDialogState,
336
+ isHydratingTask: false,
337
+ taskLoadError: true,
338
+ taskHydrationVersion:
339
+ (queuedDialogState.taskHydrationVersion ?? 0) + 1,
340
+ }
341
+ : queuedDialogState
342
+ );
343
+
344
+ setState((currentState) =>
345
+ currentState.taskOpenRequestId === requestId
346
+ ? {
347
+ ...currentState,
348
+ isHydratingTask: false,
349
+ taskLoadError: true,
350
+ taskHydrationVersion: (currentState.taskHydrationVersion ?? 0) + 1,
351
+ }
352
+ : currentState
353
+ );
354
+ }, []);
355
+
286
356
  useEffect(() => {
357
+ stateRef.current = state;
287
358
  isDialogOpenRef.current = state.isOpen;
288
359
 
289
360
  if (state.isOpen) {
@@ -292,7 +363,7 @@ export function TaskDialogProvider({
292
363
 
293
364
  closeRequestInFlightRef.current = false;
294
365
  flushQueuedDialogState();
295
- }, [flushQueuedDialogState, state.isOpen]);
366
+ }, [flushQueuedDialogState, state]);
296
367
 
297
368
  useEffect(() => {
298
369
  return () => {
@@ -344,48 +415,108 @@ export function TaskDialogProvider({
344
415
  );
345
416
 
346
417
  const openTaskById = useCallback(
347
- async (taskId: string) => {
348
- try {
349
- let response:
350
- | {
351
- task: Task & {
352
- list?: {
353
- board_id?: string | null;
354
- } | null;
355
- };
356
- availableLists: TaskList[];
357
- taskWsId: string;
358
- taskWorkspacePersonal: boolean;
359
- taskWorkspaceTier: WorkspaceProductTier;
360
- }
361
- | undefined;
418
+ async (taskId: string, options?: OpenTaskByIdOptions) => {
419
+ const requestId = taskOpenRequestIdRef.current + 1;
420
+ taskOpenRequestIdRef.current = requestId;
421
+
422
+ const initialTaskSnapshot = options?.initialTask;
423
+ const now = new Date().toISOString();
424
+ const initialTask = {
425
+ name: '',
426
+ description: '',
427
+ list_id: '',
428
+ display_number: 0,
429
+ created_at: now,
430
+ updated_at: now,
431
+ deleted: false,
432
+ archived: false,
433
+ labels: [],
434
+ assignees: [],
435
+ projects: [],
436
+ ...initialTaskSnapshot,
437
+ id: taskId,
438
+ } as Task;
439
+ const initialTaskWithBoard = initialTaskSnapshot as
440
+ | (Partial<Task> & {
441
+ board_id?: string | null;
442
+ list?: { board_id?: string | null } | null;
443
+ })
444
+ | undefined;
445
+ const initialBoardId =
446
+ options?.boardId ??
447
+ initialTaskWithBoard?.board_id ??
448
+ initialTaskWithBoard?.list?.board_id ??
449
+ undefined;
450
+ const initialTaskWorkspacePersonal =
451
+ options?.taskWorkspacePersonal ?? isPersonalWorkspace;
362
452
 
363
- try {
364
- const { getCurrentUserTask } = await import(
365
- '@tuturuuu/internal-api/tasks'
366
- );
453
+ const initialDialogState: TaskDialogState = {
454
+ isOpen: true,
455
+ task: initialTask,
456
+ boardId: initialBoardId,
457
+ mode: 'edit',
458
+ availableLists: options?.availableLists,
459
+ collaborationMode: false,
460
+ realtimeEnabled: false,
461
+ fakeTaskUrl: options?.fakeTaskUrl,
462
+ taskWsId: options?.taskWsId,
463
+ taskWorkspacePersonal: initialTaskWorkspacePersonal,
464
+ taskWorkspaceTier: options?.taskWorkspaceTier,
465
+ initialSharedContext: options?.initialSharedContext,
466
+ isHydratingTask: true,
467
+ taskLoadError: false,
468
+ taskHydrationVersion: 0,
469
+ taskOpenRequestId: requestId,
470
+ };
471
+ const currentState = stateRef.current;
472
+
473
+ if (
474
+ currentState.isOpen &&
475
+ currentState.task?.id === taskId &&
476
+ currentState.taskLoadError
477
+ ) {
478
+ setState(initialDialogState);
479
+ } else {
480
+ queueDialogState(initialDialogState);
481
+ }
367
482
 
368
- response = await getCurrentUserTask(taskId, {
483
+ try {
484
+ const { getTaskDialogHydration } = await import(
485
+ '@tuturuuu/internal-api/tasks'
486
+ );
487
+
488
+ const response = await getTaskDialogHydration(
489
+ taskId,
490
+ {
491
+ taskWsId: options?.taskWsId,
492
+ taskWorkspacePersonal: options?.taskWorkspacePersonal,
493
+ taskWorkspaceTier: options?.taskWorkspaceTier,
494
+ },
495
+ {
369
496
  fetch: (input, init) =>
370
497
  fetch(new URL(String(input), window.location.origin).toString(), {
371
498
  ...init,
372
499
  cache: 'no-store',
373
500
  }),
374
- });
375
- } catch {
376
- return false;
377
- }
501
+ }
502
+ );
378
503
 
379
504
  if (!response) {
505
+ markHydratingDialogFailed(requestId);
380
506
  return false;
381
507
  }
382
508
 
383
- const transformedTask = response.task;
509
+ const transformedTask = response.task as Task & {
510
+ board_id?: string | null;
511
+ list?: {
512
+ board_id?: string | null;
513
+ } | null;
514
+ };
384
515
  const taskWsId = response.taskWsId;
385
516
  const taskWorkspacePersonal = response.taskWorkspacePersonal;
386
517
  const taskWorkspaceTier = response.taskWorkspaceTier;
387
518
  const isTaskWorkspacePersonal =
388
- taskWorkspacePersonal ?? isPersonalWorkspace;
519
+ taskWorkspacePersonal ?? initialTaskWorkspacePersonal;
389
520
 
390
521
  // Realtime sync (auto-save via Yjs) is always enabled in edit mode.
391
522
  // Cursor presence requires tier check and non-personal workspace.
@@ -394,26 +525,40 @@ export function TaskDialogProvider({
394
525
  taskWorkspaceTier
395
526
  );
396
527
 
397
- // Open the task in edit mode
398
- queueDialogState({
528
+ replaceHydratingDialogState(requestId, {
399
529
  isOpen: true,
400
530
  task: transformedTask as Task,
401
- boardId: transformedTask.list?.board_id ?? undefined,
531
+ boardId:
532
+ transformedTask.board_id ??
533
+ transformedTask.list?.board_id ??
534
+ initialBoardId,
402
535
  mode: 'edit',
403
- availableLists: response.availableLists || undefined,
536
+ availableLists:
537
+ response.availableLists || options?.availableLists || undefined,
404
538
  collaborationMode: shouldEnableCursors,
405
539
  realtimeEnabled: true,
540
+ fakeTaskUrl: options?.fakeTaskUrl,
406
541
  taskWsId,
407
542
  taskWorkspacePersonal: isTaskWorkspacePersonal,
408
543
  taskWorkspaceTier,
544
+ isHydratingTask: false,
545
+ taskLoadError: false,
546
+ taskHydrationVersion: 1,
547
+ taskOpenRequestId: requestId,
409
548
  });
410
549
  return true;
411
- } catch (error) {
412
- console.error('Failed to open task:', error);
550
+ } catch {
551
+ markHydratingDialogFailed(requestId);
413
552
  return false;
414
553
  }
415
554
  },
416
- [canUseTaskCursors, isPersonalWorkspace, queueDialogState]
555
+ [
556
+ canUseTaskCursors,
557
+ isPersonalWorkspace,
558
+ markHydratingDialogFailed,
559
+ queueDialogState,
560
+ replaceHydratingDialogState,
561
+ ]
417
562
  );
418
563
 
419
564
  const createTask = useCallback(
@@ -13,8 +13,12 @@ import { RECENT_SIDEBAR_VISIT_EVENT } from '../recent-sidebar-events';
13
13
  import { TaskDialogManager } from '../task-dialog-manager';
14
14
  import { REQUEST_OPEN_TASK_EVENT } from '../task-open-events';
15
15
 
16
- const { mockSearchParams } = vi.hoisted(() => ({
16
+ const { mockSearchParams, taskDialogRenderStats } = vi.hoisted(() => ({
17
17
  mockSearchParams: new URLSearchParams(),
18
+ taskDialogRenderStats: {
19
+ mounts: 0,
20
+ unmounts: 0,
21
+ },
18
22
  }));
19
23
 
20
24
  // Mock Next.js navigation (no longer needs useRouter/usePathname for URL manipulation)
@@ -34,7 +38,8 @@ vi.mock('next/navigation', () => ({
34
38
 
35
39
  const {
36
40
  mockGetCurrentUserProfile,
37
- mockGetCurrentUserTask,
41
+ mockGetTaskDialogHydration,
42
+ mockGetUserConfig,
38
43
  mockGetWorkspaceTask,
39
44
  mockListWorkspaceLabels,
40
45
  mockListWorkspaceMembers,
@@ -42,7 +47,8 @@ const {
42
47
  mockResolveTaskProjectWorkspaceId,
43
48
  } = vi.hoisted(() => ({
44
49
  mockGetCurrentUserProfile: vi.fn(),
45
- mockGetCurrentUserTask: vi.fn(),
50
+ mockGetTaskDialogHydration: vi.fn(),
51
+ mockGetUserConfig: vi.fn(),
46
52
  mockGetWorkspaceTask: vi.fn(),
47
53
  mockListWorkspaceLabels: vi.fn(),
48
54
  mockListWorkspaceMembers: vi.fn(),
@@ -55,13 +61,17 @@ vi.mock('@tuturuuu/internal-api', () => ({
55
61
  }));
56
62
 
57
63
  vi.mock('@tuturuuu/internal-api/tasks', () => ({
58
- getCurrentUserTask: mockGetCurrentUserTask,
64
+ getTaskDialogHydration: mockGetTaskDialogHydration,
59
65
  getWorkspaceTask: mockGetWorkspaceTask,
60
66
  listWorkspaceLabels: mockListWorkspaceLabels,
61
67
  listWorkspaceTaskProjectsByIds: mockListWorkspaceTaskProjectsByIds,
62
68
  resolveTaskProjectWorkspaceId: mockResolveTaskProjectWorkspaceId,
63
69
  }));
64
70
 
71
+ vi.mock('@tuturuuu/internal-api/users', () => ({
72
+ getUserConfig: mockGetUserConfig,
73
+ }));
74
+
65
75
  vi.mock('@tuturuuu/internal-api/workspaces', () => ({
66
76
  listWorkspaceMembers: mockListWorkspaceMembers,
67
77
  }));
@@ -156,30 +166,52 @@ vi.mock('@tuturuuu/supabase/next/client', () => ({
156
166
  // Mock the TaskEditDialog component since it's lazy-loaded
157
167
  vi.mock('../task-edit-dialog', () => ({
158
168
  TaskEditDialog: ({
169
+ defaultPresentation,
159
170
  isOpen,
171
+ isHydratingTask,
172
+ taskLoadError,
160
173
  task,
161
174
  onClose,
162
175
  onNavigateToTask,
163
176
  }: {
177
+ defaultPresentation?: string;
164
178
  isOpen: boolean;
179
+ isHydratingTask?: boolean;
180
+ taskLoadError?: boolean;
165
181
  task?: Task;
166
182
  onClose: () => void;
167
183
  onNavigateToTask?: (taskId: string) => Promise<void>;
168
- }) => (
169
- <div data-testid="task-edit-dialog" data-open={isOpen}>
170
- {task && <div data-testid="task-name">{task.name}</div>}
171
- <button
172
- type="button"
173
- onClick={() => void onNavigateToTask?.('task-2')}
174
- data-testid="navigate-button"
184
+ }) => {
185
+ React.useEffect(() => {
186
+ taskDialogRenderStats.mounts += 1;
187
+
188
+ return () => {
189
+ taskDialogRenderStats.unmounts += 1;
190
+ };
191
+ }, []);
192
+
193
+ return (
194
+ <div
195
+ data-testid="task-edit-dialog"
196
+ data-default-presentation={defaultPresentation}
197
+ data-hydrating={String(!!isHydratingTask)}
198
+ data-load-error={String(!!taskLoadError)}
199
+ data-open={isOpen}
175
200
  >
176
- Navigate
177
- </button>
178
- <button type="button" onClick={onClose} data-testid="close-button">
179
- Close
180
- </button>
181
- </div>
182
- ),
201
+ {task && <div data-testid="task-name">{task.name}</div>}
202
+ <button
203
+ type="button"
204
+ onClick={() => void onNavigateToTask?.('task-2')}
205
+ data-testid="navigate-button"
206
+ >
207
+ Navigate
208
+ </button>
209
+ <button type="button" onClick={onClose} data-testid="close-button">
210
+ Close
211
+ </button>
212
+ </div>
213
+ );
214
+ },
183
215
  }));
184
216
 
185
217
  // Mock task data
@@ -218,6 +250,17 @@ function createTestQueryClient() {
218
250
  });
219
251
  }
220
252
 
253
+ function createDeferred<T>() {
254
+ let resolve!: (value: T) => void;
255
+ let reject!: (reason?: unknown) => void;
256
+ const promise = new Promise<T>((promiseResolve, promiseReject) => {
257
+ resolve = promiseResolve;
258
+ reject = promiseReject;
259
+ });
260
+
261
+ return { promise, resolve, reject };
262
+ }
263
+
221
264
  function Wrapper({ children }: { children: React.ReactNode }) {
222
265
  const queryClient = createTestQueryClient();
223
266
  return (
@@ -236,6 +279,8 @@ beforeEach(() => {
236
279
  mockSearchParams.forEach((_, key) => {
237
280
  mockSearchParams.delete(key);
238
281
  });
282
+ taskDialogRenderStats.mounts = 0;
283
+ taskDialogRenderStats.unmounts = 0;
239
284
  pushStateSpy = vi.spyOn(window.history, 'pushState');
240
285
  replaceStateSpy = vi.spyOn(window.history, 'replaceState');
241
286
  mockGetCurrentUserProfile.mockResolvedValue({
@@ -244,7 +289,13 @@ beforeEach(() => {
244
289
  email: 'user@example.com',
245
290
  avatar_url: null,
246
291
  });
247
- mockGetCurrentUserTask.mockResolvedValue({
292
+ mockGetUserConfig.mockImplementation((configId: string) =>
293
+ Promise.resolve({
294
+ value:
295
+ configId === 'TASK_DIALOG_DEFAULT_PRESENTATION' ? 'compact' : 'false',
296
+ })
297
+ );
298
+ mockGetTaskDialogHydration.mockResolvedValue({
248
299
  task: {
249
300
  ...mockTask,
250
301
  list: { board_id: 'board-1' },
@@ -348,6 +399,10 @@ describe('TaskDialogManager', () => {
348
399
  'data-open',
349
400
  'true'
350
401
  );
402
+ expect(getByTestId('task-edit-dialog')).toHaveAttribute(
403
+ 'data-default-presentation',
404
+ 'compact'
405
+ );
351
406
  expect(getByTestId('task-name')).toHaveTextContent('Test Task');
352
407
  });
353
408
  });
@@ -456,9 +511,9 @@ describe('TaskDialogManager', () => {
456
511
  );
457
512
 
458
513
  await waitFor(() => {
459
- expect(mockGetWorkspaceTask).toHaveBeenCalledWith(
460
- 'workspace-1',
514
+ expect(mockGetTaskDialogHydration).toHaveBeenCalledWith(
461
515
  'task-2',
516
+ expect.any(Object),
462
517
  expect.any(Object)
463
518
  );
464
519
  });
@@ -468,7 +523,7 @@ describe('TaskDialogManager', () => {
468
523
  'data-open',
469
524
  'true'
470
525
  );
471
- expect(getByTestId('task-name')).toHaveTextContent('Related Task');
526
+ expect(getByTestId('task-name')).toHaveTextContent('Test Task');
472
527
  });
473
528
  });
474
529
 
@@ -561,6 +616,147 @@ describe('TaskDialogManager', () => {
561
616
  });
562
617
  });
563
618
 
619
+ it('renders a hydrating task dialog immediately for shared task-open events', async () => {
620
+ const deferred = createDeferred<{
621
+ task: Task & { list?: { board_id?: string | null } | null };
622
+ availableLists: TaskList[];
623
+ taskWsId: string;
624
+ taskWorkspacePersonal: boolean;
625
+ taskWorkspaceTier: 'PRO';
626
+ }>();
627
+ mockGetTaskDialogHydration.mockReturnValueOnce(deferred.promise);
628
+
629
+ const { getByTestId } = render(
630
+ <Wrapper>
631
+ <TaskDialogManager wsId="workspace-1" />
632
+ </Wrapper>
633
+ );
634
+
635
+ act(() => {
636
+ window.dispatchEvent(
637
+ new CustomEvent(REQUEST_OPEN_TASK_EVENT, {
638
+ detail: { taskId: 'task-42' },
639
+ })
640
+ );
641
+ });
642
+
643
+ expect(getByTestId('task-edit-dialog')).toHaveAttribute(
644
+ 'data-hydrating',
645
+ 'true'
646
+ );
647
+
648
+ await act(async () => {
649
+ deferred.resolve({
650
+ task: {
651
+ ...mockTask,
652
+ id: 'task-42',
653
+ name: 'Hydrated Event Task',
654
+ list: { board_id: 'board-1' },
655
+ },
656
+ availableLists: [mockList],
657
+ taskWsId: 'workspace-1',
658
+ taskWorkspacePersonal: false,
659
+ taskWorkspaceTier: 'PRO',
660
+ });
661
+ await Promise.resolve();
662
+ });
663
+
664
+ await waitFor(() => {
665
+ expect(getByTestId('task-edit-dialog')).toHaveAttribute(
666
+ 'data-hydrating',
667
+ 'false'
668
+ );
669
+ expect(getByTestId('task-name')).toHaveTextContent('Hydrated Event Task');
670
+ });
671
+ });
672
+
673
+ it('keeps the same dialog mounted when a source-workspace task finishes hydrating', async () => {
674
+ const deferred = createDeferred<{
675
+ task: Task & { list?: { board_id?: string | null } | null };
676
+ availableLists: TaskList[];
677
+ taskWsId: string;
678
+ taskWorkspacePersonal: boolean;
679
+ taskWorkspaceTier: 'PRO';
680
+ }>();
681
+ mockGetTaskDialogHydration.mockReturnValueOnce(deferred.promise);
682
+
683
+ const TestComponent = () => {
684
+ const { openTaskById } = useTaskDialogContext();
685
+
686
+ React.useEffect(() => {
687
+ void openTaskById('external-task-1', {
688
+ initialTask: {
689
+ ...mockTask,
690
+ id: 'external-task-1',
691
+ name: 'External snapshot',
692
+ },
693
+ boardId: 'external-board-1',
694
+ availableLists: [
695
+ {
696
+ ...mockList,
697
+ id: 'external-list-1',
698
+ board_id: 'external-board-1',
699
+ },
700
+ ],
701
+ taskWsId: 'source-workspace-1',
702
+ taskWorkspacePersonal: false,
703
+ });
704
+ }, [openTaskById]);
705
+
706
+ return <TaskDialogManager wsId="personal-workspace-1" />;
707
+ };
708
+
709
+ const { getByTestId } = render(
710
+ <Wrapper>
711
+ <TestComponent />
712
+ </Wrapper>
713
+ );
714
+
715
+ await waitFor(() => {
716
+ expect(getByTestId('task-edit-dialog')).toHaveAttribute(
717
+ 'data-hydrating',
718
+ 'true'
719
+ );
720
+ expect(getByTestId('task-name')).toHaveTextContent('External snapshot');
721
+ });
722
+ expect(taskDialogRenderStats).toMatchObject({
723
+ mounts: 1,
724
+ unmounts: 0,
725
+ });
726
+
727
+ await act(async () => {
728
+ deferred.resolve({
729
+ task: {
730
+ ...mockTask,
731
+ id: 'external-task-1',
732
+ name: 'Hydrated external task',
733
+ list: { board_id: 'external-board-1' },
734
+ },
735
+ availableLists: [
736
+ { ...mockList, id: 'external-list-1', board_id: 'external-board-1' },
737
+ ],
738
+ taskWsId: 'source-workspace-1',
739
+ taskWorkspacePersonal: false,
740
+ taskWorkspaceTier: 'PRO',
741
+ });
742
+ await Promise.resolve();
743
+ });
744
+
745
+ await waitFor(() => {
746
+ expect(getByTestId('task-edit-dialog')).toHaveAttribute(
747
+ 'data-hydrating',
748
+ 'false'
749
+ );
750
+ expect(getByTestId('task-name')).toHaveTextContent(
751
+ 'Hydrated external task'
752
+ );
753
+ });
754
+ expect(taskDialogRenderStats).toMatchObject({
755
+ mounts: 1,
756
+ unmounts: 0,
757
+ });
758
+ });
759
+
564
760
  it('opens a task from the canonical task query parameter', async () => {
565
761
  mockSearchParams.set('task', 'task-42');
566
762
 
@@ -571,9 +767,9 @@ describe('TaskDialogManager', () => {
571
767
  );
572
768
 
573
769
  await waitFor(() => {
574
- expect(mockGetWorkspaceTask).toHaveBeenCalledWith(
575
- 'workspace-1',
770
+ expect(mockGetTaskDialogHydration).toHaveBeenCalledWith(
576
771
  'task-42',
772
+ expect.any(Object),
577
773
  expect.any(Object)
578
774
  );
579
775
  });
@@ -616,7 +812,7 @@ describe('TaskDialogManager', () => {
616
812
  expect(getByTestId('task-name')).toHaveTextContent('Test Task');
617
813
  });
618
814
 
619
- mockGetCurrentUserTask.mockClear();
815
+ mockGetTaskDialogHydration.mockClear();
620
816
  mockGetWorkspaceTask.mockClear();
621
817
 
622
818
  fireEvent.click(getByTestId('navigate-button'));
@@ -625,7 +821,7 @@ describe('TaskDialogManager', () => {
625
821
  expect(getByTestId('task-name')).toHaveTextContent('Cached Related Task');
626
822
  });
627
823
 
628
- expect(mockGetCurrentUserTask).not.toHaveBeenCalled();
824
+ expect(mockGetTaskDialogHydration).not.toHaveBeenCalled();
629
825
  });
630
826
 
631
827
  it('should use replaceState to revert URL when dialog closes', async () => {
@@ -142,9 +142,7 @@ export function BoardClient({
142
142
  // Fetch workspace labels once at the board level
143
143
  const { data: workspaceLabels = [] } = useWorkspaceLabels(boardWorkspaceId);
144
144
 
145
- const { broadcast } = useBoardRealtime(boardId, {
146
- enabled: !workspace.personal,
147
- });
145
+ const { broadcast } = useBoardRealtime(boardId);
148
146
 
149
147
  const refreshActiveBoard = useCallback(
150
148
  (options?: BoardRefreshOptions) => {