@tuturuuu/ui 0.8.0 → 0.9.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 (182) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/biome.json +1 -1
  3. package/package.json +73 -71
  4. package/src/components/ui/accordion.tsx +1 -1
  5. package/src/components/ui/breadcrumb.tsx +1 -1
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +4 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +239 -33
  8. package/src/components/ui/calendar-app/components/load-smart-scheduling-tasks.tsx +143 -0
  9. package/src/components/ui/calendar-app/components/priority-view.tsx +10 -3
  10. package/src/components/ui/calendar-app/components/tasks-sidebar.tsx +4 -116
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +67 -2
  12. package/src/components/ui/calendar.tsx +1 -1
  13. package/src/components/ui/carousel.tsx +1 -1
  14. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +1 -1
  15. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +1 -1
  16. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +1 -1
  17. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +1 -1
  18. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +1 -1
  19. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +1 -1
  20. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +2 -2
  21. package/src/components/ui/chat/chat-agent-details-utils.test.ts +1 -1
  22. package/src/components/ui/chat/chat-agent-details-utils.tsx +1 -1
  23. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +2 -2
  24. package/src/components/ui/checkbox.tsx +1 -1
  25. package/src/components/ui/color-picker.tsx +1 -1
  26. package/src/components/ui/command.tsx +1 -1
  27. package/src/components/ui/context-menu.tsx +5 -1
  28. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +3 -0
  29. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +19 -0
  30. package/src/components/ui/custom/combobox.test.tsx +195 -0
  31. package/src/components/ui/custom/combobox.tsx +273 -156
  32. package/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +5 -13
  33. package/src/components/ui/custom/facebook-mockup/facebook-mockup.tsx +7 -1
  34. package/src/components/ui/custom/facebook-mockup/form.tsx +1 -1
  35. package/src/components/ui/custom/facebook-mockup/image-upload-field.tsx +1 -1
  36. package/src/components/ui/custom/facebook-mockup/preview.tsx +1 -1
  37. package/src/components/ui/custom/settings-dialog-shell.tsx +2 -1
  38. package/src/components/ui/custom/theme-toggle.tsx +1 -1
  39. package/src/components/ui/custom/workspace-select.tsx +8 -3
  40. package/src/components/ui/dialog.test.tsx +52 -0
  41. package/src/components/ui/dialog.tsx +6 -2
  42. package/src/components/ui/dropdown-menu.tsx +5 -1
  43. package/src/components/ui/finance/debts/debt-loan-form.tsx +12 -5
  44. package/src/components/ui/finance/debts/debt-loan-summary.tsx +3 -2
  45. package/src/components/ui/finance/debts/debts-page.test.tsx +54 -5
  46. package/src/components/ui/finance/debts/debts-page.tsx +15 -2
  47. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +3 -5
  48. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +25 -5
  49. package/src/components/ui/finance/invoices/new-invoice-page.tsx +7 -2
  50. package/src/components/ui/finance/invoices/standard-invoice.tsx +4 -2
  51. package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -2
  52. package/src/components/ui/finance/invoices/utils.ts +3 -1
  53. package/src/components/ui/finance/transactions/form-content-dialog.tsx +3 -0
  54. package/src/components/ui/finance/transactions/form-types.ts +1 -0
  55. package/src/components/ui/finance/transactions/form.tsx +2 -0
  56. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +2 -0
  57. package/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +1 -1
  58. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +1 -4
  59. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +3 -0
  60. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -1
  61. package/src/components/ui/finance/wallets/form.test.tsx +51 -3
  62. package/src/components/ui/finance/wallets/form.tsx +15 -4
  63. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  64. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +4 -2
  65. package/src/components/ui/finance/wallets/wallets-data-table.tsx +1 -0
  66. package/src/components/ui/finance/wallets/wallets-page.tsx +5 -2
  67. package/src/components/ui/input-otp.tsx +1 -1
  68. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +28 -39
  69. package/src/components/ui/legacy/calendar/calendar-cell.tsx +2 -0
  70. package/src/components/ui/legacy/calendar/calendar-content.tsx +10 -6
  71. package/src/components/ui/legacy/calendar/calendar-header.tsx +23 -3
  72. package/src/components/ui/legacy/calendar/calendar-loading-skeleton.tsx +135 -0
  73. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +175 -237
  74. package/src/components/ui/legacy/calendar/event-card.test.tsx +177 -0
  75. package/src/components/ui/legacy/calendar/event-card.tsx +220 -131
  76. package/src/components/ui/legacy/calendar/event-modal.tsx +17 -17
  77. package/src/components/ui/legacy/calendar/event-provider-display.tsx +69 -0
  78. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +86 -4
  79. package/src/components/ui/legacy/calendar/smart-calendar.tsx +32 -2
  80. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +19 -10
  81. package/src/components/ui/navigation-menu.tsx +1 -1
  82. package/src/components/ui/pagination.tsx +1 -1
  83. package/src/components/ui/radio-group.tsx +1 -1
  84. package/src/components/ui/select.tsx +5 -1
  85. package/src/components/ui/sheet.tsx +1 -1
  86. package/src/components/ui/sidebar.tsx +1 -1
  87. package/src/components/ui/storefront/cart-popover.tsx +61 -0
  88. package/src/components/ui/storefront/cart-summary-parts.tsx +290 -0
  89. package/src/components/ui/storefront/cart-summary.tsx +93 -154
  90. package/src/components/ui/storefront/checkout-overlay.tsx +4 -5
  91. package/src/components/ui/storefront/listing-card.tsx +1 -1
  92. package/src/components/ui/storefront/merch-sections.tsx +70 -0
  93. package/src/components/ui/storefront/product-detail.tsx +1 -1
  94. package/src/components/ui/storefront/storefront-surface.test.tsx +106 -11
  95. package/src/components/ui/storefront/storefront-surface.tsx +101 -166
  96. package/src/components/ui/storefront/types.ts +4 -0
  97. package/src/components/ui/storefront/utils.ts +6 -0
  98. package/src/components/ui/text-editor/__tests__/extensions.test.ts +123 -0
  99. package/src/components/ui/text-editor/background-color-extension.ts +62 -0
  100. package/src/components/ui/text-editor/color-controls.tsx +284 -0
  101. package/src/components/ui/text-editor/editor.tsx +69 -14
  102. package/src/components/ui/text-editor/extensions.ts +8 -2
  103. package/src/components/ui/text-editor/highlight-extension.ts +22 -0
  104. package/src/components/ui/text-editor/tool-bar.tsx +9 -16
  105. package/src/components/ui/toast.tsx +1 -1
  106. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +270 -0
  107. package/src/components/ui/tu-do/boards/board-public-link-section.tsx +231 -0
  108. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +222 -109
  109. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +112 -43
  110. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +2 -0
  111. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-move.ts +5 -0
  112. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +3 -0
  113. package/src/components/ui/tu-do/boards/boardId/kanban/data/kanban-deadline-query.ts +50 -2
  114. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/__tests__/column-reorder.test.ts +17 -0
  115. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/column-reorder.ts +4 -1
  116. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +38 -9
  117. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-order.ts +2 -8
  118. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-sort-key.ts +47 -0
  119. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +81 -30
  120. package/src/components/ui/tu-do/boards/boardId/kanban/planner/__tests__/kanban-planner-island.test.tsx +380 -0
  121. package/src/components/ui/tu-do/boards/boardId/kanban/planner/kanban-planner-dialog.tsx +204 -0
  122. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-digest-panel.tsx +61 -0
  123. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-item-strip.tsx +54 -0
  124. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-plan-toolbar.tsx +251 -0
  125. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-scope-badge.tsx +27 -0
  126. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-section.tsx +58 -0
  127. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-share-dialog.tsx +238 -0
  128. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-target-controls.tsx +143 -0
  129. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-utils.ts +65 -0
  130. package/src/components/ui/tu-do/boards/boardId/kanban/planner/use-kanban-planner-state.ts +234 -0
  131. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +397 -2
  132. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +103 -13
  133. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +443 -19
  134. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +94 -32
  135. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +213 -106
  136. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +26 -4
  137. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +5 -2
  138. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +3 -0
  139. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +3 -0
  140. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +191 -28
  141. package/src/components/ui/tu-do/boards/boardId/task-filter.test.tsx +152 -0
  142. package/src/components/ui/tu-do/boards/boardId/task-filter.tsx +555 -545
  143. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +7 -0
  144. package/src/components/ui/tu-do/boards/share-section.tsx +100 -0
  145. package/src/components/ui/tu-do/drafts/draft-convert-dialog.tsx +10 -12
  146. package/src/components/ui/tu-do/drafts/drafts-page.tsx +33 -16
  147. package/src/components/ui/tu-do/initiatives/task-initiatives-client.tsx +56 -88
  148. package/src/components/ui/tu-do/my-tasks/my-tasks-content.tsx +26 -2
  149. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +55 -8
  150. package/src/components/ui/tu-do/notes/note-edit-dialog.tsx +1 -4
  151. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +25 -0
  152. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +341 -38
  153. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +253 -0
  154. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +203 -2
  155. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +17 -0
  156. package/src/components/ui/tu-do/shared/__tests__/task-legacy-route-recovery.test.tsx +16 -0
  157. package/src/components/ui/tu-do/shared/board-client.tsx +2 -7
  158. package/src/components/ui/tu-do/shared/board-config-storage.ts +7 -1
  159. package/src/components/ui/tu-do/shared/board-header.tsx +464 -975
  160. package/src/components/ui/tu-do/shared/board-layout-settings.tsx +165 -136
  161. package/src/components/ui/tu-do/shared/board-switcher.tsx +209 -217
  162. package/src/components/ui/tu-do/shared/board-views.tsx +587 -75
  163. package/src/components/ui/tu-do/shared/list-view.tsx +227 -1
  164. package/src/components/ui/tu-do/shared/recycle-bin-panel.tsx +142 -94
  165. package/src/components/ui/tu-do/shared/special-task-list-pins.ts +51 -0
  166. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +28 -0
  167. package/src/components/ui/tu-do/shared/task-edit-dialog/field-diff-viewer.tsx +3 -2
  168. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.test.tsx +91 -0
  169. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.tsx +123 -78
  170. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +7 -1
  171. package/src/components/ui/tu-do/shared/task-edit-dialog/task-snapshot-dialog.tsx +8 -3
  172. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +2 -1
  173. package/src/components/ui/tu-do/shared/task-legacy-route-recovery.tsx +2 -9
  174. package/src/declarations.d.ts +1 -0
  175. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +322 -2
  176. package/src/hooks/__tests__/use-calendar-sync.test.tsx +446 -0
  177. package/src/hooks/use-calendar-sync.tsx +247 -243
  178. package/src/hooks/use-calendar.tsx +323 -138
  179. package/src/hooks/use-task-actions.ts +24 -0
  180. package/src/hooks/use-user-workspace-config.ts +75 -0
  181. package/src/hooks/use-workspace-currency.ts +8 -3
  182. package/src/hooks/useBoardRealtimeEventHandler.ts +11 -0
@@ -0,0 +1,253 @@
1
+ import '@testing-library/jest-dom';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { BoardSwitcher } from '../board-switcher';
6
+
7
+ const {
8
+ createWorkspaceTaskBoardMock,
9
+ isTaskRememberLastBoardEnabledMock,
10
+ listWorkspaceTaskBoardsMock,
11
+ pushMock,
12
+ rememberLastBoardConfig,
13
+ updateUserWorkspaceConfigMock,
14
+ useUserWorkspaceConfigMock,
15
+ } = vi.hoisted(() => ({
16
+ createWorkspaceTaskBoardMock: vi.fn(),
17
+ isTaskRememberLastBoardEnabledMock: vi.fn(
18
+ (value: string | null | undefined) => value !== 'false'
19
+ ),
20
+ listWorkspaceTaskBoardsMock: vi.fn(),
21
+ pushMock: vi.fn(),
22
+ rememberLastBoardConfig: {
23
+ value: 'true' as string | null | undefined,
24
+ },
25
+ updateUserWorkspaceConfigMock: vi.fn(),
26
+ useUserWorkspaceConfigMock: vi.fn(),
27
+ }));
28
+
29
+ let comboboxProps:
30
+ | {
31
+ createText?: string;
32
+ creatingText?: string;
33
+ onChange: (value: string) => void;
34
+ onCreate?: (value: string) => Promise<{ label: string; value: string }>;
35
+ options: Array<{ icon?: unknown; label: string; value: string }>;
36
+ searchPlaceholder?: string;
37
+ selected?: string;
38
+ showSelectedIcon?: boolean;
39
+ }
40
+ | undefined;
41
+
42
+ vi.mock('@tuturuuu/internal-api/tasks', () => ({
43
+ createWorkspaceTaskBoard: (
44
+ ...args: Parameters<typeof createWorkspaceTaskBoardMock>
45
+ ) => createWorkspaceTaskBoardMock(...args),
46
+ listWorkspaceTaskBoards: (
47
+ ...args: Parameters<typeof listWorkspaceTaskBoardsMock>
48
+ ) => listWorkspaceTaskBoardsMock(...args),
49
+ }));
50
+
51
+ vi.mock('@tuturuuu/internal-api/users', () => ({
52
+ isTaskRememberLastBoardEnabled: (value: string | null | undefined): boolean =>
53
+ isTaskRememberLastBoardEnabledMock(value),
54
+ TASK_DEFAULT_BOARD_ID_CONFIG_ID: 'TASK_DEFAULT_BOARD_ID',
55
+ TASK_REMEMBER_LAST_BOARD_CONFIG_ID: 'TASK_REMEMBER_LAST_BOARD',
56
+ }));
57
+
58
+ vi.mock('../../../../../hooks/use-user-workspace-config', () => ({
59
+ useUpdateUserWorkspaceConfig: () => ({
60
+ isPending: false,
61
+ mutate: updateUserWorkspaceConfigMock,
62
+ }),
63
+ useUserWorkspaceConfig: (...args: unknown[]) => {
64
+ useUserWorkspaceConfigMock(...args);
65
+ return {
66
+ data: rememberLastBoardConfig.value,
67
+ isLoading: false,
68
+ };
69
+ },
70
+ }));
71
+
72
+ vi.mock('@tuturuuu/ui/custom/combobox', () => ({
73
+ Combobox: (props: any) => {
74
+ comboboxProps = props;
75
+ return (
76
+ <div>
77
+ <button
78
+ type="button"
79
+ data-testid="board-combobox"
80
+ onClick={() => props.onChange('board-2')}
81
+ >
82
+ {props.label}
83
+ </button>
84
+ <button
85
+ type="button"
86
+ data-testid="create-board"
87
+ onClick={async () => {
88
+ const result = await props.onCreate?.('Launch Board');
89
+ const boardId = typeof result === 'string' ? result : result?.value;
90
+ if (boardId) props.onChange(boardId);
91
+ }}
92
+ >
93
+ Create board
94
+ </button>
95
+ </div>
96
+ );
97
+ },
98
+ }));
99
+
100
+ vi.mock('@tuturuuu/ui/sonner', () => ({
101
+ toast: {
102
+ error: vi.fn(),
103
+ },
104
+ }));
105
+
106
+ vi.mock('next/navigation', () => ({
107
+ useRouter: () => ({
108
+ push: pushMock,
109
+ }),
110
+ }));
111
+
112
+ vi.mock('../tasks-route-context', () => ({
113
+ useTasksHref: () => (path: string) => `/tasks${path}`,
114
+ }));
115
+
116
+ function renderBoardSwitcher() {
117
+ const queryClient = new QueryClient({
118
+ defaultOptions: {
119
+ queries: {
120
+ retry: false,
121
+ },
122
+ },
123
+ });
124
+
125
+ return render(
126
+ <QueryClientProvider client={queryClient}>
127
+ <BoardSwitcher
128
+ board={{
129
+ id: 'board-1',
130
+ name: 'Tasks',
131
+ ticket_prefix: 'T',
132
+ ws_id: 'ws-1',
133
+ }}
134
+ translations={{
135
+ activeBoards: 'Active boards',
136
+ archivedBoards: 'Archived boards',
137
+ deletedBoards: 'Deleted boards',
138
+ createBoard: 'Create Board',
139
+ creatingBoard: 'Creating',
140
+ searchBoards: 'Search boards...',
141
+ tasks: 'Tasks',
142
+ }}
143
+ />
144
+ </QueryClientProvider>
145
+ );
146
+ }
147
+
148
+ describe('BoardSwitcher', () => {
149
+ beforeEach(() => {
150
+ vi.clearAllMocks();
151
+ comboboxProps = undefined;
152
+ rememberLastBoardConfig.value = 'true';
153
+ listWorkspaceTaskBoardsMock.mockResolvedValue({
154
+ boards: [
155
+ {
156
+ archived_at: null,
157
+ created_at: '2026-06-01T00:00:00.000Z',
158
+ deleted_at: null,
159
+ icon: null,
160
+ id: 'board-2',
161
+ name: 'Roadmap',
162
+ },
163
+ ],
164
+ });
165
+ });
166
+
167
+ it('uses the shared combobox and navigates when a board is selected', async () => {
168
+ renderBoardSwitcher();
169
+
170
+ expect(screen.getByTestId('board-combobox')).toBeInTheDocument();
171
+
172
+ await waitFor(() => {
173
+ expect(comboboxProps?.options).toEqual(
174
+ expect.arrayContaining([
175
+ expect.objectContaining({
176
+ label: 'Roadmap',
177
+ value: 'board-2',
178
+ }),
179
+ ])
180
+ );
181
+ });
182
+
183
+ expect(comboboxProps).toMatchObject({
184
+ createText: 'Create Board',
185
+ creatingText: 'Creating',
186
+ searchPlaceholder: 'Search boards...',
187
+ selected: 'board-1',
188
+ });
189
+ expect(comboboxProps?.showSelectedIcon).toBeUndefined();
190
+ expect(comboboxProps?.options.some((option) => option.icon)).toBe(true);
191
+
192
+ fireEvent.click(screen.getByTestId('board-combobox'));
193
+
194
+ expect(pushMock).toHaveBeenCalledWith('/ws-1/tasks/boards/board-2');
195
+ expect(useUserWorkspaceConfigMock).toHaveBeenCalledWith(
196
+ 'ws-1',
197
+ 'TASK_REMEMBER_LAST_BOARD',
198
+ 'true'
199
+ );
200
+ expect(isTaskRememberLastBoardEnabledMock).toHaveBeenCalledWith('true');
201
+ expect(updateUserWorkspaceConfigMock).toHaveBeenCalledWith({
202
+ configId: 'TASK_DEFAULT_BOARD_ID',
203
+ value: 'board-2',
204
+ workspaceId: 'ws-1',
205
+ });
206
+ });
207
+
208
+ it('navigates without updating the default board when board memory is disabled', async () => {
209
+ rememberLastBoardConfig.value = 'false';
210
+ renderBoardSwitcher();
211
+
212
+ await waitFor(() => {
213
+ expect(comboboxProps?.options.length).toBeGreaterThan(0);
214
+ });
215
+
216
+ fireEvent.click(screen.getByTestId('board-combobox'));
217
+
218
+ expect(pushMock).toHaveBeenCalledWith('/ws-1/tasks/boards/board-2');
219
+ expect(updateUserWorkspaceConfigMock).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('creates a new board from the picker and opens it', async () => {
223
+ createWorkspaceTaskBoardMock.mockResolvedValue({
224
+ board: {
225
+ archived_at: null,
226
+ created_at: '2026-06-24T00:00:00.000Z',
227
+ deleted_at: null,
228
+ icon: null,
229
+ id: 'board-3',
230
+ name: 'Launch Board',
231
+ },
232
+ });
233
+
234
+ renderBoardSwitcher();
235
+
236
+ fireEvent.click(screen.getByTestId('create-board'));
237
+
238
+ await waitFor(() => {
239
+ expect(createWorkspaceTaskBoardMock).toHaveBeenCalledWith('ws-1', {
240
+ name: 'Launch Board',
241
+ });
242
+ });
243
+
244
+ await waitFor(() => {
245
+ expect(pushMock).toHaveBeenCalledWith('/ws-1/tasks/boards/board-3');
246
+ });
247
+ expect(updateUserWorkspaceConfigMock).toHaveBeenCalledWith({
248
+ configId: 'TASK_DEFAULT_BOARD_ID',
249
+ value: 'board-3',
250
+ workspaceId: 'ws-1',
251
+ });
252
+ });
253
+ });
@@ -17,6 +17,8 @@ import { getBoardConfigKey } from '../board-config-storage';
17
17
  import { BoardViews } from '../board-views';
18
18
 
19
19
  const listWorkspaceTasksMock = vi.hoisted(() => vi.fn());
20
+ const getUserWorkspaceConfigMock = vi.hoisted(() => vi.fn());
21
+ const updateUserWorkspaceConfigMock = vi.hoisted(() => vi.fn());
20
22
  const createTaskMock = vi.fn();
21
23
  const loadListPageMock = vi.fn();
22
24
  let progressivePagination: Record<string, unknown> = {};
@@ -54,6 +56,15 @@ vi.mock('@tuturuuu/internal-api/tasks', () => ({
54
56
  listWorkspaceTasks: listWorkspaceTasksMock,
55
57
  }));
56
58
 
59
+ vi.mock('@tuturuuu/internal-api/users', () => ({
60
+ TASK_BOARD_PINNED_SPECIAL_LISTS_CONFIG_ID: 'TASK_BOARD_PINNED_SPECIAL_LISTS',
61
+ TASK_LAST_BOARD_VIEW_CONFIG_ID: 'TASK_LAST_BOARD_VIEW',
62
+ getUserWorkspaceConfig: (...args: unknown[]) =>
63
+ getUserWorkspaceConfigMock(...args),
64
+ updateUserWorkspaceConfig: (...args: unknown[]) =>
65
+ updateUserWorkspaceConfigMock(...args),
66
+ }));
67
+
57
68
  vi.mock('../progressive-loader-context', () => ({
58
69
  useProgressiveLoader: () => ({
59
70
  loadListPage: loadListPageMock,
@@ -75,7 +86,32 @@ vi.mock('../board-header', () => ({
75
86
  }));
76
87
 
77
88
  vi.mock('../recycle-bin-panel', () => ({
78
- RecycleBinPanel: () => null,
89
+ RecycleBinContent: () => <div data-testid="recycle-bin-view">Recycle</div>,
90
+ }));
91
+
92
+ vi.mock('../../drafts/drafts-page', () => ({
93
+ DraftsPage: ({
94
+ boardId,
95
+ includeUnassignedForBoard,
96
+ wsId,
97
+ }: {
98
+ boardId?: string;
99
+ includeUnassignedForBoard?: boolean;
100
+ wsId: string;
101
+ }) => (
102
+ <div
103
+ data-board-id={boardId}
104
+ data-include-unassigned={String(includeUnassignedForBoard)}
105
+ data-testid="drafts-view"
106
+ data-ws-id={wsId}
107
+ >
108
+ Drafts
109
+ </div>
110
+ ),
111
+ }));
112
+
113
+ vi.mock('../../my-tasks/my-tasks-content', () => ({
114
+ default: () => <div data-testid="my-tasks-view">My Tasks</div>,
79
115
  }));
80
116
 
81
117
  vi.mock('../../boards/boardId/kanban', () => ({
@@ -170,6 +206,7 @@ function renderBoardViews(overrides?: {
170
206
  board?: Record<string, unknown>;
171
207
  idleBottomIsland?: React.ReactNode;
172
208
  lists?: TaskList[];
209
+ props?: Partial<React.ComponentProps<typeof BoardViews>>;
173
210
  tasks?: Task[];
174
211
  workspace?: { id: string; personal: boolean };
175
212
  }) {
@@ -192,6 +229,7 @@ function renderBoardViews(overrides?: {
192
229
  workspace={(overrides?.workspace ?? mockWorkspace) as any}
193
230
  workspaceLabels={mockWorkspaceLabels}
194
231
  idleBottomIsland={overrides?.idleBottomIsland}
232
+ {...overrides?.props}
195
233
  />
196
234
  </HotkeysProvider>
197
235
  </QueryClientProvider>
@@ -213,7 +251,12 @@ describe('BoardViews', () => {
213
251
  progressivePagination = {};
214
252
  listWorkspaceTasksMock.mockReset();
215
253
  listWorkspaceTasksMock.mockResolvedValue({ tasks: mockTasks });
254
+ getUserWorkspaceConfigMock.mockReset();
255
+ getUserWorkspaceConfigMock.mockResolvedValue({ value: null });
256
+ updateUserWorkspaceConfigMock.mockReset();
257
+ updateUserWorkspaceConfigMock.mockResolvedValue({ message: 'ok' });
216
258
  window.localStorage.clear();
259
+ window.history.replaceState({}, '', '/');
217
260
  });
218
261
 
219
262
  it('registers visible hotkey labels for each board view', () => {
@@ -222,11 +265,71 @@ describe('BoardViews', () => {
222
265
  expect(boardHeaderProps?.viewHotkeyLabels).toEqual({
223
266
  kanban: formatHotkeySequence(['G', 'K']),
224
267
  list: formatHotkeySequence(['G', 'L']),
268
+ my_tasks: formatHotkeySequence(['G', 'M']),
225
269
  timeline: formatHotkeySequence(['G', 'T']),
270
+ drafts: formatHotkeySequence(['G', 'D']),
271
+ recycle_bin: formatHotkeySequence(['G', 'R']),
272
+ });
273
+ });
274
+
275
+ it('exposes drafts and recycle bin as editable board modes by default', () => {
276
+ renderBoardViews();
277
+
278
+ expect(boardHeaderProps?.availableViews).toEqual([
279
+ 'kanban',
280
+ 'list',
281
+ 'my_tasks',
282
+ 'timeline',
283
+ 'drafts',
284
+ 'recycle_bin',
285
+ ]);
286
+ });
287
+
288
+ it('passes explicit read-only public mode through shared board components', () => {
289
+ renderBoardViews({
290
+ props: {
291
+ availableViews: ['kanban', 'list'],
292
+ publicHeaderPrefix: <span data-testid="public-prefix" />,
293
+ publicView: true,
294
+ readOnly: true,
295
+ },
296
+ });
297
+
298
+ expect(boardHeaderProps).toMatchObject({
299
+ availableViews: ['kanban', 'list'],
300
+ publicView: true,
301
+ readOnly: true,
302
+ });
303
+ expect(boardHeaderProps?.titlePrefix).toBeDefined();
304
+ expect(kanbanBoardProps?.readOnly).toBe(true);
305
+ expect(listWorkspaceTasksMock).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it('renders board-scoped drafts and recycle bin views from the header mode switcher', async () => {
309
+ renderBoardViews();
310
+
311
+ await act(async () => {
312
+ boardHeaderProps?.onViewChange('drafts');
313
+ });
314
+
315
+ expect(screen.getByTestId('drafts-view')).toHaveAttribute(
316
+ 'data-board-id',
317
+ 'board-1'
318
+ );
319
+ expect(screen.getByTestId('drafts-view')).toHaveAttribute(
320
+ 'data-include-unassigned',
321
+ 'true'
322
+ );
323
+
324
+ await act(async () => {
325
+ boardHeaderProps?.onViewChange('recycle_bin');
226
326
  });
327
+
328
+ expect(screen.getByTestId('recycle-bin-view')).toBeInTheDocument();
329
+ expect(createTaskMock).not.toHaveBeenCalled();
227
330
  });
228
331
 
229
- it('switches between kanban, list, and timeline using TanStack hotkey sequences', async () => {
332
+ it('switches between all board views using TanStack hotkey sequences', async () => {
230
333
  renderBoardViews();
231
334
 
232
335
  expect(screen.getByTestId('kanban-view')).toBeInTheDocument();
@@ -251,6 +354,43 @@ describe('BoardViews', () => {
251
354
  await waitFor(() => {
252
355
  expect(screen.getByTestId('kanban-view')).toBeInTheDocument();
253
356
  });
357
+
358
+ fireEvent.keyDown(document, { key: 'g' });
359
+ fireEvent.keyDown(document, { key: 'm' });
360
+
361
+ await waitFor(() => {
362
+ expect(screen.getByTestId('my-tasks-view')).toBeInTheDocument();
363
+ });
364
+
365
+ fireEvent.keyDown(document, { key: 'g' });
366
+ fireEvent.keyDown(document, { key: 'd' });
367
+
368
+ await waitFor(() => {
369
+ expect(screen.getByTestId('drafts-view')).toBeInTheDocument();
370
+ });
371
+
372
+ fireEvent.keyDown(document, { key: 'g' });
373
+ fireEvent.keyDown(document, { key: 'r' });
374
+
375
+ await waitFor(() => {
376
+ expect(screen.getByTestId('recycle-bin-view')).toBeInTheDocument();
377
+ });
378
+ });
379
+
380
+ it('saves the selected workspace task view when the header changes view', async () => {
381
+ renderBoardViews();
382
+
383
+ await act(async () => {
384
+ boardHeaderProps?.onViewChange('list');
385
+ });
386
+
387
+ await waitFor(() => {
388
+ expect(updateUserWorkspaceConfigMock).toHaveBeenCalledWith(
389
+ 'ws-1',
390
+ 'TASK_LAST_BOARD_VIEW',
391
+ 'list'
392
+ );
393
+ });
254
394
  });
255
395
 
256
396
  it('toggles the idle bottom island around active kanban bulk selection', async () => {
@@ -508,6 +648,42 @@ describe('BoardViews', () => {
508
648
  });
509
649
  });
510
650
 
651
+ it('persists deadline section collapse state per board and section', async () => {
652
+ window.localStorage.setItem(
653
+ 'task-board-deadline-section-collapsed:board-1:overdue',
654
+ 'true'
655
+ );
656
+
657
+ renderBoardViews();
658
+
659
+ await waitFor(() => {
660
+ expect(kanbanBoardProps?.deadlineSectionsCollapsed).toEqual(
661
+ expect.objectContaining({
662
+ overdue: true,
663
+ upcoming: false,
664
+ })
665
+ );
666
+ });
667
+
668
+ act(() => {
669
+ kanbanBoardProps?.onDeadlineSectionCollapsedChange?.('upcoming', true);
670
+ });
671
+
672
+ await waitFor(() => {
673
+ expect(
674
+ window.localStorage.getItem(
675
+ 'task-board-deadline-section-collapsed:board-1:upcoming'
676
+ )
677
+ ).toBe('true');
678
+ expect(kanbanBoardProps?.deadlineSectionsCollapsed).toEqual(
679
+ expect.objectContaining({
680
+ overdue: true,
681
+ upcoming: true,
682
+ })
683
+ );
684
+ });
685
+ });
686
+
511
687
  it('excludes deleted lists from active board views and create shortcuts', () => {
512
688
  const listsWithDeletedFirst: TaskList[] = [
513
689
  {
@@ -650,6 +826,31 @@ describe('BoardViews', () => {
650
826
  });
651
827
  });
652
828
 
829
+ it('passes board filter criteria into deadline task query options without global sort', async () => {
830
+ renderBoardViews();
831
+
832
+ act(() => {
833
+ boardHeaderProps?.onListStatusFilterChange('active');
834
+ boardHeaderProps?.onFiltersChange({
835
+ ...boardHeaderProps.filters,
836
+ labels: [{ color: 'BLUE', id: 'label-1', name: 'Urgent' } as any],
837
+ searchQuery: 'launch',
838
+ sortBy: 'name-asc',
839
+ });
840
+ });
841
+
842
+ await waitFor(() => {
843
+ expect(kanbanBoardProps?.deadlineTaskQueryOptions).toEqual(
844
+ expect.objectContaining({
845
+ labelIds: ['label-1'],
846
+ listStatuses: ['active'],
847
+ q: 'launch',
848
+ })
849
+ );
850
+ });
851
+ expect(kanbanBoardProps?.deadlineTaskQueryOptions?.sortBy).toBeUndefined();
852
+ });
853
+
653
854
  it('uses server-side search counts to hide task lists without matching tasks', async () => {
654
855
  listWorkspaceTasksMock.mockImplementation(async (_workspaceId, options) => {
655
856
  if (options?.includeListCounts) {
@@ -0,0 +1,17 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { TaskBoardLoadingState } from '../task-board-loading-state';
5
+
6
+ describe('TaskBoardLoadingState', () => {
7
+ it('fills the padded board route with a transparent kanban skeleton', () => {
8
+ render(<TaskBoardLoadingState root />);
9
+
10
+ expect(screen.getByTestId('task-board-loading-state')).toHaveClass(
11
+ '-m-4',
12
+ 'h-[calc(100dvh+2rem)]',
13
+ 'bg-transparent'
14
+ );
15
+ expect(screen.getByTestId('kanban-skeleton')).toHaveClass('bg-transparent');
16
+ });
17
+ });
@@ -65,6 +65,22 @@ describe('TaskLegacyRouteRecovery', () => {
65
65
  });
66
66
  });
67
67
 
68
+ it('uses the board skeleton while resolving the canonical task route', () => {
69
+ mockGetWorkspaceTask.mockReturnValue(new Promise(() => {}));
70
+
71
+ renderWithQueryClient(
72
+ <TaskLegacyRouteRecovery
73
+ routePrefix="/tasks"
74
+ taskId="task-1"
75
+ workspaceId="personal"
76
+ />
77
+ );
78
+
79
+ expect(screen.getByTestId('task-board-loading-state')).toBeInTheDocument();
80
+ expect(screen.getByTestId('kanban-skeleton')).toBeInTheDocument();
81
+ expect(screen.queryByText('loading')).not.toBeInTheDocument();
82
+ });
83
+
68
84
  it('shows a recoverable error state instead of a 404 when resolution fails', async () => {
69
85
  mockGetWorkspaceTask.mockRejectedValue(new Error('boom'));
70
86
 
@@ -24,6 +24,7 @@ import {
24
24
  import { BoardViews } from './board-views';
25
25
  import { ProgressiveLoaderProvider } from './progressive-loader-context';
26
26
  import { dispatchRecentSidebarVisit } from './recent-sidebar-events';
27
+ import { TaskBoardLoadingState } from './task-board-loading-state';
27
28
  import { useProgressiveBoardLoader } from './use-progressive-board-loader';
28
29
 
29
30
  const BOARD_REVALIDATE_COOLDOWN_MS = 30_000;
@@ -237,13 +238,7 @@ export function BoardClient({
237
238
  ]);
238
239
 
239
240
  if (boardLoading && !board) {
240
- return (
241
- <div className="flex flex-col">
242
- <div className="p-4 text-center text-muted-foreground">
243
- Loading board...
244
- </div>
245
- </div>
246
- );
241
+ return <TaskBoardLoadingState />;
247
242
  }
248
243
 
249
244
  if (!board?.id) {
@@ -2,7 +2,13 @@
2
2
 
3
3
  import type { TaskFilters } from '../boards/boardId/task-filter';
4
4
 
5
- export type StoredBoardView = 'kanban' | 'list' | 'timeline';
5
+ export type StoredBoardView =
6
+ | 'kanban'
7
+ | 'list'
8
+ | 'my_tasks'
9
+ | 'timeline'
10
+ | 'drafts'
11
+ | 'recycle_bin';
6
12
  export type StoredListStatusFilter = 'all' | 'active' | 'not_started';
7
13
 
8
14
  export interface BoardViewConfig {