@tuturuuu/ui 0.9.0 → 0.10.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 +29 -0
- package/package.json +6 -5
- package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
- package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
- package/src/components/ui/custom/nav-link.test.tsx +165 -0
- package/src/components/ui/custom/nav-link.tsx +69 -11
- package/src/components/ui/custom/navigation.tsx +1 -0
- package/src/components/ui/custom/settings/task-settings.tsx +104 -0
- package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
- package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
- package/src/components/ui/custom/settings-dialog-search.ts +75 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
- package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
- package/src/components/ui/custom/workspace-select.tsx +17 -16
- package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
- package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
- package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
- package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
- package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
- package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
- package/src/components/ui/tu-do/boards/form.tsx +1 -1
- package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
- package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
- package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
- package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
- package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
- package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +116 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
- package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +21 -0
- package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
- package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +14 -4
- package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
- package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
- package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
- package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
- package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
- package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +9 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
- package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
- package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
- package/src/hooks/useBoardPresence.ts +364 -0
- package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
- package/src/lib/workspace-actions.ts +2 -6
|
@@ -123,6 +123,8 @@ import { TaskShareDialog } from './task-share-dialog';
|
|
|
123
123
|
import type { TaskFilters } from './types';
|
|
124
124
|
import { UnsavedChangesWarningDialog } from './unsaved-changes-warning-dialog';
|
|
125
125
|
|
|
126
|
+
type AssigneeMemberSource = 'workspace' | 'board' | 'workspace-and-board';
|
|
127
|
+
|
|
126
128
|
export {
|
|
127
129
|
type DialogHeaderInfo,
|
|
128
130
|
getTaskDialogHeaderInfo,
|
|
@@ -149,6 +151,8 @@ export interface TaskEditDialogProps {
|
|
|
149
151
|
taskLoadError?: boolean;
|
|
150
152
|
taskHydrationVersion?: number;
|
|
151
153
|
isPersonalWorkspace?: boolean;
|
|
154
|
+
canUseBoardAssignees?: boolean;
|
|
155
|
+
assigneeMemberSource?: AssigneeMemberSource;
|
|
152
156
|
parentTaskId?: string;
|
|
153
157
|
parentTaskName?: string;
|
|
154
158
|
pendingRelationship?: PendingRelationship;
|
|
@@ -189,6 +193,8 @@ export function TaskEditDialog({
|
|
|
189
193
|
taskLoadError = false,
|
|
190
194
|
taskHydrationVersion = 0,
|
|
191
195
|
isPersonalWorkspace = false,
|
|
196
|
+
canUseBoardAssignees,
|
|
197
|
+
assigneeMemberSource,
|
|
192
198
|
parentTaskId,
|
|
193
199
|
parentTaskName,
|
|
194
200
|
pendingRelationship,
|
|
@@ -494,6 +500,8 @@ export function TaskEditDialog({
|
|
|
494
500
|
isOpen,
|
|
495
501
|
propAvailableLists,
|
|
496
502
|
taskSearchQuery,
|
|
503
|
+
canUseBoardAssignees: canUseBoardAssignees ?? !isPersonalWorkspace,
|
|
504
|
+
assigneeMemberSource,
|
|
497
505
|
sharedContext,
|
|
498
506
|
});
|
|
499
507
|
const currentList = availableLists?.find(
|
|
@@ -2166,6 +2174,7 @@ export function TaskEditDialog({
|
|
|
2166
2174
|
selectedAssignees={formState.selectedAssignees}
|
|
2167
2175
|
isLoading={isLoading}
|
|
2168
2176
|
isPersonalWorkspace={isPersonalWorkspace}
|
|
2177
|
+
canUseBoardAssignees={canUseBoardAssignees ?? !isPersonalWorkspace}
|
|
2169
2178
|
totalDuration={formState.totalDuration}
|
|
2170
2179
|
isSplittable={formState.isSplittable}
|
|
2171
2180
|
minSplitDurationMinutes={formState.minSplitDurationMinutes}
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
TaskSchedulingMenu,
|
|
42
42
|
} from '../boards/boardId/menus';
|
|
43
43
|
import { useTaskDialog } from '../hooks/useTaskDialog';
|
|
44
|
+
import type { TaskAssigneeMemberSource } from '../providers/task-dialog-provider';
|
|
44
45
|
import { useTasksHref } from '../tasks-route-context';
|
|
45
46
|
|
|
46
47
|
interface TaskRowActionsMenuProps {
|
|
@@ -49,6 +50,8 @@ interface TaskRowActionsMenuProps {
|
|
|
49
50
|
workspaceId: string;
|
|
50
51
|
lists: TaskList[];
|
|
51
52
|
isPersonalWorkspace?: boolean;
|
|
53
|
+
canUseBoardAssignees?: boolean;
|
|
54
|
+
assigneeMemberSource?: TaskAssigneeMemberSource;
|
|
52
55
|
onUpdate: () => void;
|
|
53
56
|
open?: boolean;
|
|
54
57
|
onOpenChange?: (open: boolean) => void;
|
|
@@ -78,6 +81,8 @@ export function TaskRowActionsMenu({
|
|
|
78
81
|
workspaceId,
|
|
79
82
|
lists,
|
|
80
83
|
isPersonalWorkspace = false,
|
|
84
|
+
canUseBoardAssignees,
|
|
85
|
+
assigneeMemberSource,
|
|
81
86
|
onUpdate,
|
|
82
87
|
open,
|
|
83
88
|
onOpenChange,
|
|
@@ -171,6 +176,12 @@ export function TaskRowActionsMenu({
|
|
|
171
176
|
taskWorkspacePersonal: task.source_workspace_id
|
|
172
177
|
? false
|
|
173
178
|
: isPersonalWorkspace,
|
|
179
|
+
canUseBoardAssignees:
|
|
180
|
+
canUseBoardAssignees ??
|
|
181
|
+
(task.source_workspace_id ? true : !isPersonalWorkspace),
|
|
182
|
+
assigneeMemberSource: task.source_workspace_id
|
|
183
|
+
? 'workspace'
|
|
184
|
+
: assigneeMemberSource,
|
|
174
185
|
}
|
|
175
186
|
);
|
|
176
187
|
setMenuOpen(false);
|
|
@@ -93,6 +93,7 @@ export function useProgressiveBoardLoader(
|
|
|
93
93
|
offset: page * PAGE_SIZE,
|
|
94
94
|
includeCount: true,
|
|
95
95
|
...options,
|
|
96
|
+
includeRelationshipSummary: false,
|
|
96
97
|
});
|
|
97
98
|
const tasks = payload.tasks ?? [];
|
|
98
99
|
const loadedThrough = page * PAGE_SIZE + tasks.length;
|
|
@@ -206,6 +207,7 @@ export function useProgressiveBoardLoader(
|
|
|
206
207
|
offset: page * PAGE_SIZE,
|
|
207
208
|
includeCount: true,
|
|
208
209
|
...listOptionsRef.current[listId],
|
|
210
|
+
includeRelationshipSummary: false,
|
|
209
211
|
})
|
|
210
212
|
)
|
|
211
213
|
);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { act, renderHook } from '@testing-library/react';
|
|
6
|
+
import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
|
|
7
|
+
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { useBoardPresence } from '../useBoardPresence';
|
|
10
|
+
|
|
11
|
+
type PresenceListener = () => void;
|
|
12
|
+
|
|
13
|
+
type MockChannel = {
|
|
14
|
+
on: ReturnType<typeof vi.fn>;
|
|
15
|
+
presenceState: ReturnType<typeof vi.fn>;
|
|
16
|
+
subscribe: ReturnType<typeof vi.fn>;
|
|
17
|
+
track: ReturnType<typeof vi.fn>;
|
|
18
|
+
untrack: ReturnType<typeof vi.fn>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type MockSupabaseClient = {
|
|
22
|
+
auth: {
|
|
23
|
+
getUser: ReturnType<typeof vi.fn>;
|
|
24
|
+
};
|
|
25
|
+
channel: ReturnType<typeof vi.fn>;
|
|
26
|
+
removeChannel: ReturnType<typeof vi.fn>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type MockCreateClientFn = {
|
|
30
|
+
(): MockSupabaseClient;
|
|
31
|
+
mockReturnValue: (value: MockSupabaseClient) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
vi.mock('@tuturuuu/supabase/next/client', () => ({
|
|
35
|
+
createClient: vi.fn(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock('@tuturuuu/internal-api/users', () => ({
|
|
39
|
+
getCurrentUserProfile: vi.fn(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('@tuturuuu/utils/constants', () => ({
|
|
43
|
+
DEV_MODE: false,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
describe('useBoardPresence', () => {
|
|
47
|
+
let mockChannel: MockChannel;
|
|
48
|
+
let mockClient: MockSupabaseClient;
|
|
49
|
+
let presenceListeners: Map<string, PresenceListener>;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
presenceListeners = new Map();
|
|
53
|
+
|
|
54
|
+
mockChannel = {
|
|
55
|
+
on: vi.fn(
|
|
56
|
+
(
|
|
57
|
+
type: string,
|
|
58
|
+
config: { event?: string },
|
|
59
|
+
callback: PresenceListener
|
|
60
|
+
) => {
|
|
61
|
+
if (type === 'presence' && config.event) {
|
|
62
|
+
presenceListeners.set(config.event, callback);
|
|
63
|
+
}
|
|
64
|
+
return mockChannel;
|
|
65
|
+
}
|
|
66
|
+
),
|
|
67
|
+
presenceState: vi.fn(() => ({})),
|
|
68
|
+
subscribe: vi.fn((callback?: (status: string) => void) => {
|
|
69
|
+
callback?.('SUBSCRIBED');
|
|
70
|
+
return mockChannel;
|
|
71
|
+
}),
|
|
72
|
+
track: vi.fn(() => Promise.resolve('ok')),
|
|
73
|
+
untrack: vi.fn(() => Promise.resolve('ok')),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
mockClient = {
|
|
77
|
+
auth: {
|
|
78
|
+
getUser: vi.fn(() =>
|
|
79
|
+
Promise.resolve({
|
|
80
|
+
data: {
|
|
81
|
+
user: {
|
|
82
|
+
email: 'ada.auth@example.com',
|
|
83
|
+
id: 'user-1',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
channel: vi.fn(() => mockChannel),
|
|
90
|
+
removeChannel: vi.fn(() => Promise.resolve()),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
(createClient as unknown as MockCreateClientFn).mockReturnValue(mockClient);
|
|
94
|
+
vi.mocked(getCurrentUserProfile).mockResolvedValue({
|
|
95
|
+
avatar_url: 'https://example.com/ada.png',
|
|
96
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
97
|
+
default_workspace_id: 'user-1',
|
|
98
|
+
display_name: 'Ada Lovelace',
|
|
99
|
+
email: 'ada@example.com',
|
|
100
|
+
full_name: 'Ada Lovelace',
|
|
101
|
+
id: 'user-1',
|
|
102
|
+
new_email: null,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
vi.clearAllMocks();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('tracks sanitized profile data on a private board realtime presence channel', async () => {
|
|
111
|
+
const { result } = renderHook(() => useBoardPresence('board-1'));
|
|
112
|
+
|
|
113
|
+
await act(async () => {
|
|
114
|
+
await (result.current.updateLocation(
|
|
115
|
+
{ boardId: 'board-1', type: 'board' },
|
|
116
|
+
{ listStatusFilter: 'active' }
|
|
117
|
+
) as unknown as Promise<void>);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(mockClient.channel).toHaveBeenCalledWith('board-realtime-board-1', {
|
|
121
|
+
config: {
|
|
122
|
+
presence: {
|
|
123
|
+
enabled: true,
|
|
124
|
+
key: 'user-1',
|
|
125
|
+
},
|
|
126
|
+
private: true,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
expect(mockChannel.track).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
away: false,
|
|
132
|
+
location: { boardId: 'board-1', type: 'board' },
|
|
133
|
+
metadata: { listStatusFilter: 'active' },
|
|
134
|
+
session_id: expect.any(String),
|
|
135
|
+
user: {
|
|
136
|
+
avatar_url: 'https://example.com/ada.png',
|
|
137
|
+
display_name: 'Ada Lovelace',
|
|
138
|
+
email: 'ada@example.com',
|
|
139
|
+
id: 'user-1',
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('does not create a channel while disabled', async () => {
|
|
146
|
+
const { result } = renderHook(() =>
|
|
147
|
+
useBoardPresence('board-1', { enabled: false })
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await act(async () => {
|
|
151
|
+
await (result.current.updateLocation({
|
|
152
|
+
boardId: 'board-1',
|
|
153
|
+
type: 'board',
|
|
154
|
+
}) as unknown as Promise<void>);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(mockClient.channel).not.toHaveBeenCalled();
|
|
158
|
+
expect(mockChannel.track).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('exposes board viewers from presence sync events', async () => {
|
|
162
|
+
const viewerPresence = {
|
|
163
|
+
location: { boardId: 'board-1', type: 'board' as const },
|
|
164
|
+
online_at: '2026-01-01T00:00:00.000Z',
|
|
165
|
+
session_id: 'session-2',
|
|
166
|
+
user: {
|
|
167
|
+
avatar_url: null,
|
|
168
|
+
display_name: 'Guest Reviewer',
|
|
169
|
+
email: 'guest@example.com',
|
|
170
|
+
id: 'user-2',
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
mockChannel.presenceState.mockReturnValue({
|
|
174
|
+
'user-2': [viewerPresence],
|
|
175
|
+
});
|
|
176
|
+
const { result } = renderHook(() => useBoardPresence('board-1'));
|
|
177
|
+
|
|
178
|
+
await act(async () => {
|
|
179
|
+
await (result.current.updateLocation({
|
|
180
|
+
boardId: 'board-1',
|
|
181
|
+
type: 'board',
|
|
182
|
+
}) as unknown as Promise<void>);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
act(() => {
|
|
186
|
+
presenceListeners.get('sync')?.();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(result.current.getBoardViewers('board-1')).toEqual([viewerPresence]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -78,6 +78,7 @@ describe('useBoardRealtime', () => {
|
|
|
78
78
|
subscribe: ReturnType<typeof vi.fn>;
|
|
79
79
|
send: ReturnType<typeof vi.fn>;
|
|
80
80
|
};
|
|
81
|
+
let mockFrom: ReturnType<typeof vi.fn>;
|
|
81
82
|
let mockRemoveChannel: ReturnType<typeof vi.fn>;
|
|
82
83
|
let broadcastListeners: Map<string, BroadcastListener>;
|
|
83
84
|
let subscribeCallback: ((status: string, err?: unknown) => void) | undefined;
|
|
@@ -142,92 +143,14 @@ describe('useBoardRealtime', () => {
|
|
|
142
143
|
};
|
|
143
144
|
|
|
144
145
|
mockRemoveChannel = vi.fn();
|
|
146
|
+
mockFrom = vi.fn();
|
|
145
147
|
|
|
146
148
|
const mockCreateClient = createClient as unknown as MockCreateClientFn;
|
|
147
149
|
|
|
148
150
|
mockCreateClient.mockReturnValue({
|
|
149
151
|
channel: vi.fn(() => mockChannel),
|
|
150
152
|
removeChannel: mockRemoveChannel,
|
|
151
|
-
from:
|
|
152
|
-
select: vi.fn(() => ({
|
|
153
|
-
in: vi.fn(() =>
|
|
154
|
-
Promise.resolve({
|
|
155
|
-
data: [
|
|
156
|
-
{
|
|
157
|
-
id: 'task-1',
|
|
158
|
-
assignees: [
|
|
159
|
-
{
|
|
160
|
-
user: {
|
|
161
|
-
id: 'u-1',
|
|
162
|
-
display_name: 'User 1',
|
|
163
|
-
avatar_url: null,
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
],
|
|
167
|
-
labels: [
|
|
168
|
-
{
|
|
169
|
-
label: {
|
|
170
|
-
id: 'l-1',
|
|
171
|
-
name: 'Bug',
|
|
172
|
-
color: 'red',
|
|
173
|
-
created_at: '2025-01-01',
|
|
174
|
-
},
|
|
175
|
-
},
|
|
176
|
-
],
|
|
177
|
-
projects: [
|
|
178
|
-
{
|
|
179
|
-
project: {
|
|
180
|
-
id: 'p-1',
|
|
181
|
-
name: 'Project 1',
|
|
182
|
-
status: 'active',
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
],
|
|
186
|
-
},
|
|
187
|
-
],
|
|
188
|
-
error: null,
|
|
189
|
-
})
|
|
190
|
-
),
|
|
191
|
-
eq: vi.fn(() => ({
|
|
192
|
-
single: vi.fn(() =>
|
|
193
|
-
Promise.resolve({
|
|
194
|
-
data: {
|
|
195
|
-
id: 'task-1',
|
|
196
|
-
assignees: [
|
|
197
|
-
{
|
|
198
|
-
user: {
|
|
199
|
-
id: 'u-1',
|
|
200
|
-
display_name: 'User 1',
|
|
201
|
-
avatar_url: null,
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
],
|
|
205
|
-
labels: [
|
|
206
|
-
{
|
|
207
|
-
label: {
|
|
208
|
-
id: 'l-1',
|
|
209
|
-
name: 'Bug',
|
|
210
|
-
color: 'red',
|
|
211
|
-
created_at: '2025-01-01',
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
],
|
|
215
|
-
projects: [
|
|
216
|
-
{
|
|
217
|
-
project: {
|
|
218
|
-
id: 'p-1',
|
|
219
|
-
name: 'Project 1',
|
|
220
|
-
status: 'active',
|
|
221
|
-
},
|
|
222
|
-
},
|
|
223
|
-
],
|
|
224
|
-
},
|
|
225
|
-
error: null,
|
|
226
|
-
})
|
|
227
|
-
),
|
|
228
|
-
})),
|
|
229
|
-
})),
|
|
230
|
-
})),
|
|
153
|
+
from: mockFrom,
|
|
231
154
|
});
|
|
232
155
|
|
|
233
156
|
vi.clearAllMocks();
|
|
@@ -835,11 +758,12 @@ describe('useBoardRealtime', () => {
|
|
|
835
758
|
});
|
|
836
759
|
|
|
837
760
|
describe('task:relations-changed event', () => {
|
|
838
|
-
it('
|
|
761
|
+
it('reconciles relation-bearing queries without refetching visible board caches', async () => {
|
|
839
762
|
queryClient.setQueryData(
|
|
840
763
|
['tasks', 'board-1'],
|
|
841
764
|
[{ ...mockTaskWithRelations, id: 'task-1' }]
|
|
842
765
|
);
|
|
766
|
+
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
843
767
|
|
|
844
768
|
renderHook(() => useBoardRealtime('board-1', { enabled: true }), {
|
|
845
769
|
wrapper,
|
|
@@ -847,31 +771,29 @@ describe('useBoardRealtime', () => {
|
|
|
847
771
|
|
|
848
772
|
const listener = broadcastListeners.get('task:relations-changed')!;
|
|
849
773
|
|
|
850
|
-
// Fire the listener — it debounces for 150ms before
|
|
774
|
+
// Fire the listener — it debounces for 150ms before invalidating caches.
|
|
851
775
|
await act(async () => {
|
|
852
776
|
listener({ payload: { taskId: 'task-1' } });
|
|
853
777
|
});
|
|
854
778
|
|
|
855
|
-
// Advance past the 150ms receiver debounce
|
|
779
|
+
// Advance past the 150ms receiver debounce.
|
|
856
780
|
await act(async () => {
|
|
857
781
|
vi.advanceTimersByTime(200);
|
|
858
|
-
// Allow the async fetch to resolve
|
|
859
|
-
await Promise.resolve();
|
|
860
|
-
await Promise.resolve();
|
|
861
782
|
});
|
|
862
783
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
'board-1',
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
expect(
|
|
871
|
-
|
|
784
|
+
expect(mockFrom).not.toHaveBeenCalled();
|
|
785
|
+
expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
|
|
786
|
+
queryKey: ['tasks', 'board-1'],
|
|
787
|
+
});
|
|
788
|
+
expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
|
|
789
|
+
queryKey: ['tasks-full', 'board-1'],
|
|
790
|
+
});
|
|
791
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
|
792
|
+
queryKey: ['task-list-counts', 'board-1'],
|
|
793
|
+
});
|
|
872
794
|
});
|
|
873
795
|
|
|
874
|
-
it('
|
|
796
|
+
it('keeps the full task cache warm when relations change', async () => {
|
|
875
797
|
queryClient.setQueryData(
|
|
876
798
|
['tasks', 'board-1'],
|
|
877
799
|
[{ ...mockTaskWithRelations, id: 'task-1' }]
|
|
@@ -880,6 +802,7 @@ describe('useBoardRealtime', () => {
|
|
|
880
802
|
['tasks-full', 'board-1'],
|
|
881
803
|
[{ ...mockTaskWithRelations, id: 'task-1' }]
|
|
882
804
|
);
|
|
805
|
+
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
883
806
|
|
|
884
807
|
renderHook(() => useBoardRealtime('board-1', { enabled: true }), {
|
|
885
808
|
wrapper,
|
|
@@ -893,57 +816,14 @@ describe('useBoardRealtime', () => {
|
|
|
893
816
|
|
|
894
817
|
await act(async () => {
|
|
895
818
|
vi.advanceTimersByTime(200);
|
|
896
|
-
await Promise.resolve();
|
|
897
|
-
await Promise.resolve();
|
|
898
819
|
});
|
|
899
820
|
|
|
900
|
-
|
|
901
|
-
'tasks-full',
|
|
902
|
-
'board-1',
|
|
903
|
-
])?.[0];
|
|
904
|
-
expect(task?.assignees).toHaveLength(1);
|
|
905
|
-
expect(task?.labels).toHaveLength(1);
|
|
906
|
-
expect(task?.projects).toHaveLength(1);
|
|
907
|
-
});
|
|
908
|
-
|
|
909
|
-
it('should handle DB fetch errors gracefully', async () => {
|
|
910
|
-
queryClient.setQueryData(['tasks', 'board-1'], [mockTaskWithRelations]);
|
|
911
|
-
|
|
912
|
-
// Mock a batched fetch error from the .in(...) branch.
|
|
913
|
-
(createClient as unknown as MockCreateClientFn).mockReturnValue({
|
|
914
|
-
channel: vi.fn(() => mockChannel),
|
|
915
|
-
removeChannel: mockRemoveChannel,
|
|
916
|
-
from: vi.fn(() => ({
|
|
917
|
-
select: vi.fn(() => ({
|
|
918
|
-
in: vi.fn(() =>
|
|
919
|
-
Promise.resolve({ data: null, error: { message: 'Not found' } })
|
|
920
|
-
),
|
|
921
|
-
})),
|
|
922
|
-
})),
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
renderHook(() => useBoardRealtime('board-1', { enabled: true }), {
|
|
926
|
-
wrapper,
|
|
821
|
+
expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
|
|
822
|
+
queryKey: ['tasks-full', 'board-1'],
|
|
927
823
|
});
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
await act(async () => {
|
|
932
|
-
listener({ payload: { taskId: 'task-1' } });
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
await act(async () => {
|
|
936
|
-
vi.advanceTimersByTime(150);
|
|
937
|
-
await Promise.resolve();
|
|
938
|
-
await Promise.resolve();
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
// Cache should remain unchanged
|
|
942
|
-
const cachedTasks = queryClient.getQueryData<Task[]>([
|
|
943
|
-
'tasks',
|
|
944
|
-
'board-1',
|
|
945
|
-
]);
|
|
946
|
-
expect(cachedTasks?.[0]?.assignees).toEqual([]);
|
|
824
|
+
expect(
|
|
825
|
+
queryClient.getQueryData<Task[]>(['tasks-full', 'board-1'])?.[0]?.id
|
|
826
|
+
).toBe('task-1');
|
|
947
827
|
});
|
|
948
828
|
|
|
949
829
|
it('calls onTaskRelationsChange for Supabase relation broadcasts', async () => {
|