@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.
@@ -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 { useChatConversations, useChatMessageSearch } from './hooks';
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 = useChatConversations(wsId, archiveFilter);
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 renderWithQueryClient(children: ReactNode) {
106
- const queryClient = new QueryClient({
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('uses the mention workspace when repairing a task from another workspace', async () => {
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: 'source-ws',
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
- 'source-ws',
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: 'source-ws',
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 mentionWorkspaceId = workspaceId?.trim() || undefined;
217
- const resolutionWorkspaceId = mentionWorkspaceId ?? routeWsId;
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: ['task', entityId, resolutionWorkspaceId],
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('applies a matching task upsert to the open dialog state', () => {
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: 'Remote task',
105
- priority: 'high',
106
- start_date: start,
107
- end_date: end,
108
- estimation_points: 5,
109
- list_id: 'list-2',
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
- expect(props.setName).toHaveBeenCalledWith('Remote task');
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