@tuturuuu/ui 0.5.0 → 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 (88) hide show
  1. package/CHANGELOG.md +29 -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 +50 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
  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/wallet-filter.tsx +21 -2
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
  28. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  29. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  30. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  31. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  32. package/src/components/ui/finance/wallets/form.tsx +41 -197
  33. package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
  34. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  35. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  36. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  37. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  40. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  41. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
  42. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
  43. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  44. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  45. package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
  46. package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
  47. package/src/components/ui/storefront/accent-button.tsx +33 -0
  48. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  49. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  50. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  51. package/src/components/ui/storefront/image-panel.tsx +40 -0
  52. package/src/components/ui/storefront/index.ts +12 -0
  53. package/src/components/ui/storefront/listing-card.tsx +129 -0
  54. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  55. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  56. package/src/components/ui/storefront/types.ts +99 -0
  57. package/src/components/ui/storefront/utils.ts +90 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  59. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  60. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  61. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  62. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  63. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  64. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  65. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
  66. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  67. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  68. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  69. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  70. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  71. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  72. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  73. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  85. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
  86. package/src/hooks/useBoardRealtime.ts +54 -1
  87. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  88. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -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) => {
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
7
  import { ListView } from './list-view';
8
8
 
9
9
  const openTaskMock = vi.hoisted(() => vi.fn());
10
+ const openTaskByIdMock = vi.hoisted(() => vi.fn());
10
11
 
11
12
  vi.mock('next-intl', () => ({
12
13
  useLocale: () => 'en',
@@ -24,6 +25,7 @@ vi.mock('next/image', () => ({
24
25
  vi.mock('../hooks/useTaskDialog', () => ({
25
26
  useTaskDialog: () => ({
26
27
  openTask: openTaskMock,
28
+ openTaskById: openTaskByIdMock,
27
29
  }),
28
30
  }));
29
31
 
@@ -104,7 +106,7 @@ const tasks: Task[] = [
104
106
  },
105
107
  ];
106
108
 
107
- function renderListView() {
109
+ function renderListView(viewTasks = tasks) {
108
110
  const queryClient = new QueryClient({
109
111
  defaultOptions: {
110
112
  queries: { retry: false },
@@ -116,8 +118,9 @@ function renderListView() {
116
118
  <ListView
117
119
  boardId="board-1"
118
120
  lists={lists}
119
- tasks={tasks}
121
+ tasks={viewTasks}
120
122
  workspaceId="ws-1"
123
+ isPersonalWorkspace={true}
121
124
  />
122
125
  </QueryClientProvider>
123
126
  );
@@ -126,6 +129,7 @@ function renderListView() {
126
129
  describe('ListView task context menu', () => {
127
130
  beforeEach(() => {
128
131
  openTaskMock.mockReset();
132
+ openTaskByIdMock.mockReset();
129
133
  });
130
134
 
131
135
  it('opens the shared task menu from row right-click and the compact menu button', () => {
@@ -147,4 +151,53 @@ describe('ListView task context menu', () => {
147
151
  fireEvent.click(screen.getByTestId('mock-task-menu-trigger-task-1'));
148
152
  expect(screen.getByTestId('mock-task-menu-task-1')).toBeInTheDocument();
149
153
  });
154
+
155
+ it('opens external rows through the hydrating task-by-id path immediately', () => {
156
+ const externalTask: Task = {
157
+ ...tasks[0]!,
158
+ id: 'external-task',
159
+ name: 'External task',
160
+ list_id: 'personal-list',
161
+ personal_board_id: 'board-1',
162
+ is_personal_external: true,
163
+ source_workspace_id: 'source-ws',
164
+ source_board_id: 'source-board',
165
+ source_board_name: 'Source board',
166
+ source_list_id: 'source-list',
167
+ source_list_name: 'Source list',
168
+ } satisfies Task;
169
+
170
+ renderListView([externalTask]);
171
+
172
+ fireEvent.click(screen.getByText('External task'));
173
+
174
+ expect(openTaskByIdMock).toHaveBeenCalledWith(
175
+ 'external-task',
176
+ expect.objectContaining({
177
+ boardId: 'source-board',
178
+ taskWsId: 'source-ws',
179
+ taskWorkspacePersonal: false,
180
+ initialTask: expect.objectContaining({
181
+ id: 'external-task',
182
+ list_id: 'source-list',
183
+ name: 'External task',
184
+ }),
185
+ initialSharedContext: expect.objectContaining({
186
+ boardConfig: expect.objectContaining({
187
+ id: 'source-board',
188
+ name: 'Source board',
189
+ ws_id: 'source-ws',
190
+ }),
191
+ availableLists: [
192
+ expect.objectContaining({
193
+ id: 'source-list',
194
+ name: 'Source list',
195
+ board_id: 'source-board',
196
+ }),
197
+ ],
198
+ }),
199
+ })
200
+ );
201
+ expect(openTaskMock).not.toHaveBeenCalled();
202
+ });
150
203
  });
@@ -61,6 +61,10 @@ import { useLocale, useTranslations } from 'next-intl';
61
61
  import { useTheme } from 'next-themes';
62
62
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
63
63
  import { useBulkOperations } from '../boards/boardId/kanban/bulk/bulk-operations';
64
+ import {
65
+ getTaskCardHydratingOpenOptions,
66
+ isExternalTaskSnapshot,
67
+ } from '../boards/boardId/task-card/task-card-open-options';
64
68
  import { useTaskDialog } from '../hooks/useTaskDialog';
65
69
  import { computeAccessibleLabelStyles } from '../utils/label-colors';
66
70
  import { useBoardBroadcast } from './board-broadcast-context';
@@ -129,7 +133,7 @@ export function ListView({
129
133
  const [openTaskMenu, setOpenTaskMenu] = useState<TaskMenuState | null>(null);
130
134
  const previousWorkspaceIdRef = useRef(workspaceId);
131
135
  const previousBoardIdRef = useRef(boardId);
132
- const { openTask } = useTaskDialog();
136
+ const { openTask, openTaskById } = useTaskDialog();
133
137
 
134
138
  // Infinite scroll
135
139
  const [displayCount, setDisplayCount] = useState(50);
@@ -277,21 +281,24 @@ export function ListView({
277
281
  }
278
282
 
279
283
  function openTaskFromRow(task: Task) {
280
- const targetBoardId = task.source_board_id ?? boardId;
281
- const targetWorkspaceId = task.source_workspace_id ?? workspaceId;
282
-
283
- openTask(
284
- task,
285
- targetBoardId,
286
- task.source_board_id ? undefined : lists,
287
- false,
288
- {
289
- taskWsId: targetWorkspaceId,
290
- taskWorkspacePersonal: task.source_workspace_id
291
- ? false
292
- : isPersonalWorkspace,
293
- }
294
- );
284
+ if (isExternalTaskSnapshot(task)) {
285
+ void openTaskById(
286
+ task.id,
287
+ getTaskCardHydratingOpenOptions({
288
+ task,
289
+ boardId,
290
+ availableLists: lists,
291
+ effectiveWorkspaceId: workspaceId,
292
+ isPersonalWorkspace,
293
+ })
294
+ );
295
+ return;
296
+ }
297
+
298
+ openTask(task, boardId, lists, false, {
299
+ taskWsId: workspaceId,
300
+ taskWorkspacePersonal: isPersonalWorkspace,
301
+ });
295
302
  }
296
303
 
297
304
  // Infinite scroll handler