@tuturuuu/ui 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  12. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  13. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  14. package/src/components/ui/custom/statistic-card.tsx +104 -0
  15. package/src/components/ui/custom/structure.test.tsx +29 -0
  16. package/src/components/ui/custom/structure.tsx +103 -88
  17. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  18. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  23. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  24. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  25. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  26. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  27. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  28. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  29. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  30. package/src/components/ui/finance/invoices/utils.ts +56 -134
  31. package/src/components/ui/resizable.test.ts +15 -0
  32. package/src/components/ui/resizable.tsx +28 -6
  33. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
  36. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  37. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  38. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  39. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  40. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  41. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  45. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  46. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  47. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  51. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  52. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  53. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  54. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  55. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  56. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  57. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  58. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  59. package/src/components/ui/tu-do/projects/types.ts +4 -1
  60. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  61. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  62. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  63. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  64. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  65. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  66. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  69. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  75. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  76. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  77. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  78. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  79. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  80. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  81. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  82. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  83. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  84. package/src/hooks/task-actions-personal-external.ts +12 -6
  85. package/src/hooks/use-notifications.ts +14 -2
  86. package/vitest.config.ts +4 -0
@@ -24,6 +24,7 @@ export interface TaskProjectDetailProps {
24
24
  documents: Task[];
25
25
  lists: TaskList[];
26
26
  currentUserId: string;
27
+ embedded?: boolean;
27
28
  wsId: string;
28
29
  }
29
30
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useRouter } from 'next/navigation';
4
4
  import { useCallback, useMemo, useState } from 'react';
5
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../dialog';
5
6
  import { useTasksHref } from '../tasks-route-context';
6
7
  import {
7
8
  ProjectGridCard,
@@ -16,11 +17,15 @@ import {
16
17
  ManageTasksDialog,
17
18
  } from './dialogs';
18
19
  import { useProjectFilters, useTaskProjects } from './hooks';
20
+ import TaskProjectDetailPageClient from './projectId/task-project-detail-page-client';
19
21
  import type { TaskProject, TaskProjectsClientProps } from './types';
20
22
 
21
23
  export function TaskProjectsClient({
24
+ currentUserId,
25
+ detailMode = 'route',
22
26
  wsId,
23
27
  initialProjects,
28
+ workspace,
24
29
  }: TaskProjectsClientProps) {
25
30
  const router = useRouter();
26
31
  const tasksHref = useTasksHref();
@@ -34,6 +39,9 @@ export function TaskProjectsClient({
34
39
  const [managingProject, setManagingProject] = useState<TaskProject | null>(
35
40
  null
36
41
  );
42
+ const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
43
+ null
44
+ );
37
45
 
38
46
  // Custom hooks
39
47
  const taskProjects = useTaskProjects({
@@ -74,6 +82,11 @@ export function TaskProjectsClient({
74
82
  ),
75
83
  [taskProjects.projects]
76
84
  );
85
+ const selectedProject = selectedProjectId
86
+ ? (taskProjects.projects.find(
87
+ (project) => project.id === selectedProjectId
88
+ ) ?? null)
89
+ : null;
77
90
 
78
91
  // Handlers
79
92
  const handleEditProject = (project: TaskProject) => {
@@ -120,9 +133,14 @@ export function TaskProjectsClient({
120
133
 
121
134
  const navigateToProject = useCallback(
122
135
  (projectId: string) => {
136
+ if (detailMode === 'dialog' && workspace && currentUserId) {
137
+ setSelectedProjectId(projectId);
138
+ return;
139
+ }
140
+
123
141
  router.push(`/${wsId}${tasksHref(`/projects/${projectId}`)}`);
124
142
  },
125
- [router, wsId, tasksHref]
143
+ [currentUserId, detailMode, router, tasksHref, workspace, wsId]
126
144
  );
127
145
 
128
146
  return (
@@ -168,6 +186,7 @@ export function TaskProjectsClient({
168
186
  onDelete={taskProjects.deleteProject}
169
187
  onManageTasks={handleOpenManageTasks}
170
188
  onNavigate={navigateToProject}
189
+ navigationMode={detailMode}
171
190
  isUpdating={taskProjects.isUpdating}
172
191
  isDeleting={taskProjects.isDeleting}
173
192
  isLinking={taskProjects.isLinking}
@@ -186,6 +205,7 @@ export function TaskProjectsClient({
186
205
  onDelete={taskProjects.deleteProject}
187
206
  onManageTasks={handleOpenManageTasks}
188
207
  onNavigate={navigateToProject}
208
+ navigationMode={detailMode}
189
209
  isUpdating={taskProjects.isUpdating}
190
210
  isDeleting={taskProjects.isDeleting}
191
211
  isLinking={taskProjects.isLinking}
@@ -221,6 +241,30 @@ export function TaskProjectsClient({
221
241
  isLinking={taskProjects.isLinking}
222
242
  isUnlinking={taskProjects.isUnlinking}
223
243
  />
244
+
245
+ <Dialog
246
+ open={Boolean(selectedProjectId)}
247
+ onOpenChange={(open) => {
248
+ if (!open) setSelectedProjectId(null);
249
+ }}
250
+ >
251
+ <DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
252
+ <DialogHeader>
253
+ <DialogTitle className="sr-only">
254
+ {selectedProject?.name ?? ''}
255
+ </DialogTitle>
256
+ </DialogHeader>
257
+ {selectedProjectId && workspace && currentUserId && (
258
+ <TaskProjectDetailPageClient
259
+ currentUserId={currentUserId}
260
+ embedded
261
+ projectId={selectedProjectId}
262
+ workspace={workspace}
263
+ wsId={wsId}
264
+ />
265
+ )}
266
+ </DialogContent>
267
+ </Dialog>
224
268
  </>
225
269
  );
226
270
  }
@@ -1,5 +1,5 @@
1
1
  import type { TaskProjectLinkedItem } from '@tuturuuu/internal-api/tasks';
2
- import type { TaskProjectWithRelations } from '@tuturuuu/types';
2
+ import type { TaskProjectWithRelations, Workspace } from '@tuturuuu/types';
3
3
 
4
4
  export type ProjectStatus =
5
5
  | 'backlog'
@@ -30,6 +30,9 @@ export interface TaskProject extends TaskProjectWithRelations {
30
30
  export interface TaskProjectsClientProps {
31
31
  wsId: string;
32
32
  initialProjects: TaskProject[];
33
+ currentUserId?: string;
34
+ detailMode?: 'dialog' | 'route';
35
+ workspace?: Workspace | null;
33
36
  }
34
37
 
35
38
  export interface TaskOption {
@@ -70,6 +70,10 @@ interface TaskDialogState {
70
70
  taskWorkspaceTier?: WorkspaceProductTier;
71
71
  /** Initial board/list context used for immediate partial-task rendering. */
72
72
  initialSharedContext?: SharedTaskContext;
73
+ /** Board that supplied the visible task card before source-task hydration. */
74
+ visibleBoardId?: string;
75
+ /** Visible task snapshot before hydration, used to preserve overlay metadata. */
76
+ visibleTaskSnapshot?: Partial<Task>;
73
77
  /** True while an existing task was opened from a partial snapshot and is hydrating. */
74
78
  isHydratingTask?: boolean;
75
79
  /** True when the latest hydration request failed after the dialog already opened. */
@@ -90,6 +94,8 @@ interface OpenTaskByIdOptions {
90
94
  canUseBoardAssignees?: boolean;
91
95
  assigneeMemberSource?: TaskAssigneeMemberSource;
92
96
  initialSharedContext?: SharedTaskContext;
97
+ visibleBoardId?: string;
98
+ visibleTaskSnapshot?: Partial<Task>;
93
99
  }
94
100
 
95
101
  interface TaskDialogContextValue {
@@ -486,6 +492,8 @@ export function TaskDialogProvider({
486
492
  assigneeMemberSource: options?.assigneeMemberSource,
487
493
  taskWorkspaceTier: options?.taskWorkspaceTier,
488
494
  initialSharedContext: options?.initialSharedContext,
495
+ visibleBoardId: options?.visibleBoardId,
496
+ visibleTaskSnapshot: options?.visibleTaskSnapshot,
489
497
  isHydratingTask: true,
490
498
  taskLoadError: false,
491
499
  taskHydrationVersion: 0,
@@ -567,6 +575,8 @@ export function TaskDialogProvider({
567
575
  options?.canUseBoardAssignees ?? !isTaskWorkspacePersonal,
568
576
  assigneeMemberSource: options?.assigneeMemberSource,
569
577
  taskWorkspaceTier,
578
+ visibleBoardId: options?.visibleBoardId,
579
+ visibleTaskSnapshot: options?.visibleTaskSnapshot,
570
580
  isHydratingTask: false,
571
581
  taskLoadError: false,
572
582
  taskHydrationVersion: 1,
@@ -209,10 +209,16 @@ describe('BoardClient', () => {
209
209
  );
210
210
 
211
211
  expect(screen.getByTestId('task-board-loading-state')).toHaveClass(
212
- '-m-4',
212
+ '-mt-4',
213
+ '-mb-4',
214
+ '-ml-4',
213
215
  'h-[calc(100dvh+2rem)]',
214
216
  'w-[calc(100%+2rem)]'
215
217
  );
218
+ expect(screen.getByTestId('task-board-loading-state')).not.toHaveClass(
219
+ '-m-4',
220
+ '-mr-4'
221
+ );
216
222
  expect(
217
223
  screen.getByTestId('task-board-header-skeleton')
218
224
  ).toBeInTheDocument();
@@ -1,6 +1,7 @@
1
1
  import '@testing-library/jest-dom';
2
2
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
3
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { SidebarContext } from '@tuturuuu/ui/custom/sidebar-context';
4
5
  import type React from 'react';
5
6
  import { beforeEach, describe, expect, it, vi } from 'vitest';
6
7
  import { BoardHeader } from '../board-header';
@@ -180,7 +181,8 @@ const mockBoard = {
180
181
  } as const;
181
182
 
182
183
  function renderBoardHeader(
183
- overrides?: Partial<React.ComponentProps<typeof BoardHeader>>
184
+ overrides?: Partial<React.ComponentProps<typeof BoardHeader>>,
185
+ sidebarContext?: React.ContextType<typeof SidebarContext>
184
186
  ) {
185
187
  const queryClient = new QueryClient({
186
188
  defaultOptions: {
@@ -190,7 +192,7 @@ function renderBoardHeader(
190
192
  },
191
193
  });
192
194
 
193
- return render(
195
+ const content = (
194
196
  <QueryClientProvider client={queryClient}>
195
197
  <BoardHeader
196
198
  workspaceId="ws-1"
@@ -221,6 +223,16 @@ function renderBoardHeader(
221
223
  />
222
224
  </QueryClientProvider>
223
225
  );
226
+
227
+ return render(
228
+ sidebarContext ? (
229
+ <SidebarContext.Provider value={sidebarContext}>
230
+ {content}
231
+ </SidebarContext.Provider>
232
+ ) : (
233
+ content
234
+ )
235
+ );
224
236
  }
225
237
 
226
238
  describe('BoardHeader', () => {
@@ -247,6 +259,79 @@ describe('BoardHeader', () => {
247
259
  );
248
260
  });
249
261
 
262
+ it('renders a compact hide-sidebar button when sidebar context is available', () => {
263
+ const handleBehaviorChange = vi.fn();
264
+
265
+ renderBoardHeader(undefined, {
266
+ behavior: 'expanded',
267
+ handleBehaviorChange,
268
+ localOverride: false,
269
+ setBehavior: vi.fn(),
270
+ setLocalOverride: vi.fn(),
271
+ });
272
+
273
+ const hideButton = screen.getByRole('button', {
274
+ name: 'common.hide_sidebar',
275
+ });
276
+
277
+ expect(hideButton).toHaveClass('border', 'h-7', 'w-7', 'px-0');
278
+ fireEvent.click(hideButton);
279
+ expect(handleBehaviorChange).toHaveBeenCalledWith('hidden');
280
+ });
281
+
282
+ it('does not render the hide-sidebar button without context or when already hidden', () => {
283
+ const { rerender } = renderBoardHeader();
284
+
285
+ expect(
286
+ screen.queryByRole('button', { name: 'common.hide_sidebar' })
287
+ ).not.toBeInTheDocument();
288
+
289
+ rerender(
290
+ <SidebarContext.Provider
291
+ value={{
292
+ behavior: 'hidden',
293
+ handleBehaviorChange: vi.fn(),
294
+ localOverride: false,
295
+ setBehavior: vi.fn(),
296
+ setLocalOverride: vi.fn(),
297
+ }}
298
+ >
299
+ <QueryClientProvider client={new QueryClient()}>
300
+ <BoardHeader
301
+ workspaceId="ws-1"
302
+ board={mockBoard}
303
+ currentView="kanban"
304
+ filters={{
305
+ assignees: [],
306
+ dueDateRange: null,
307
+ estimationRange: null,
308
+ includeMyTasks: false,
309
+ includeUnassigned: false,
310
+ labels: [],
311
+ priorities: [],
312
+ projects: [],
313
+ sourceBoardIds: [],
314
+ sourceScope: 'all_visible',
315
+ sourceWorkspaceIds: [],
316
+ }}
317
+ isMultiSelectMode={false}
318
+ isPersonalWorkspace={false}
319
+ listStatusFilter="all"
320
+ onFiltersChange={vi.fn()}
321
+ onListStatusFilterChange={vi.fn()}
322
+ onUpdate={vi.fn()}
323
+ onViewChange={vi.fn()}
324
+ setIsMultiSelectMode={vi.fn()}
325
+ />
326
+ </QueryClientProvider>
327
+ </SidebarContext.Provider>
328
+ );
329
+
330
+ expect(
331
+ screen.queryByRole('button', { name: 'common.hide_sidebar' })
332
+ ).not.toBeInTheDocument();
333
+ });
334
+
250
335
  it('prefetches current board settings before opening the dialog', async () => {
251
336
  renderBoardHeader();
252
337
 
@@ -8,11 +8,17 @@ describe('TaskBoardLoadingState', () => {
8
8
  render(<TaskBoardLoadingState root />);
9
9
 
10
10
  expect(screen.getByTestId('task-board-loading-state')).toHaveClass(
11
- '-m-4',
11
+ '-mt-4',
12
+ '-mb-4',
13
+ '-ml-4',
12
14
  'h-[calc(100dvh+2rem)]',
13
15
  'w-[calc(100%+2rem)]',
14
16
  'bg-transparent'
15
17
  );
18
+ expect(screen.getByTestId('task-board-loading-state')).not.toHaveClass(
19
+ '-m-4',
20
+ '-mr-4'
21
+ );
16
22
  expect(screen.getByTestId('kanban-skeleton')).toHaveClass('bg-transparent');
17
23
  expect(screen.getByTestId('kanban-skeleton-frame')).toHaveClass(
18
24
  'py-2',
@@ -34,6 +40,9 @@ describe('TaskBoardLoadingState', () => {
34
40
  );
35
41
  expect(screen.getByTestId('task-board-loading-state')).not.toHaveClass(
36
42
  '-m-4',
43
+ '-mt-4',
44
+ '-mb-4',
45
+ '-ml-4',
37
46
  'w-[calc(100%+2rem)]'
38
47
  );
39
48
  expect(screen.getByTestId('kanban-skeleton-frame')).toHaveClass('p-2');
@@ -46,22 +55,37 @@ describe('TaskBoardLoadingState', () => {
46
55
  render(<TaskBoardLoadingState root showHeader />);
47
56
 
48
57
  expect(screen.getByTestId('task-board-loading-state')).toHaveClass(
49
- '-m-4',
58
+ '-mt-4',
59
+ '-mb-4',
60
+ '-ml-4',
50
61
  'h-[calc(100dvh+2rem)]',
51
62
  'w-[calc(100%+2rem)]',
52
63
  'flex',
53
64
  'flex-col'
54
65
  );
66
+ expect(screen.getByTestId('task-board-loading-state')).not.toHaveClass(
67
+ '-m-4',
68
+ '-mr-4'
69
+ );
55
70
  expect(screen.getByTestId('task-board-header-skeleton')).toHaveAttribute(
56
71
  'aria-hidden',
57
72
  'true'
58
73
  );
59
74
  expect(screen.getByTestId('task-board-header-skeleton')).toHaveClass(
60
75
  'border-b',
61
- 'px-2',
76
+ 'pl-2',
77
+ 'pr-0',
62
78
  'pt-2',
63
79
  'pb-2'
64
80
  );
81
+ expect(screen.getByTestId('task-board-header-skeleton')).not.toHaveClass(
82
+ 'px-2'
83
+ );
84
+ expect(screen.getByTestId('kanban-skeleton-frame')).toHaveClass(
85
+ 'py-2',
86
+ 'pl-2',
87
+ 'pr-0'
88
+ );
65
89
  expect(screen.getByTestId('task-board-header-skeleton')).not.toHaveClass(
66
90
  '-mt-2'
67
91
  );
@@ -171,6 +171,8 @@ vi.mock('../task-edit-dialog', () => ({
171
171
  isHydratingTask,
172
172
  taskLoadError,
173
173
  task,
174
+ visibleBoardId,
175
+ visibleTaskSnapshot,
174
176
  onClose,
175
177
  onNavigateToTask,
176
178
  }: {
@@ -179,6 +181,8 @@ vi.mock('../task-edit-dialog', () => ({
179
181
  isHydratingTask?: boolean;
180
182
  taskLoadError?: boolean;
181
183
  task?: Task;
184
+ visibleBoardId?: string;
185
+ visibleTaskSnapshot?: Partial<Task>;
182
186
  onClose: () => void;
183
187
  onNavigateToTask?: (taskId: string) => Promise<void>;
184
188
  }) => {
@@ -197,6 +201,8 @@ vi.mock('../task-edit-dialog', () => ({
197
201
  data-hydrating={String(!!isHydratingTask)}
198
202
  data-load-error={String(!!taskLoadError)}
199
203
  data-open={isOpen}
204
+ data-visible-board-id={visibleBoardId}
205
+ data-visible-list-id={visibleTaskSnapshot?.list_id}
200
206
  >
201
207
  {task && <div data-testid="task-name">{task.name}</div>}
202
208
  <button
@@ -684,11 +690,25 @@ describe('TaskDialogManager', () => {
684
690
  const { openTaskById } = useTaskDialogContext();
685
691
 
686
692
  React.useEffect(() => {
693
+ const visibleExternalSnapshot = {
694
+ ...mockTask,
695
+ id: 'external-task-1',
696
+ name: 'External snapshot',
697
+ list_id: 'personal-list-1',
698
+ personal_board_id: 'personal-board-1',
699
+ personal_list_id: 'personal-list-1',
700
+ personal_sort_key: 2000,
701
+ source_workspace_id: 'source-workspace-1',
702
+ source_board_id: 'external-board-1',
703
+ source_list_id: 'external-list-1',
704
+ source_list_name: 'External list',
705
+ is_personal_external: true,
706
+ } satisfies Task;
707
+
687
708
  void openTaskById('external-task-1', {
688
709
  initialTask: {
689
- ...mockTask,
690
- id: 'external-task-1',
691
- name: 'External snapshot',
710
+ ...visibleExternalSnapshot,
711
+ list_id: 'external-list-1',
692
712
  },
693
713
  boardId: 'external-board-1',
694
714
  availableLists: [
@@ -700,6 +720,8 @@ describe('TaskDialogManager', () => {
700
720
  ],
701
721
  taskWsId: 'source-workspace-1',
702
722
  taskWorkspacePersonal: false,
723
+ visibleBoardId: 'personal-board-1',
724
+ visibleTaskSnapshot: visibleExternalSnapshot,
703
725
  });
704
726
  }, [openTaskById]);
705
727
 
@@ -719,6 +741,14 @@ describe('TaskDialogManager', () => {
719
741
  );
720
742
  expect(getByTestId('task-name')).toHaveTextContent('External snapshot');
721
743
  });
744
+ expect(getByTestId('task-edit-dialog')).toHaveAttribute(
745
+ 'data-visible-board-id',
746
+ 'personal-board-1'
747
+ );
748
+ expect(getByTestId('task-edit-dialog')).toHaveAttribute(
749
+ 'data-visible-list-id',
750
+ 'personal-list-1'
751
+ );
722
752
  expect(taskDialogRenderStats).toMatchObject({
723
753
  mounts: 1,
724
754
  unmounts: 0,
@@ -751,6 +781,14 @@ describe('TaskDialogManager', () => {
751
781
  'Hydrated external task'
752
782
  );
753
783
  });
784
+ expect(getByTestId('task-edit-dialog')).toHaveAttribute(
785
+ 'data-visible-board-id',
786
+ 'personal-board-1'
787
+ );
788
+ expect(getByTestId('task-edit-dialog')).toHaveAttribute(
789
+ 'data-visible-list-id',
790
+ 'personal-list-1'
791
+ );
754
792
  expect(taskDialogRenderStats).toMatchObject({
755
793
  mounts: 1,
756
794
  unmounts: 0,
@@ -515,6 +515,60 @@ describe('useProgressiveBoardLoader', () => {
515
515
  ]);
516
516
  });
517
517
 
518
+ it('preserves a freshly property-updated external overlay when list revalidation misses it', async () => {
519
+ const propertyUpdatedExternalTask = {
520
+ id: 'task-external',
521
+ display_number: 43,
522
+ name: 'Updated external task',
523
+ list_id: 'personal-list-2',
524
+ created_at: '2026-05-07T00:00:00.000Z',
525
+ sort_key: 1_500_000,
526
+ personal_board_id: 'board-1',
527
+ personal_list_id: 'personal-list-2',
528
+ personal_sort_key: 1_500_000,
529
+ source_workspace_id: 'source-ws',
530
+ source_board_id: 'source-board',
531
+ source_list_id: 'source-list',
532
+ source_list_name: 'Source list',
533
+ source_list_status: 'active',
534
+ is_personal_external: true,
535
+ priority: 'high',
536
+ _localMutationAt: Date.now(),
537
+ } as Task;
538
+
539
+ const { result } = renderHook(
540
+ () => useProgressiveBoardLoader('personal', 'board-1'),
541
+ { wrapper }
542
+ );
543
+
544
+ vi.mocked(listWorkspaceTasks).mockResolvedValueOnce({ tasks: [] });
545
+
546
+ await act(async () => {
547
+ await result.current.loadListPage('personal-list-2', 0);
548
+ });
549
+
550
+ queryClient.setQueryData(
551
+ ['tasks', 'board-1'],
552
+ [propertyUpdatedExternalTask]
553
+ );
554
+ vi.mocked(listWorkspaceTasks).mockResolvedValueOnce({ tasks: [] });
555
+
556
+ await act(async () => {
557
+ await result.current.revalidateLoadedLists();
558
+ });
559
+
560
+ expect(queryClient.getQueryData<Task[]>(['tasks', 'board-1'])).toEqual([
561
+ expect.objectContaining({
562
+ id: 'task-external',
563
+ priority: 'high',
564
+ list_id: 'personal-list-2',
565
+ personal_list_id: 'personal-list-2',
566
+ source_list_id: 'source-list',
567
+ _localMutationAt: expect.any(Number),
568
+ }),
569
+ ]);
570
+ });
571
+
518
572
  it('revalidates loaded lists without clearing other list tasks', async () => {
519
573
  const existingOtherListTask: Task = {
520
574
  id: 'task-other',
@@ -15,6 +15,7 @@ import {
15
15
  LayoutGrid,
16
16
  List,
17
17
  Loader2,
18
+ PanelLeftClose,
18
19
  Pencil,
19
20
  Play,
20
21
  Search,
@@ -29,6 +30,7 @@ import type { WorkspaceTaskBoard } from '@tuturuuu/types';
29
30
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
30
31
  import { Button } from '@tuturuuu/ui/button';
31
32
  import { Combobox } from '@tuturuuu/ui/custom/combobox';
33
+ import { useOptionalSidebar } from '@tuturuuu/ui/custom/sidebar-context';
32
34
  import { Input } from '@tuturuuu/ui/input';
33
35
  import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
34
36
  import { cn } from '@tuturuuu/utils/format';
@@ -157,6 +159,9 @@ export function BoardHeader({
157
159
  !publicView &&
158
160
  isPersonalWorkspace &&
159
161
  currentView === 'kanban';
162
+ const sidebar = useOptionalSidebar();
163
+ const hideSidebarVisible =
164
+ Boolean(sidebar) && sidebar?.behavior !== 'hidden' && !publicView;
160
165
  const hasSharedBoardGuests =
161
166
  board.access_type === 'guest' || board.has_guest_access === true;
162
167
  const presenceVisible =
@@ -568,6 +573,21 @@ export function BoardHeader({
568
573
  />
569
574
  )}
570
575
 
576
+ {hideSidebarVisible && (
577
+ <ToolbarTooltip label={t('common.hide_sidebar')}>
578
+ <Button
579
+ type="button"
580
+ size="xs"
581
+ variant="outline"
582
+ className={toolbarButtonClass}
583
+ onClick={() => sidebar?.handleBehaviorChange('hidden')}
584
+ aria-label={t('common.hide_sidebar')}
585
+ >
586
+ <PanelLeftClose className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
587
+ </Button>
588
+ </ToolbarTooltip>
589
+ )}
590
+
571
591
  {/* Smart Focus Button */}
572
592
  {interactiveControlsVisible && (
573
593
  <ToolbarTooltip
@@ -13,11 +13,11 @@ const HEADER_ACTIONS = [
13
13
  'settings',
14
14
  ];
15
15
 
16
- function TaskBoardHeaderSkeleton() {
16
+ function TaskBoardHeaderSkeleton({ root = false }: { root?: boolean }) {
17
17
  return (
18
18
  <div
19
19
  aria-hidden="true"
20
- className="border-b px-2 pt-2 pb-2"
20
+ className={cn('border-b pt-2 pb-2', root ? 'pr-0 pl-2' : 'px-2')}
21
21
  data-testid="task-board-header-skeleton"
22
22
  >
23
23
  <div className="flex flex-wrap items-center justify-between gap-1.5 sm:gap-2">
@@ -57,7 +57,7 @@ export function TaskBoardLoadingState({
57
57
  className={cn(
58
58
  'overflow-hidden bg-transparent',
59
59
  root
60
- ? '-m-4 h-[calc(100dvh+2rem)] min-h-[calc(32rem+2rem)] w-[calc(100%+2rem)] min-w-[calc(100%+2rem)]'
60
+ ? '-mt-4 -mb-4 -ml-4 h-[calc(100dvh+2rem)] min-h-[calc(32rem+2rem)] w-[calc(100%+2rem)] min-w-[calc(100%+2rem)]'
61
61
  : 'h-[calc(100dvh-1rem)] min-h-[32rem] w-full',
62
62
  showHeader && 'flex flex-col',
63
63
  className
@@ -66,7 +66,7 @@ export function TaskBoardLoadingState({
66
66
  >
67
67
  {showHeader ? (
68
68
  <>
69
- <TaskBoardHeaderSkeleton />
69
+ <TaskBoardHeaderSkeleton root={root} />
70
70
  <div
71
71
  className="min-h-0 flex-1 overflow-hidden"
72
72
  data-testid="task-board-loading-body"
@@ -542,6 +542,8 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
542
542
  taskWsId={state.taskWsId}
543
543
  task={state.task}
544
544
  boardId={state.boardId || ''}
545
+ visibleBoardId={state.visibleBoardId}
546
+ visibleTaskSnapshot={state.visibleTaskSnapshot}
545
547
  isOpen={state.isOpen}
546
548
  availableLists={state.availableLists}
547
549
  sharedContext={
@@ -220,6 +220,9 @@ interface TaskDialogHeaderProps {
220
220
  isPersonalWorkspace?: boolean;
221
221
  /** Callback to open share dialog */
222
222
  onOpenShareDialog?: () => void;
223
+ /** Optional archive action for document-type tasks. */
224
+ onArchiveTask?: () => void;
225
+ archiveTaskDisabled?: boolean;
223
226
  /** Whether the dialog is in read-only mode */
224
227
  disabled?: boolean;
225
228
  /** Whether task-dependent actions should be disabled while the task hydrates. */
@@ -263,6 +266,8 @@ export function TaskDialogHeader({
263
266
  onNavigateBack,
264
267
  isPersonalWorkspace = false,
265
268
  onOpenShareDialog,
269
+ onArchiveTask,
270
+ archiveTaskDisabled = false,
266
271
  disabled = false,
267
272
  controlsDisabled = false,
268
273
  onScrollToUserCursor,
@@ -487,6 +492,8 @@ export function TaskDialogHeader({
487
492
  onShowDeleteDialog={() => setShowDeleteConfirm(true)}
488
493
  onClearDraft={clearDraftState}
489
494
  onNavigateBack={onNavigateBack}
495
+ onArchiveTask={onArchiveTask}
496
+ archiveTaskDisabled={archiveTaskDisabled}
490
497
  onOpenShareDialog={onOpenShareDialog}
491
498
  disabled={disabled}
492
499
  controlsDisabled={controlsDisabled}