@tuturuuu/ui 0.7.0 → 0.8.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 (67) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +8 -8
  3. package/src/components/ui/currency-input.test.tsx +43 -0
  4. package/src/components/ui/currency-input.tsx +1 -1
  5. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  6. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  7. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  8. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  9. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  10. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  12. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  13. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  14. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  15. package/src/components/ui/money-input.test.tsx +64 -0
  16. package/src/components/ui/money-input.tsx +63 -0
  17. package/src/components/ui/storefront/cart-summary.tsx +114 -29
  18. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  19. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  20. package/src/components/ui/storefront/image-panel.tsx +6 -0
  21. package/src/components/ui/storefront/index.ts +11 -0
  22. package/src/components/ui/storefront/listing-card.tsx +84 -22
  23. package/src/components/ui/storefront/product-detail.tsx +289 -0
  24. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  25. package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
  26. package/src/components/ui/storefront/storefront-surface.tsx +333 -133
  27. package/src/components/ui/storefront/types.ts +23 -1
  28. package/src/components/ui/storefront/utils.ts +111 -27
  29. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  30. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  31. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  32. package/src/components/ui/text-editor/content-migration.ts +41 -18
  33. package/src/components/ui/text-editor/extensions.ts +1 -1
  34. package/src/components/ui/text-editor/image-extension.ts +40 -18
  35. package/src/components/ui/text-editor/video-extension.ts +11 -2
  36. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  37. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  38. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  40. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  42. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  43. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  44. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  46. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  50. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  51. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  52. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  53. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  54. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  55. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  56. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  57. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  58. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  59. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  60. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  61. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  62. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  63. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  64. package/src/hooks/useBoardRealtime.ts +6 -3
  65. package/src/hooks/useBoardRealtime.types.ts +11 -0
  66. package/src/hooks/useCursorTracking.ts +91 -27
  67. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
- import { render, screen } from '@testing-library/react';
6
+ import { render, screen, waitFor } from '@testing-library/react';
7
7
  import type { ReactNode } from 'react';
8
8
  import { beforeEach, describe, expect, it, vi } from 'vitest';
9
9
  import WorkspaceProjectsClientPage from '../workspace-projects-client-page';
@@ -97,4 +97,73 @@ describe('WorkspaceProjectsClientPage', () => {
97
97
  expect(await screen.findByTestId('boards-view')).toBeInTheDocument();
98
98
  expect(mocks.replace).not.toHaveBeenCalled();
99
99
  });
100
+
101
+ it('does not fetch board data for members without manage_projects', async () => {
102
+ mocks.checkWorkspacePermission.mockResolvedValue({
103
+ hasPermission: false,
104
+ });
105
+
106
+ const queryClient = new QueryClient({
107
+ defaultOptions: {
108
+ queries: { retry: false },
109
+ mutations: { retry: false },
110
+ },
111
+ });
112
+
113
+ render(
114
+ <QueryClientProvider client={queryClient}>
115
+ <WorkspaceProjectsClientPage />
116
+ </QueryClientProvider>
117
+ );
118
+
119
+ await waitFor(() => {
120
+ expect(mocks.replace).toHaveBeenCalledWith('/personal');
121
+ });
122
+ expect(mocks.getWorkspaceBoardsData).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('allows task-board guests to fetch their shared boards', async () => {
126
+ mocks.getWorkspace.mockResolvedValue({
127
+ access_type: 'guest',
128
+ guest_products: ['tasks'],
129
+ id: 'guest-ws',
130
+ personal: false,
131
+ });
132
+ mocks.checkWorkspacePermission.mockResolvedValue({
133
+ hasPermission: false,
134
+ });
135
+ mocks.getWorkspaceBoardsData.mockResolvedValue({
136
+ access_type: 'guest',
137
+ count: 1,
138
+ data: [
139
+ {
140
+ archived_at: null,
141
+ deleted_at: null,
142
+ id: 'board-1',
143
+ name: 'Shared Tasks',
144
+ },
145
+ ],
146
+ });
147
+
148
+ const queryClient = new QueryClient({
149
+ defaultOptions: {
150
+ queries: { retry: false },
151
+ mutations: { retry: false },
152
+ },
153
+ });
154
+
155
+ render(
156
+ <QueryClientProvider client={queryClient}>
157
+ <WorkspaceProjectsClientPage />
158
+ </QueryClientProvider>
159
+ );
160
+
161
+ expect(await screen.findByTestId('boards-view')).toBeInTheDocument();
162
+ expect(mocks.getWorkspaceBoardsData).toHaveBeenCalledWith('guest-ws', {
163
+ page: 1,
164
+ pageSize: 10,
165
+ q: '',
166
+ });
167
+ expect(mocks.replace).not.toHaveBeenCalled();
168
+ });
100
169
  });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { act, render, waitFor } from '@testing-library/react';
6
+ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import type { ListPaginationState } from '../../shared/progressive-loader-context';
9
+ import { BoardColumn } from './board-column';
10
+
11
+ const mocks = vi.hoisted(() => ({
12
+ createTask: vi.fn(),
13
+ loadListPage: vi.fn(),
14
+ pagination: {} as Record<string, ListPaginationState>,
15
+ }));
16
+
17
+ vi.mock('@dnd-kit/sortable', () => ({
18
+ useSortable: () => ({
19
+ attributes: {},
20
+ isDragging: false,
21
+ listeners: {},
22
+ setNodeRef: vi.fn(),
23
+ transform: null,
24
+ transition: null,
25
+ }),
26
+ }));
27
+
28
+ vi.mock('@dnd-kit/utilities', () => ({
29
+ CSS: {
30
+ Transform: {
31
+ toString: () => '',
32
+ },
33
+ },
34
+ }));
35
+
36
+ vi.mock('next-intl', () => ({
37
+ useTranslations: () => (key: string, values?: Record<string, string>) =>
38
+ values?.name ? `${key}:${values.name}` : key,
39
+ }));
40
+
41
+ vi.mock('../../hooks/useTaskDialog', () => ({
42
+ useTaskDialog: () => ({
43
+ createTask: mocks.createTask,
44
+ }),
45
+ }));
46
+
47
+ vi.mock('../../shared/progressive-loader-context', () => ({
48
+ useProgressiveLoader: () => ({
49
+ loadListPage: mocks.loadListPage,
50
+ pagination: mocks.pagination,
51
+ revalidateLoadedLists: vi.fn(),
52
+ }),
53
+ }));
54
+
55
+ vi.mock('./list-actions', () => ({
56
+ ListActions: () => <div data-testid="list-actions" />,
57
+ }));
58
+
59
+ vi.mock('./task-list', () => ({
60
+ VirtualizedTaskList: () => <div data-testid="task-list" />,
61
+ }));
62
+
63
+ const externalColumn: TaskList = {
64
+ archived: false,
65
+ board_id: 'board-1',
66
+ color: 'CYAN',
67
+ created_at: '2026-06-16T00:00:00.000Z',
68
+ creator_id: 'user-1',
69
+ deleted: false,
70
+ id: 'personal-external-staging:board-1',
71
+ is_external_staging: true,
72
+ name: 'External tasks',
73
+ position: 0,
74
+ status: 'active',
75
+ };
76
+
77
+ const loadedExternalState: ListPaginationState = {
78
+ hasMore: true,
79
+ isInitialLoad: false,
80
+ isLoading: false,
81
+ page: 0,
82
+ totalCount: 0,
83
+ };
84
+
85
+ function renderExternalColumn() {
86
+ return (
87
+ <BoardColumn
88
+ boardId="board-1"
89
+ column={externalColumn}
90
+ tasks={[]}
91
+ wsId="personal"
92
+ />
93
+ );
94
+ }
95
+
96
+ describe('BoardColumn external lane retry behavior', () => {
97
+ beforeEach(() => {
98
+ vi.clearAllMocks();
99
+ mocks.pagination = {
100
+ [externalColumn.id]: loadedExternalState,
101
+ };
102
+ mocks.loadListPage.mockRejectedValue(
103
+ new Error('external lane unavailable')
104
+ );
105
+ });
106
+
107
+ it('does not immediately retry the same failed external-options signature', async () => {
108
+ const { rerender } = render(renderExternalColumn());
109
+
110
+ await waitFor(() => {
111
+ expect(mocks.loadListPage).toHaveBeenCalledTimes(1);
112
+ });
113
+
114
+ await act(async () => {
115
+ await Promise.resolve();
116
+ rerender(renderExternalColumn());
117
+ await Promise.resolve();
118
+ });
119
+
120
+ expect(mocks.loadListPage).toHaveBeenCalledTimes(1);
121
+ expect(mocks.loadListPage).toHaveBeenCalledWith(externalColumn.id, 0, {
122
+ externalIncludeDocuments: false,
123
+ externalIncludeDoneClosed: false,
124
+ externalSortBy: 'created-desc',
125
+ });
126
+ });
127
+ });
@@ -259,9 +259,7 @@ export function BoardColumn({
259
259
  loadedExternalOptionsSignatureRef.current = externalOptionsSignature;
260
260
  const promise = loadListPage(column.id, page, externalLoadOptions);
261
261
 
262
- promise.catch(() => {
263
- loadedExternalOptionsSignatureRef.current = null;
264
- });
262
+ void promise.catch(() => {});
265
263
 
266
264
  return promise;
267
265
  },
@@ -263,6 +263,7 @@ export function applyTaskDropPreviewToCache({
263
263
  }
264
264
 
265
265
  return {
266
+ localMutationAt,
266
267
  previousFullTasks: snapshot.fullTasks,
267
268
  previousTasks: snapshot.tasks,
268
269
  previewSortKey: previewTasks.previewSortKey,
@@ -270,6 +271,18 @@ export function applyTaskDropPreviewToCache({
270
271
  };
271
272
  }
272
273
 
274
+ export function hasTaskLocalMutationAt(
275
+ tasks: Task[] | undefined,
276
+ taskId: string,
277
+ localMutationAt: number
278
+ ) {
279
+ const task = tasks?.find((item) => item.id === taskId) as
280
+ | (Task & { _localMutationAt?: unknown })
281
+ | undefined;
282
+
283
+ return task?._localMutationAt === localMutationAt;
284
+ }
285
+
273
286
  export function mergeTaskIntoBoardTaskCache(
274
287
  currentTasks: Task[] | undefined,
275
288
  nextTask: Task
@@ -1,13 +1,16 @@
1
+ import { QueryClient } from '@tanstack/react-query';
1
2
  import type { Task } from '@tuturuuu/types/primitives/Task';
2
3
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
4
  import { describe, expect, it } from 'vitest';
4
5
  import {
6
+ applyTaskDropPreviewToCache,
5
7
  getProjectedTaskDropOrderFromPreview,
6
8
  getTaskDropEndPreviewFromRects,
7
9
  getTaskDropPositionFromRects,
8
10
  getTaskDropPreviewCacheTasks,
9
11
  getTaskDropPreviewFromRects,
10
12
  getTaskInsertionIndex,
13
+ hasTaskLocalMutationAt,
11
14
  insertTaskAtDropPosition,
12
15
  mergePersonalPlacementMutationTask,
13
16
  mergeTaskIntoBoardTaskCache,
@@ -683,4 +686,64 @@ describe('task drag insertion helpers', () => {
683
686
  })
684
687
  );
685
688
  });
689
+
690
+ it('marks optimistic previews so stale drag rollbacks can be skipped', () => {
691
+ const queryClient = new QueryClient();
692
+ const boardId = 'board-1';
693
+ const activeTask = createTask({
694
+ id: 'task-1',
695
+ list_id: 'source-list',
696
+ sort_key: 1_000_000,
697
+ });
698
+ const targetTask = createTask({
699
+ id: 'task-2',
700
+ list_id: 'target-list',
701
+ sort_key: 2_000_000,
702
+ });
703
+ const snapshot = {
704
+ fullTasks: [activeTask, targetTask],
705
+ tasks: [activeTask, targetTask],
706
+ };
707
+
708
+ queryClient.setQueryData(['tasks', boardId], snapshot.tasks);
709
+ queryClient.setQueryData(['tasks-full', boardId], snapshot.fullTasks);
710
+
711
+ const preview = applyTaskDropPreviewToCache({
712
+ activeTask,
713
+ boardId,
714
+ orderedTasks: [targetTask, activeTask],
715
+ queryClient,
716
+ snapshot,
717
+ targetList: createList({ id: 'target-list' }),
718
+ targetListId: 'target-list',
719
+ });
720
+
721
+ expect(preview?.localMutationAt).toEqual(expect.any(Number));
722
+ expect(
723
+ hasTaskLocalMutationAt(
724
+ queryClient.getQueryData<Task[]>(['tasks', boardId]),
725
+ activeTask.id,
726
+ preview?.localMutationAt ?? -1
727
+ )
728
+ ).toBe(true);
729
+
730
+ queryClient.setQueryData<Task[]>(['tasks', boardId], (currentTasks) =>
731
+ currentTasks?.map((task) =>
732
+ task.id === activeTask.id
733
+ ? ({
734
+ ...task,
735
+ _localMutationAt: (preview?.localMutationAt ?? 0) + 1,
736
+ } as Task & { _localMutationAt: number })
737
+ : task
738
+ )
739
+ );
740
+
741
+ expect(
742
+ hasTaskLocalMutationAt(
743
+ queryClient.getQueryData<Task[]>(['tasks', boardId]),
744
+ activeTask.id,
745
+ preview?.localMutationAt ?? -1
746
+ )
747
+ ).toBe(false);
748
+ });
686
749
  });
@@ -28,6 +28,7 @@ import { getColumnReorderUpdates } from './column-reorder';
28
28
  import { calculateSortKeyWithRetry as createCalculateSortKeyWithRetry } from './kanban-sort-helpers';
29
29
  import {
30
30
  applyTaskDropPreviewToCache,
31
+ hasTaskLocalMutationAt,
31
32
  mergePersonalPlacementMutationTask,
32
33
  setBoardTaskCache,
33
34
  } from './task-drag-cache';
@@ -65,6 +66,7 @@ import type {
65
66
  export {
66
67
  applyTaskDropPreviewToCache,
67
68
  getTaskDropPreviewCacheTasks,
69
+ hasTaskLocalMutationAt,
68
70
  mergePersonalPlacementMutationTask,
69
71
  mergeTaskIntoBoardTaskCache,
70
72
  } from './task-drag-cache';
@@ -504,6 +506,16 @@ export function useKanbanDnd({
504
506
  queryClient.setQueryData<Task[]>(queryKey, (currentTasks) => {
505
507
  if (!currentTasks) return previousCache;
506
508
 
509
+ if (
510
+ !hasTaskLocalMutationAt(
511
+ currentTasks,
512
+ task.id,
513
+ nextTask._localMutationAt
514
+ )
515
+ ) {
516
+ return currentTasks;
517
+ }
518
+
507
519
  if (!previousTaskValue) {
508
520
  return currentTasks.filter((item) => item.id !== task.id);
509
521
  }
@@ -1079,17 +1091,43 @@ export function useKanbanDnd({
1079
1091
  if (!boardId || !optimisticDropPreview) return;
1080
1092
 
1081
1093
  if (optimisticDropPreview.previousTasks) {
1082
- queryClient.setQueryData(
1083
- ['tasks', boardId],
1084
- optimisticDropPreview.previousTasks
1085
- );
1094
+ const currentTasks = queryClient.getQueryData<Task[]>([
1095
+ 'tasks',
1096
+ boardId,
1097
+ ]);
1098
+
1099
+ if (
1100
+ hasTaskLocalMutationAt(
1101
+ currentTasks,
1102
+ activeTaskForDrop.id,
1103
+ optimisticDropPreview.localMutationAt
1104
+ )
1105
+ ) {
1106
+ queryClient.setQueryData(
1107
+ ['tasks', boardId],
1108
+ optimisticDropPreview.previousTasks
1109
+ );
1110
+ }
1086
1111
  }
1087
1112
 
1088
1113
  if (optimisticDropPreview.previousFullTasks) {
1089
- queryClient.setQueryData(
1090
- ['tasks-full', boardId],
1091
- optimisticDropPreview.previousFullTasks
1092
- );
1114
+ const currentFullTasks = queryClient.getQueryData<Task[]>([
1115
+ 'tasks-full',
1116
+ boardId,
1117
+ ]);
1118
+
1119
+ if (
1120
+ hasTaskLocalMutationAt(
1121
+ currentFullTasks,
1122
+ activeTaskForDrop.id,
1123
+ optimisticDropPreview.localMutationAt
1124
+ )
1125
+ ) {
1126
+ queryClient.setQueryData(
1127
+ ['tasks-full', boardId],
1128
+ optimisticDropPreview.previousFullTasks
1129
+ );
1130
+ }
1093
1131
  }
1094
1132
  };
1095
1133
 
@@ -7,7 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
7
  import { DEFAULT_KANBAN_COLUMN_WIDTH } from './kanban-column-width';
8
8
  import { KanbanColumns } from './kanban-columns';
9
9
 
10
- const { taskCardMock } = vi.hoisted(() => ({
10
+ const { cursorOverlayMock, taskCardMock } = vi.hoisted(() => ({
11
+ cursorOverlayMock: vi.fn(),
11
12
  taskCardMock: vi.fn(),
12
13
  }));
13
14
 
@@ -41,7 +42,10 @@ vi.mock('../../task', () => ({
41
42
  }));
42
43
 
43
44
  vi.mock('../../../../shared/cursor-overlay-multi-wrapper', () => ({
44
- default: () => <div data-testid="cursor-overlay" />,
45
+ default: (props: Record<string, unknown>) => {
46
+ cursorOverlayMock(props);
47
+ return <div data-testid="cursor-overlay" />;
48
+ },
45
49
  }));
46
50
 
47
51
  const lists: TaskList[] = [
@@ -112,6 +116,7 @@ function task(overrides: Partial<Task>): Task {
112
116
 
113
117
  describe('KanbanColumns', () => {
114
118
  beforeEach(() => {
119
+ cursorOverlayMock.mockClear();
115
120
  taskCardMock.mockClear();
116
121
  });
117
122
 
@@ -263,6 +268,12 @@ describe('KanbanColumns', () => {
263
268
  );
264
269
 
265
270
  expect(screen.getByTestId('cursor-overlay')).toBeInTheDocument();
271
+ expect(cursorOverlayMock).toHaveBeenCalledWith(
272
+ expect.objectContaining({
273
+ channelName: 'board-realtime-board-1',
274
+ cursorScope: { boardId: 'board-1', type: 'board' },
275
+ })
276
+ );
266
277
  });
267
278
 
268
279
  it('renders populated deadline panels before the regular kanban columns', () => {
@@ -6,6 +6,7 @@ import {
6
6
  } from '@dnd-kit/sortable';
7
7
  import type { Task } from '@tuturuuu/types/primitives/Task';
8
8
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
9
+ import { getBoardRealtimeChannelName } from '@tuturuuu/ui/hooks/useBoardRealtime.types';
9
10
  import type { ListStatusFilter } from '../../../../shared/board-header';
10
11
  import CursorOverlayMultiWrapper from '../../../../shared/cursor-overlay-multi-wrapper';
11
12
  import { BoardColumn } from '../../board-column';
@@ -219,8 +220,9 @@ export function KanbanColumns({
219
220
  {/* Overlay for collaborator cursors (gated on tier — free workspaces don't get board cursors) */}
220
221
  {!isPersonalWorkspace && boardId && cursorsEnabled && (
221
222
  <CursorOverlayMultiWrapper
222
- channelName={`board-cursor-${boardId}`}
223
+ channelName={getBoardRealtimeChannelName(boardId)}
223
224
  containerRef={boardRef}
225
+ cursorScope={{ boardId, type: 'board' }}
224
226
  listStatusFilter={listStatusFilter}
225
227
  filters={filters}
226
228
  />
@@ -0,0 +1,164 @@
1
+ import type { ReactElement } from 'react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => {
5
+ class MockInternalApiError extends Error {
6
+ constructor(
7
+ message: string,
8
+ public readonly status: number,
9
+ public readonly code?: string
10
+ ) {
11
+ super(message);
12
+ this.name = 'InternalApiError';
13
+ }
14
+ }
15
+
16
+ return {
17
+ BoardClient: vi.fn(),
18
+ getCurrentUser: vi.fn(),
19
+ getWorkspace: vi.fn(),
20
+ getWorkspaceTaskBoard: vi.fn(),
21
+ headers: vi.fn(),
22
+ InternalApiError: MockInternalApiError,
23
+ notFound: vi.fn(() => {
24
+ throw new Error('NEXT_NOT_FOUND');
25
+ }),
26
+ redirect: vi.fn((url: string) => {
27
+ throw new Error(`NEXT_REDIRECT:${url}`);
28
+ }),
29
+ withForwardedInternalApiAuth: vi.fn(() => ({ auth: 'forwarded' })),
30
+ };
31
+ });
32
+
33
+ vi.mock('@tuturuuu/ui/tu-do/shared/board-client', () => ({
34
+ BoardClient: mocks.BoardClient,
35
+ }));
36
+
37
+ vi.mock('@tuturuuu/internal-api', () => ({
38
+ getWorkspaceTaskBoard: (
39
+ ...args: Parameters<typeof mocks.getWorkspaceTaskBoard>
40
+ ) => mocks.getWorkspaceTaskBoard(...args),
41
+ InternalApiError: mocks.InternalApiError,
42
+ withForwardedInternalApiAuth: (
43
+ ...args: Parameters<typeof mocks.withForwardedInternalApiAuth>
44
+ ) => mocks.withForwardedInternalApiAuth(...args),
45
+ }));
46
+
47
+ vi.mock('@tuturuuu/utils/user-helper', () => ({
48
+ getCurrentUser: (...args: Parameters<typeof mocks.getCurrentUser>) =>
49
+ mocks.getCurrentUser(...args),
50
+ }));
51
+
52
+ vi.mock('@tuturuuu/utils/workspace-helper', () => ({
53
+ getWorkspace: (...args: Parameters<typeof mocks.getWorkspace>) =>
54
+ mocks.getWorkspace(...args),
55
+ }));
56
+
57
+ vi.mock('next/headers', () => ({
58
+ headers: (...args: Parameters<typeof mocks.headers>) =>
59
+ mocks.headers(...args),
60
+ }));
61
+
62
+ vi.mock('next/navigation', () => ({
63
+ notFound: (...args: Parameters<typeof mocks.notFound>) =>
64
+ mocks.notFound(...args),
65
+ redirect: (...args: Parameters<typeof mocks.redirect>) =>
66
+ mocks.redirect(...args),
67
+ }));
68
+
69
+ import TaskBoardServerPage from './task-board-server-page';
70
+
71
+ const BOARD_ID = '11111111-1111-1111-1111-111111111111';
72
+
73
+ type BoardClientElement = ReactElement<{
74
+ currentUserId?: string;
75
+ workspace: unknown;
76
+ workspaceTier: unknown;
77
+ }>;
78
+
79
+ function renderServerPage() {
80
+ return TaskBoardServerPage({
81
+ params: Promise.resolve({
82
+ boardId: BOARD_ID,
83
+ wsId: 'ws-1',
84
+ }),
85
+ }) as Promise<BoardClientElement>;
86
+ }
87
+
88
+ describe('TaskBoardServerPage', () => {
89
+ beforeEach(() => {
90
+ vi.clearAllMocks();
91
+ mocks.getCurrentUser.mockResolvedValue({
92
+ email: 'member@example.com',
93
+ id: 'user-1',
94
+ });
95
+ mocks.headers.mockResolvedValue(new Headers({ cookie: 'session=1' }));
96
+ });
97
+
98
+ it('checks board access before fetching workspace metadata', async () => {
99
+ mocks.getWorkspaceTaskBoard.mockRejectedValue(
100
+ new mocks.InternalApiError('Forbidden', 403)
101
+ );
102
+
103
+ await expect(renderServerPage()).rejects.toThrow('NEXT_NOT_FOUND');
104
+
105
+ expect(mocks.withForwardedInternalApiAuth).toHaveBeenCalledWith(
106
+ expect.any(Headers)
107
+ );
108
+ expect(mocks.getWorkspaceTaskBoard).toHaveBeenCalledWith('ws-1', BOARD_ID, {
109
+ auth: 'forwarded',
110
+ });
111
+ expect(mocks.getWorkspace).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('uses a minimal workspace shell for board guests', async () => {
115
+ mocks.getWorkspaceTaskBoard.mockResolvedValue({
116
+ board: {
117
+ access_type: 'guest',
118
+ id: BOARD_ID,
119
+ ws_id: 'ws-guest',
120
+ },
121
+ });
122
+
123
+ const element = await renderServerPage();
124
+
125
+ expect(mocks.getWorkspace).not.toHaveBeenCalled();
126
+ expect(element.type).toBe(mocks.BoardClient);
127
+ expect(element.props.workspace).toEqual({
128
+ id: 'ws-guest',
129
+ joined: false,
130
+ personal: false,
131
+ tier: null,
132
+ });
133
+ expect(element.props.workspaceTier).toBeNull();
134
+ expect(element.props.currentUserId).toBe('user-1');
135
+ });
136
+
137
+ it('loads the full member workspace only after board access succeeds', async () => {
138
+ const workspace = {
139
+ creator_id: 'creator-1',
140
+ id: 'ws-1',
141
+ joined: true,
142
+ name: 'Member Workspace',
143
+ personal: false,
144
+ tier: 'FREE',
145
+ };
146
+ mocks.getWorkspaceTaskBoard.mockResolvedValue({
147
+ board: {
148
+ access_type: 'member',
149
+ id: BOARD_ID,
150
+ ws_id: 'ws-1',
151
+ },
152
+ });
153
+ mocks.getWorkspace.mockResolvedValue(workspace);
154
+
155
+ const element = await renderServerPage();
156
+
157
+ expect(mocks.getWorkspace).toHaveBeenCalledWith('ws-1', {
158
+ useAdmin: true,
159
+ });
160
+ expect(element.type).toBe(mocks.BoardClient);
161
+ expect(element.props.workspace).toBe(workspace);
162
+ expect(element.props.workspaceTier).toBe('FREE');
163
+ });
164
+ });