@tuturuuu/ui 0.1.0 → 0.2.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 +18 -0
- package/package.json +6 -6
- package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +43 -13
- package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +138 -74
- package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +70 -0
- package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +60 -1
- package/src/components/ui/chat/chat-agent-details-sidebar.tsx +13 -5
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +110 -0
- package/src/components/ui/chat/chat-sidebar-panel.tsx +13 -3
- package/src/components/ui/legacy/meet/planId/page.tsx +10 -4
- package/src/components/ui/text-editor/__tests__/task-mention-chip.test.tsx +203 -6
- package/src/components/ui/text-editor/task-mention-chip.tsx +29 -7
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-realtime-sync.test.tsx +37 -9
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +89 -70
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import type { ChatConversation } from '@tuturuuu/internal-api';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { ChatSidebarPanel } from './chat-sidebar-panel';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
chatSidebarProps: null as Record<string, unknown> | null,
|
|
8
|
+
fetchNextPage: vi.fn(),
|
|
9
|
+
useChatMessageSearch: vi.fn(),
|
|
10
|
+
useInfiniteChatConversations: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('next-intl', () => ({
|
|
14
|
+
useTranslations: () => (key: string) => key,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('next/navigation', () => ({
|
|
18
|
+
usePathname: () => '/personal',
|
|
19
|
+
useSearchParams: () => new URLSearchParams('scope=personal'),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('./chat-sidebar', () => ({
|
|
23
|
+
ChatSidebar: (props: Record<string, unknown>) => {
|
|
24
|
+
mocks.chatSidebarProps = props;
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
onClick={() =>
|
|
28
|
+
(props.onLoadMoreConversations as (() => void) | undefined)?.()
|
|
29
|
+
}
|
|
30
|
+
type="button"
|
|
31
|
+
>
|
|
32
|
+
load more
|
|
33
|
+
</button>
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock('./create-conversation-dialog', () => ({
|
|
39
|
+
CreateConversationDialog: () => null,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('./hooks', () => ({
|
|
43
|
+
flattenChatConversationPages: (
|
|
44
|
+
data?: { pages?: { conversations?: ChatConversation[] }[] } | null
|
|
45
|
+
) => data?.pages?.flatMap((page) => page.conversations ?? []) ?? [],
|
|
46
|
+
useChatMessageSearch: (...args: unknown[]) =>
|
|
47
|
+
mocks.useChatMessageSearch(...args),
|
|
48
|
+
useInfiniteChatConversations: (...args: unknown[]) =>
|
|
49
|
+
mocks.useInfiniteChatConversations(...args),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const conversation: ChatConversation = {
|
|
53
|
+
aiEnabled: false,
|
|
54
|
+
archivedAt: null,
|
|
55
|
+
createdAt: '2026-06-02T00:00:00.000Z',
|
|
56
|
+
createdBy: 'user-1',
|
|
57
|
+
description: null,
|
|
58
|
+
id: 'conversation-1',
|
|
59
|
+
latestMessage: null,
|
|
60
|
+
memberCount: 2,
|
|
61
|
+
members: [],
|
|
62
|
+
metadata: {},
|
|
63
|
+
title: 'Planning',
|
|
64
|
+
type: 'group',
|
|
65
|
+
unreadCount: 0,
|
|
66
|
+
updatedAt: '2026-06-02T00:00:00.000Z',
|
|
67
|
+
wsId: 'personal',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe('ChatSidebarPanel', () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
mocks.chatSidebarProps = null;
|
|
74
|
+
mocks.useChatMessageSearch.mockReturnValue({ data: [] });
|
|
75
|
+
mocks.useInfiniteChatConversations.mockReturnValue({
|
|
76
|
+
data: {
|
|
77
|
+
pages: [{ conversations: [conversation], nextOffset: 40 }],
|
|
78
|
+
},
|
|
79
|
+
fetchNextPage: mocks.fetchNextPage,
|
|
80
|
+
hasNextPage: true,
|
|
81
|
+
isFetchingNextPage: false,
|
|
82
|
+
isLoading: false,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('uses the infinite conversations query and forwards load-more controls', () => {
|
|
87
|
+
render(
|
|
88
|
+
<ChatSidebarPanel
|
|
89
|
+
currentUserId="user-1"
|
|
90
|
+
isCollapsed={false}
|
|
91
|
+
wsId="personal"
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(mocks.useInfiniteChatConversations).toHaveBeenCalledWith({
|
|
96
|
+
archived: 'active',
|
|
97
|
+
wsId: 'personal',
|
|
98
|
+
});
|
|
99
|
+
expect(mocks.chatSidebarProps).toMatchObject({
|
|
100
|
+
conversations: [conversation],
|
|
101
|
+
hasMoreConversations: true,
|
|
102
|
+
isFetchingMoreConversations: false,
|
|
103
|
+
isLoading: false,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
fireEvent.click(screen.getByRole('button', { name: 'load more' }));
|
|
107
|
+
|
|
108
|
+
expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -3,7 +3,11 @@ import { usePathname, useSearchParams } from 'next/navigation';
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { ChatSidebar } from './chat-sidebar';
|
|
5
5
|
import { CreateConversationDialog } from './create-conversation-dialog';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
flattenChatConversationPages,
|
|
8
|
+
useChatMessageSearch,
|
|
9
|
+
useInfiniteChatConversations,
|
|
10
|
+
} from './hooks';
|
|
7
11
|
import {
|
|
8
12
|
CHAT_CONVERSATION_TYPE_FILTERS,
|
|
9
13
|
type ChatConversationArchiveFilter,
|
|
@@ -62,7 +66,10 @@ export function ChatSidebarPanel({
|
|
|
62
66
|
const setCreateOpen = onCreateOpenChange ?? setInternalCreateOpen;
|
|
63
67
|
const archiveFilter = controlledArchiveFilter ?? internalArchiveFilter;
|
|
64
68
|
const selectedTypes = controlledSelectedTypes ?? internalSelectedTypes;
|
|
65
|
-
const conversationsQuery =
|
|
69
|
+
const conversationsQuery = useInfiniteChatConversations({
|
|
70
|
+
archived: archiveFilter,
|
|
71
|
+
wsId,
|
|
72
|
+
});
|
|
66
73
|
const searchQuery = useChatMessageSearch({
|
|
67
74
|
query: searchValue,
|
|
68
75
|
wsId,
|
|
@@ -70,7 +77,7 @@ export function ChatSidebarPanel({
|
|
|
70
77
|
const conversationScope = normalizeChatConversationScope(
|
|
71
78
|
searchParams.get('scope') ?? defaultConversationScope
|
|
72
79
|
);
|
|
73
|
-
const conversations = conversationsQuery.data
|
|
80
|
+
const conversations = flattenChatConversationPages(conversationsQuery.data);
|
|
74
81
|
const scopeConversations = filterChatConversationsByScope(
|
|
75
82
|
conversations,
|
|
76
83
|
conversationScope
|
|
@@ -158,7 +165,10 @@ export function ChatSidebarPanel({
|
|
|
158
165
|
conversations={scopedConversations}
|
|
159
166
|
currentUserId={currentUserId}
|
|
160
167
|
embedded
|
|
168
|
+
hasMoreConversations={conversationsQuery.hasNextPage}
|
|
169
|
+
isFetchingMoreConversations={conversationsQuery.isFetchingNextPage}
|
|
161
170
|
isLoading={conversationsQuery.isLoading}
|
|
171
|
+
onLoadMoreConversations={() => conversationsQuery.fetchNextPage()}
|
|
162
172
|
onSearchChange={setSearchValue}
|
|
163
173
|
onSelectConversation={selectConversation}
|
|
164
174
|
searchResults={scopedSearchResults}
|
|
@@ -17,23 +17,29 @@ interface Props {
|
|
|
17
17
|
params: Promise<{
|
|
18
18
|
planId: string;
|
|
19
19
|
}>;
|
|
20
|
+
actorUserId?: string | null;
|
|
20
21
|
baseUrl: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export default async function MeetTogetherPlanDetailsPage({
|
|
25
|
+
actorUserId,
|
|
24
26
|
params,
|
|
25
27
|
baseUrl,
|
|
26
28
|
}: Props) {
|
|
27
29
|
const { planId } = await params;
|
|
28
30
|
|
|
29
31
|
const platformUser = await getCurrentUser();
|
|
30
|
-
const plan = await getPlan(planId);
|
|
31
|
-
const users: PlanUser[] = await getUsers(planId);
|
|
32
|
-
const polls = await getPollsForPlan(planId);
|
|
33
|
-
const timeblocks = await getTimeBlocks(planId);
|
|
32
|
+
const plan = await getPlan(planId, { actorUserId });
|
|
34
33
|
|
|
35
34
|
if (!plan) return notFound();
|
|
36
35
|
|
|
36
|
+
const canonicalPlanId = plan.id;
|
|
37
|
+
if (!canonicalPlanId) return notFound();
|
|
38
|
+
|
|
39
|
+
const users: PlanUser[] = await getUsers(canonicalPlanId);
|
|
40
|
+
const polls = await getPollsForPlan(canonicalPlanId);
|
|
41
|
+
const timeblocks = await getTimeBlocks(canonicalPlanId);
|
|
42
|
+
|
|
37
43
|
return (
|
|
38
44
|
<div className="flex min-h-screen w-full flex-col items-center">
|
|
39
45
|
<Suspense fallback={null}>
|
|
@@ -102,15 +102,20 @@ vi.mock('../../sonner', () => ({
|
|
|
102
102
|
},
|
|
103
103
|
}));
|
|
104
104
|
|
|
105
|
-
function
|
|
106
|
-
|
|
105
|
+
function createTestQueryClient() {
|
|
106
|
+
return new QueryClient({
|
|
107
107
|
defaultOptions: {
|
|
108
108
|
queries: {
|
|
109
109
|
retry: false,
|
|
110
110
|
},
|
|
111
111
|
},
|
|
112
112
|
});
|
|
113
|
+
}
|
|
113
114
|
|
|
115
|
+
function renderWithQueryClient(
|
|
116
|
+
children: ReactNode,
|
|
117
|
+
queryClient = createTestQueryClient()
|
|
118
|
+
) {
|
|
114
119
|
return render(
|
|
115
120
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
116
121
|
);
|
|
@@ -217,7 +222,112 @@ describe('TaskMentionChip', () => {
|
|
|
217
222
|
expect(screen.getByText('Complete Team Charter V1.0')).toBeInTheDocument();
|
|
218
223
|
});
|
|
219
224
|
|
|
220
|
-
it('
|
|
225
|
+
it('does not reuse stale mention fallback cache across board contexts', async () => {
|
|
226
|
+
const boardOneTask = {
|
|
227
|
+
assignees: [],
|
|
228
|
+
board_id: 'board-1',
|
|
229
|
+
display_number: 3,
|
|
230
|
+
id: 'board-one-task-id',
|
|
231
|
+
labels: [],
|
|
232
|
+
list_id: 'list-1',
|
|
233
|
+
name: 'Board one task',
|
|
234
|
+
projects: [],
|
|
235
|
+
ticket_prefix: null,
|
|
236
|
+
};
|
|
237
|
+
const boardTwoTask = {
|
|
238
|
+
assignees: [],
|
|
239
|
+
board_id: 'board-2',
|
|
240
|
+
display_number: 4,
|
|
241
|
+
id: 'board-two-task-id',
|
|
242
|
+
labels: [],
|
|
243
|
+
list_id: 'list-2',
|
|
244
|
+
name: 'Board two task',
|
|
245
|
+
projects: [],
|
|
246
|
+
ticket_prefix: null,
|
|
247
|
+
};
|
|
248
|
+
const queryClient = createTestQueryClient();
|
|
249
|
+
const firstResolved = vi.fn();
|
|
250
|
+
const secondResolved = vi.fn();
|
|
251
|
+
|
|
252
|
+
mocks.getCurrentUserTask.mockImplementation(async (taskId: string) => {
|
|
253
|
+
if (taskId === 'stale-task-id') {
|
|
254
|
+
throw new Error('Task not found');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const task =
|
|
258
|
+
taskId === boardOneTask.id
|
|
259
|
+
? boardOneTask
|
|
260
|
+
: taskId === boardTwoTask.id
|
|
261
|
+
? boardTwoTask
|
|
262
|
+
: null;
|
|
263
|
+
|
|
264
|
+
if (!task) {
|
|
265
|
+
throw new Error('Task not found');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
availableLists: [],
|
|
270
|
+
task,
|
|
271
|
+
taskWorkspacePersonal: false,
|
|
272
|
+
taskWorkspaceTier: 'FREE',
|
|
273
|
+
taskWsId: 'ws-1',
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
mocks.listWorkspaceTasks.mockImplementation(
|
|
277
|
+
async (_wsId: string, params: { boardId?: string }) => ({
|
|
278
|
+
tasks: params.boardId === 'board-2' ? [boardTwoTask] : [boardOneTask],
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const firstRender = renderWithQueryClient(
|
|
283
|
+
<TaskMentionChip
|
|
284
|
+
entityId="stale-task-id"
|
|
285
|
+
displayNumber="3"
|
|
286
|
+
subtitle="Board one task"
|
|
287
|
+
onResolvedTaskMention={firstResolved}
|
|
288
|
+
/>,
|
|
289
|
+
queryClient
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
await waitFor(() => {
|
|
293
|
+
expect(firstResolved).toHaveBeenCalledWith(
|
|
294
|
+
expect.objectContaining({ entityId: boardOneTask.id })
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
firstRender.unmount();
|
|
299
|
+
window.history.pushState({}, '', '/ws-1/tasks/boards/board-2');
|
|
300
|
+
|
|
301
|
+
renderWithQueryClient(
|
|
302
|
+
<TaskMentionChip
|
|
303
|
+
entityId="stale-task-id"
|
|
304
|
+
displayNumber="4"
|
|
305
|
+
subtitle="Board two task"
|
|
306
|
+
onResolvedTaskMention={secondResolved}
|
|
307
|
+
/>,
|
|
308
|
+
queryClient
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
await waitFor(() => {
|
|
312
|
+
expect(mocks.listWorkspaceTasks).toHaveBeenCalledWith(
|
|
313
|
+
'ws-1',
|
|
314
|
+
expect.objectContaining({
|
|
315
|
+
boardId: 'board-2',
|
|
316
|
+
identifier: '4',
|
|
317
|
+
})
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
await waitFor(() => {
|
|
321
|
+
expect(secondResolved).toHaveBeenCalledWith(
|
|
322
|
+
expect.objectContaining({ entityId: boardTwoTask.id })
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
expect(secondResolved).not.toHaveBeenCalledWith(
|
|
326
|
+
expect.objectContaining({ entityId: boardOneTask.id })
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('ignores untrusted mention workspace when repairing stale task mentions', async () => {
|
|
221
331
|
const resolvedTask = {
|
|
222
332
|
assignees: [],
|
|
223
333
|
board_id: 'board-1',
|
|
@@ -242,7 +352,7 @@ describe('TaskMentionChip', () => {
|
|
|
242
352
|
task: resolvedTask,
|
|
243
353
|
taskWorkspacePersonal: false,
|
|
244
354
|
taskWorkspaceTier: 'FREE',
|
|
245
|
-
taskWsId: '
|
|
355
|
+
taskWsId: 'route-ws',
|
|
246
356
|
};
|
|
247
357
|
});
|
|
248
358
|
mocks.listWorkspaceTasks.mockResolvedValue({ tasks: [resolvedTask] });
|
|
@@ -259,7 +369,7 @@ describe('TaskMentionChip', () => {
|
|
|
259
369
|
|
|
260
370
|
await waitFor(() => {
|
|
261
371
|
expect(mocks.listWorkspaceTasks).toHaveBeenCalledWith(
|
|
262
|
-
'
|
|
372
|
+
'route-ws',
|
|
263
373
|
expect.objectContaining({
|
|
264
374
|
boardId: 'board-1',
|
|
265
375
|
identifier: '7',
|
|
@@ -271,9 +381,96 @@ describe('TaskMentionChip', () => {
|
|
|
271
381
|
expect(onResolvedTaskMention).toHaveBeenCalledWith(
|
|
272
382
|
expect.objectContaining({
|
|
273
383
|
entityId: 'source-task-id',
|
|
274
|
-
workspaceId: '
|
|
384
|
+
workspaceId: 'route-ws',
|
|
275
385
|
})
|
|
276
386
|
);
|
|
277
387
|
});
|
|
278
388
|
});
|
|
389
|
+
|
|
390
|
+
it('does not rewrite stale mentions to tasks from another workspace', async () => {
|
|
391
|
+
const resolvedTask = {
|
|
392
|
+
assignees: [],
|
|
393
|
+
board_id: 'board-1',
|
|
394
|
+
display_number: 8,
|
|
395
|
+
id: 'source-task-id',
|
|
396
|
+
labels: [],
|
|
397
|
+
list_id: 'list-1',
|
|
398
|
+
name: 'Source workspace task',
|
|
399
|
+
projects: [],
|
|
400
|
+
ticket_prefix: null,
|
|
401
|
+
};
|
|
402
|
+
const onResolvedTaskMention = vi.fn();
|
|
403
|
+
|
|
404
|
+
window.history.pushState({}, '', '/route-ws/tasks/boards/board-1');
|
|
405
|
+
mocks.getCurrentUserTask.mockImplementation(async (taskId: string) => {
|
|
406
|
+
if (taskId === 'stale-task-id') {
|
|
407
|
+
throw new Error('Task not found');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
availableLists: [],
|
|
412
|
+
task: resolvedTask,
|
|
413
|
+
taskWorkspacePersonal: false,
|
|
414
|
+
taskWorkspaceTier: 'FREE',
|
|
415
|
+
taskWsId: 'source-ws',
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
mocks.listWorkspaceTasks.mockResolvedValue({ tasks: [resolvedTask] });
|
|
419
|
+
|
|
420
|
+
renderWithQueryClient(
|
|
421
|
+
<TaskMentionChip
|
|
422
|
+
entityId="stale-task-id"
|
|
423
|
+
displayNumber="8"
|
|
424
|
+
subtitle="Source workspace task"
|
|
425
|
+
onResolvedTaskMention={onResolvedTaskMention}
|
|
426
|
+
/>
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
await waitFor(() => {
|
|
430
|
+
expect(mocks.getCurrentUserTask).toHaveBeenCalledWith('source-task-id');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
expect(onResolvedTaskMention).not.toHaveBeenCalled();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('uses the server-returned workspace for resolved cross-workspace task mentions', async () => {
|
|
437
|
+
const resolvedTask = {
|
|
438
|
+
assignees: [],
|
|
439
|
+
board_id: 'source-board',
|
|
440
|
+
display_number: 9,
|
|
441
|
+
id: 'source-task-id',
|
|
442
|
+
labels: [],
|
|
443
|
+
list_id: 'list-1',
|
|
444
|
+
name: 'Trusted cross workspace task',
|
|
445
|
+
projects: [],
|
|
446
|
+
ticket_prefix: null,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
window.history.pushState({}, '', '/route-ws/tasks/boards/board-1');
|
|
450
|
+
mocks.getCurrentUserTask.mockResolvedValue({
|
|
451
|
+
availableLists: [],
|
|
452
|
+
task: resolvedTask,
|
|
453
|
+
taskWorkspacePersonal: false,
|
|
454
|
+
taskWorkspaceTier: 'FREE',
|
|
455
|
+
taskWsId: 'source-ws',
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
renderWithQueryClient(
|
|
459
|
+
<TaskMentionChip
|
|
460
|
+
entityId="source-task-id"
|
|
461
|
+
displayNumber="9"
|
|
462
|
+
subtitle="Trusted cross workspace task"
|
|
463
|
+
workspaceId="source-ws"
|
|
464
|
+
/>
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
await waitFor(() => {
|
|
468
|
+
expect(mocks.getCurrentUserTask).toHaveBeenCalledWith('source-task-id');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(mocks.listWorkspaceTasks).not.toHaveBeenCalled();
|
|
472
|
+
expect(
|
|
473
|
+
screen.getByText('Trusted cross workspace task')
|
|
474
|
+
).toBeInTheDocument();
|
|
475
|
+
});
|
|
279
476
|
});
|
|
@@ -186,7 +186,6 @@ export function TaskMentionChip({
|
|
|
186
186
|
displayNumber,
|
|
187
187
|
avatarUrl,
|
|
188
188
|
subtitle,
|
|
189
|
-
workspaceId,
|
|
190
189
|
className,
|
|
191
190
|
editor: editorProp,
|
|
192
191
|
onResolvedTaskMention,
|
|
@@ -213,8 +212,19 @@ export function TaskMentionChip({
|
|
|
213
212
|
|
|
214
213
|
return getRouteTaskBoardIdFromPathname(window.location.pathname);
|
|
215
214
|
}, []);
|
|
216
|
-
const
|
|
217
|
-
const
|
|
215
|
+
const resolutionWorkspaceId = routeWsId;
|
|
216
|
+
const taskMentionQueryKey = useMemo(
|
|
217
|
+
() => [
|
|
218
|
+
'task',
|
|
219
|
+
'mention',
|
|
220
|
+
entityId,
|
|
221
|
+
resolutionWorkspaceId ?? null,
|
|
222
|
+
routeBoardId ?? null,
|
|
223
|
+
displayNumber.trim(),
|
|
224
|
+
subtitle?.trim() ?? null,
|
|
225
|
+
],
|
|
226
|
+
[displayNumber, entityId, resolutionWorkspaceId, routeBoardId, subtitle]
|
|
227
|
+
);
|
|
218
228
|
const isDark = useDomResolvedTheme() === 'dark';
|
|
219
229
|
|
|
220
230
|
// Dialog states
|
|
@@ -230,7 +240,7 @@ export function TaskMentionChip({
|
|
|
230
240
|
isLoading: taskLoading,
|
|
231
241
|
error: taskError,
|
|
232
242
|
} = useQuery({
|
|
233
|
-
queryKey:
|
|
243
|
+
queryKey: taskMentionQueryKey,
|
|
234
244
|
queryFn: async () => {
|
|
235
245
|
return resolveTaskMentionPayload({
|
|
236
246
|
displayNumber,
|
|
@@ -249,9 +259,19 @@ export function TaskMentionChip({
|
|
|
249
259
|
const resolvedTaskId = task?.id ?? entityId;
|
|
250
260
|
const taskWorkspaceId = taskPayload?.taskWsId;
|
|
251
261
|
const taskWorkspacePersonal = taskPayload?.taskWorkspacePersonal;
|
|
252
|
-
const canonicalWorkspaceId =
|
|
253
|
-
mentionWorkspaceId ?? taskWorkspaceId ?? routeWsId;
|
|
262
|
+
const canonicalWorkspaceId = taskWorkspaceId ?? routeWsId;
|
|
254
263
|
const boardWorkspaceId = canonicalWorkspaceId;
|
|
264
|
+
const resolvedTaskBelongsToRouteWorkspace = useMemo(() => {
|
|
265
|
+
if (!routeWsId || !taskWorkspaceId) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (taskWorkspaceId === routeWsId) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return routeWsId === 'personal' && taskWorkspacePersonal === true;
|
|
274
|
+
}, [routeWsId, taskWorkspaceId, taskWorkspacePersonal]);
|
|
255
275
|
|
|
256
276
|
// Get board config - only fetch when menu opens and we have task data
|
|
257
277
|
const { data: boardConfig } = useBoardConfig(
|
|
@@ -394,6 +414,7 @@ export function TaskMentionChip({
|
|
|
394
414
|
}, [availableLists, task]);
|
|
395
415
|
|
|
396
416
|
const handleUpdate = useCallback(() => {
|
|
417
|
+
queryClient.invalidateQueries({ queryKey: ['task', 'mention'] });
|
|
397
418
|
queryClient.invalidateQueries({ queryKey: ['task', entityId] });
|
|
398
419
|
if (resolvedTaskId !== entityId) {
|
|
399
420
|
queryClient.invalidateQueries({ queryKey: ['task', resolvedTaskId] });
|
|
@@ -668,7 +689,7 @@ export function TaskMentionChip({
|
|
|
668
689
|
}, [task?.name, subtitle]);
|
|
669
690
|
|
|
670
691
|
useEffect(() => {
|
|
671
|
-
if (!task || task.id === entityId) {
|
|
692
|
+
if (!task || task.id === entityId || !resolvedTaskBelongsToRouteWorkspace) {
|
|
672
693
|
return;
|
|
673
694
|
}
|
|
674
695
|
|
|
@@ -697,6 +718,7 @@ export function TaskMentionChip({
|
|
|
697
718
|
entityId,
|
|
698
719
|
onResolvedTaskMention,
|
|
699
720
|
queryClient,
|
|
721
|
+
resolvedTaskBelongsToRouteWorkspace,
|
|
700
722
|
subtitle,
|
|
701
723
|
task,
|
|
702
724
|
taskPayload,
|
|
@@ -90,29 +90,48 @@ describe('useTaskRealtimeSync', () => {
|
|
|
90
90
|
);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
it('
|
|
93
|
+
it('refetches a matching task upsert before applying open dialog state', async () => {
|
|
94
94
|
const start = '2026-05-22T01:00:00.000Z';
|
|
95
95
|
const end = '2026-05-22T02:00:00.000Z';
|
|
96
96
|
const props = makeProps();
|
|
97
97
|
|
|
98
|
+
taskApiMocks.getWorkspaceTask.mockResolvedValueOnce({
|
|
99
|
+
task: {
|
|
100
|
+
id: 'task-1',
|
|
101
|
+
name: 'Server task',
|
|
102
|
+
priority: 'high',
|
|
103
|
+
start_date: start,
|
|
104
|
+
end_date: end,
|
|
105
|
+
estimation_points: 5,
|
|
106
|
+
list_id: 'list-2',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
98
110
|
renderHook(() => useTaskRealtimeSync(props), { wrapper });
|
|
99
111
|
|
|
100
112
|
act(() => {
|
|
101
113
|
boardRealtimeMocks.onTaskChange?.(
|
|
102
114
|
{
|
|
103
115
|
id: 'task-1',
|
|
104
|
-
name: '
|
|
105
|
-
priority: '
|
|
106
|
-
start_date:
|
|
107
|
-
end_date:
|
|
108
|
-
estimation_points:
|
|
109
|
-
list_id: 'list-
|
|
116
|
+
name: 'Untrusted broadcast task',
|
|
117
|
+
priority: 'critical',
|
|
118
|
+
start_date: '2026-05-23T01:00:00.000Z',
|
|
119
|
+
end_date: '2026-05-23T02:00:00.000Z',
|
|
120
|
+
estimation_points: 8,
|
|
121
|
+
list_id: 'list-3',
|
|
110
122
|
} as Task,
|
|
111
123
|
'UPDATE'
|
|
112
124
|
);
|
|
113
125
|
});
|
|
114
126
|
|
|
115
|
-
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(props.setName).toHaveBeenCalledWith('Server task');
|
|
129
|
+
});
|
|
130
|
+
expect(taskApiMocks.getWorkspaceTask).toHaveBeenCalledWith(
|
|
131
|
+
'ws-1',
|
|
132
|
+
'task-1'
|
|
133
|
+
);
|
|
134
|
+
expect(props.setName).not.toHaveBeenCalledWith('Untrusted broadcast task');
|
|
116
135
|
expect(props.setPriority).toHaveBeenCalledWith('high');
|
|
117
136
|
expect(props.setStartDate).toHaveBeenCalledWith(new Date(start));
|
|
118
137
|
expect(props.setEndDate).toHaveBeenCalledWith(new Date(end));
|
|
@@ -137,10 +156,13 @@ describe('useTaskRealtimeSync', () => {
|
|
|
137
156
|
expect(props.setSelectedListId).not.toHaveBeenCalled();
|
|
138
157
|
});
|
|
139
158
|
|
|
140
|
-
it('does not overwrite a local pending title edit', () => {
|
|
159
|
+
it('does not overwrite a local pending title edit', async () => {
|
|
141
160
|
const props = makeProps({
|
|
142
161
|
pendingNameRef: { current: 'Local draft title' },
|
|
143
162
|
});
|
|
163
|
+
taskApiMocks.getWorkspaceTask.mockResolvedValueOnce({
|
|
164
|
+
task: { id: 'task-1', name: 'Remote title' },
|
|
165
|
+
});
|
|
144
166
|
|
|
145
167
|
renderHook(() => useTaskRealtimeSync(props), { wrapper });
|
|
146
168
|
|
|
@@ -151,6 +173,12 @@ describe('useTaskRealtimeSync', () => {
|
|
|
151
173
|
);
|
|
152
174
|
});
|
|
153
175
|
|
|
176
|
+
await waitFor(() => {
|
|
177
|
+
expect(taskApiMocks.getWorkspaceTask).toHaveBeenCalledWith(
|
|
178
|
+
'ws-1',
|
|
179
|
+
'task-1'
|
|
180
|
+
);
|
|
181
|
+
});
|
|
154
182
|
expect(props.setName).not.toHaveBeenCalled();
|
|
155
183
|
});
|
|
156
184
|
|