@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.
- package/CHANGELOG.md +48 -0
- package/package.json +8 -8
- package/src/components/ui/currency-input.test.tsx +43 -0
- package/src/components/ui/currency-input.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
- package/src/components/ui/finance/transactions/form-types.ts +2 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
- package/src/components/ui/money-input.test.tsx +64 -0
- package/src/components/ui/money-input.tsx +63 -0
- package/src/components/ui/storefront/cart-summary.tsx +114 -29
- package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
- package/src/components/ui/storefront/hero-panel.tsx +2 -8
- package/src/components/ui/storefront/image-panel.tsx +6 -0
- package/src/components/ui/storefront/index.ts +11 -0
- package/src/components/ui/storefront/listing-card.tsx +84 -22
- package/src/components/ui/storefront/product-detail.tsx +289 -0
- package/src/components/ui/storefront/product-dialog.tsx +72 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
- package/src/components/ui/storefront/storefront-surface.tsx +333 -133
- package/src/components/ui/storefront/types.ts +23 -1
- package/src/components/ui/storefront/utils.ts +111 -27
- package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
- package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
- package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
- package/src/components/ui/text-editor/content-migration.ts +41 -18
- package/src/components/ui/text-editor/extensions.ts +1 -1
- package/src/components/ui/text-editor/image-extension.ts +40 -18
- package/src/components/ui/text-editor/video-extension.ts +11 -2
- package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
- package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
- package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
- package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
- package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
- package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
- package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
- package/src/hooks/useBoardRealtime.ts +6 -3
- package/src/hooks/useBoardRealtime.types.ts +11 -0
- package/src/hooks/useCursorTracking.ts +91 -27
- 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.
|
|
1083
|
-
|
|
1084
|
-
|
|
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.
|
|
1090
|
-
|
|
1091
|
-
|
|
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: () =>
|
|
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={
|
|
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
|
+
});
|