@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,380 @@
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 * as React from 'react';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { KanbanPlannerDialog } from '../kanban-planner-dialog';
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ addWorkspaceTaskPlanWorkspace: vi.fn(),
10
+ createWorkspaceTaskPlan: vi.fn(),
11
+ createWorkspaceTaskPlanItem: vi.fn(),
12
+ createWorkspaceTaskPlanShare: vi.fn(),
13
+ getWorkspaceTaskPlanDigest: vi.fn(),
14
+ listWorkspaceBoardsWithLists: vi.fn(),
15
+ listWorkspaceTaskPlans: vi.fn(),
16
+ listWorkspaces: vi.fn(),
17
+ updateWorkspaceTaskPlan: vi.fn(),
18
+ }));
19
+
20
+ vi.mock('next-intl', () => ({
21
+ useTranslations: () => (key: string, values?: Record<string, string>) =>
22
+ values?.name ? `${key} ${values.name}` : key,
23
+ }));
24
+
25
+ vi.mock('@tuturuuu/ui/sonner', () => ({
26
+ toast: { error: vi.fn(), success: vi.fn() },
27
+ }));
28
+
29
+ vi.mock('@tuturuuu/ui/collapsible', () => ({
30
+ Collapsible: ({ children }: { children: React.ReactNode }) => (
31
+ <div>{children}</div>
32
+ ),
33
+ CollapsibleContent: ({ children }: { children: React.ReactNode }) => (
34
+ <div>{children}</div>
35
+ ),
36
+ CollapsibleTrigger: ({ children }: { children: React.ReactNode }) =>
37
+ React.isValidElement(children)
38
+ ? React.cloneElement(
39
+ children as React.ReactElement<{ 'aria-expanded'?: string }>,
40
+ { 'aria-expanded': 'false' }
41
+ )
42
+ : children,
43
+ }));
44
+
45
+ vi.mock('@tuturuuu/ui/custom/combobox', () => ({
46
+ Combobox: ({
47
+ disabled,
48
+ mode = 'single',
49
+ onChange,
50
+ options,
51
+ placeholder,
52
+ selected,
53
+ }: {
54
+ disabled?: boolean;
55
+ mode?: 'single' | 'multiple';
56
+ onChange?: (value: string | string[]) => void;
57
+ options: { label: string; value: string }[];
58
+ placeholder?: string;
59
+ selected: string | string[];
60
+ }) => (
61
+ <select
62
+ aria-label={placeholder}
63
+ disabled={disabled}
64
+ multiple={mode === 'multiple'}
65
+ value={selected}
66
+ onChange={(event) => {
67
+ if (mode === 'multiple') {
68
+ onChange?.(
69
+ Array.from(event.currentTarget.selectedOptions).map(
70
+ (option) => option.value
71
+ )
72
+ );
73
+ return;
74
+ }
75
+
76
+ onChange?.(event.target.value);
77
+ }}
78
+ >
79
+ {options.map((option) => (
80
+ <option key={option.value} value={option.value}>
81
+ {option.label}
82
+ </option>
83
+ ))}
84
+ </select>
85
+ ),
86
+ }));
87
+
88
+ vi.mock('@tuturuuu/ui/dialog', () => ({
89
+ Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
90
+ open ? <div role="dialog">{children}</div> : null,
91
+ DialogContent: ({ children }: { children: React.ReactNode }) => (
92
+ <>{children}</>
93
+ ),
94
+ DialogDescription: ({ children }: { children: React.ReactNode }) => (
95
+ <p>{children}</p>
96
+ ),
97
+ DialogFooter: ({ children }: { children: React.ReactNode }) => (
98
+ <>{children}</>
99
+ ),
100
+ DialogHeader: ({ children }: { children: React.ReactNode }) => (
101
+ <>{children}</>
102
+ ),
103
+ DialogTitle: ({ children }: { children: React.ReactNode }) => (
104
+ <h2>{children}</h2>
105
+ ),
106
+ }));
107
+
108
+ vi.mock('@tuturuuu/internal-api', async () => {
109
+ const actual = await vi.importActual<typeof import('@tuturuuu/internal-api')>(
110
+ '@tuturuuu/internal-api'
111
+ );
112
+ return {
113
+ ...actual,
114
+ addWorkspaceTaskPlanWorkspace: (...args: unknown[]) =>
115
+ mocks.addWorkspaceTaskPlanWorkspace(...args),
116
+ createWorkspaceTaskPlan: (...args: unknown[]) =>
117
+ mocks.createWorkspaceTaskPlan(...args),
118
+ createWorkspaceTaskPlanItem: (...args: unknown[]) =>
119
+ mocks.createWorkspaceTaskPlanItem(...args),
120
+ createWorkspaceTaskPlanShare: (...args: unknown[]) =>
121
+ mocks.createWorkspaceTaskPlanShare(...args),
122
+ getWorkspaceTaskPlanDigest: (...args: unknown[]) =>
123
+ mocks.getWorkspaceTaskPlanDigest(...args),
124
+ listWorkspaceTaskPlans: (...args: unknown[]) =>
125
+ mocks.listWorkspaceTaskPlans(...args),
126
+ listWorkspaceBoardsWithLists: (...args: unknown[]) =>
127
+ mocks.listWorkspaceBoardsWithLists(...args),
128
+ listWorkspaces: (...args: unknown[]) => mocks.listWorkspaces(...args),
129
+ updateWorkspaceTaskPlan: (...args: unknown[]) =>
130
+ mocks.updateWorkspaceTaskPlan(...args),
131
+ };
132
+ });
133
+
134
+ const basePlan = {
135
+ id: 'plan-1',
136
+ owner_id: 'user-1',
137
+ personal_ws_id: 'ws-personal',
138
+ title: 'Launch plan',
139
+ period_type: 'week' as const,
140
+ period_start: '2026-06-22',
141
+ period_end: '2026-06-28',
142
+ timezone: 'UTC',
143
+ status: 'draft' as const,
144
+ default_target_ws_id: 'team-ws',
145
+ default_target_board_id: 'board-1',
146
+ default_target_list_id: 'list-1',
147
+ created_at: '2026-06-22T00:00:00.000Z',
148
+ updated_at: '2026-06-22T00:00:00.000Z',
149
+ archived_at: null,
150
+ workspaces: [
151
+ { plan_id: 'plan-1', ws_id: 'ws-personal' },
152
+ { plan_id: 'plan-1', ws_id: 'team-ws' },
153
+ ],
154
+ shares: [
155
+ {
156
+ id: 'share-1',
157
+ plan_id: 'plan-1',
158
+ shared_with_ws_id: 'team-ws',
159
+ shared_with_user_id: null,
160
+ shared_with_email: null,
161
+ permission: 'view' as const,
162
+ shared_by_user_id: 'user-1',
163
+ created_at: '2026-06-22T00:00:00.000Z',
164
+ updated_at: '2026-06-22T00:00:00.000Z',
165
+ },
166
+ ],
167
+ items: [
168
+ {
169
+ id: 'item-1',
170
+ plan_id: 'plan-1',
171
+ task_id: 'task-1',
172
+ target_ws_id: 'team-ws',
173
+ target_board_id: 'board-1',
174
+ target_list_id: 'list-1',
175
+ planned_start: '2026-06-23',
176
+ planned_end: null,
177
+ sort_key: 1,
178
+ status: 'planned' as const,
179
+ notes: 'Local timing override',
180
+ snapshot_title: null,
181
+ created_by_user_id: 'user-1',
182
+ created_at: '2026-06-22T00:00:00.000Z',
183
+ updated_at: '2026-06-22T00:00:00.000Z',
184
+ task: { id: 'task-1', name: 'Team task' },
185
+ },
186
+ ],
187
+ };
188
+
189
+ function renderPlanner() {
190
+ const queryClient = new QueryClient({
191
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
192
+ });
193
+
194
+ return render(
195
+ <QueryClientProvider client={queryClient}>
196
+ <KanbanPlannerDialog
197
+ boardId="board-1"
198
+ isPersonalWorkspace
199
+ onOpenChange={vi.fn()}
200
+ open
201
+ workspaceId="ws-personal"
202
+ />
203
+ </QueryClientProvider>
204
+ );
205
+ }
206
+
207
+ describe('KanbanPlannerDialog', () => {
208
+ beforeEach(() => {
209
+ for (const mock of Object.values(mocks)) mock.mockReset();
210
+ mocks.listWorkspaces.mockResolvedValue([
211
+ { id: 'ws-personal', name: 'Personal', personal: true },
212
+ { id: 'team-ws', name: 'Team', personal: false },
213
+ ]);
214
+ mocks.listWorkspaceBoardsWithLists.mockResolvedValue({
215
+ boards: [
216
+ {
217
+ id: 'board-1',
218
+ name: 'Team board',
219
+ task_lists: [{ id: 'list-1', name: 'Todo' }],
220
+ },
221
+ ],
222
+ });
223
+ });
224
+
225
+ it('renders a compact disabled state when the plan schema is unavailable', async () => {
226
+ mocks.listWorkspaceTaskPlans.mockResolvedValue({
227
+ ok: false,
228
+ code: 'schema_unavailable',
229
+ schemaAvailable: false,
230
+ message: 'Migration pending',
231
+ });
232
+
233
+ renderPlanner();
234
+
235
+ expect(await screen.findByText('schema_unavailable')).toBeInTheDocument();
236
+ });
237
+
238
+ it('renders browse-first planner sections with create and edit separated', async () => {
239
+ mocks.listWorkspaceTaskPlans.mockResolvedValue({
240
+ ok: true,
241
+ schemaAvailable: true,
242
+ plans: [basePlan],
243
+ });
244
+
245
+ renderPlanner();
246
+
247
+ await waitFor(() => {
248
+ expect(mocks.listWorkspaceTaskPlans).toHaveBeenCalledWith('ws-personal');
249
+ });
250
+
251
+ for (const sectionName of [
252
+ 'plan_browser',
253
+ 'new_plan',
254
+ 'edit_plan',
255
+ 'target_workspace',
256
+ 'planned_tasks',
257
+ 'digest',
258
+ 'share_plan',
259
+ ]) {
260
+ const sectionButton = screen
261
+ .getAllByRole('button', { name: new RegExp(sectionName) })
262
+ .find((button) => button.getAttribute('aria-expanded') === 'false');
263
+
264
+ expect(sectionButton).toBeDefined();
265
+ }
266
+
267
+ expect(await screen.findByText('Launch plan')).toBeInTheDocument();
268
+ });
269
+
270
+ it('switches planner mode and creates a monthly plan', async () => {
271
+ mocks.listWorkspaceTaskPlans.mockResolvedValue({
272
+ ok: true,
273
+ schemaAvailable: true,
274
+ plans: [],
275
+ });
276
+ mocks.createWorkspaceTaskPlan.mockResolvedValue({
277
+ ok: true,
278
+ schemaAvailable: true,
279
+ plan: basePlan,
280
+ });
281
+
282
+ renderPlanner();
283
+
284
+ await screen.findByLabelText('mode_week');
285
+
286
+ fireEvent.change(screen.getByLabelText('mode_week'), {
287
+ target: { value: 'month' },
288
+ });
289
+ fireEvent.change(screen.getByPlaceholderText('plan_title_placeholder'), {
290
+ target: { value: 'Monthly roadmap' },
291
+ });
292
+ fireEvent.click(screen.getAllByText('create_plan').at(-1)!);
293
+
294
+ await waitFor(() => {
295
+ expect(mocks.createWorkspaceTaskPlan).toHaveBeenCalledWith(
296
+ 'ws-personal',
297
+ expect.objectContaining({
298
+ period_type: 'month',
299
+ title: 'Monthly roadmap',
300
+ })
301
+ );
302
+ });
303
+ });
304
+
305
+ it('shows source labels and shares a selected plan by email', async () => {
306
+ mocks.listWorkspaceTaskPlans.mockResolvedValue({
307
+ ok: true,
308
+ schemaAvailable: true,
309
+ plans: [basePlan],
310
+ });
311
+ mocks.createWorkspaceTaskPlanShare.mockResolvedValue({
312
+ ok: true,
313
+ schemaAvailable: true,
314
+ share: basePlan.shares[0],
315
+ });
316
+
317
+ renderPlanner();
318
+
319
+ expect(await screen.findByText('scope_team_source')).toBeInTheDocument();
320
+ expect(screen.getByText('scope_external_workspace')).toBeInTheDocument();
321
+ expect(screen.getByText('scope_my_override')).toBeInTheDocument();
322
+
323
+ fireEvent.change(screen.getByPlaceholderText('share_email_placeholder'), {
324
+ target: { value: 'lead@example.com' },
325
+ });
326
+ fireEvent.click(screen.getByText('share_email'));
327
+
328
+ await waitFor(() => {
329
+ expect(mocks.createWorkspaceTaskPlanShare).toHaveBeenCalledWith(
330
+ 'ws-personal',
331
+ 'plan-1',
332
+ { shared_with_email: 'lead@example.com', permission: 'view' }
333
+ );
334
+ });
335
+ });
336
+
337
+ it('edits a selected plan without using the create panel', async () => {
338
+ mocks.listWorkspaceTaskPlans.mockResolvedValue({
339
+ ok: true,
340
+ schemaAvailable: true,
341
+ plans: [basePlan],
342
+ });
343
+ mocks.updateWorkspaceTaskPlan.mockResolvedValue({
344
+ ok: true,
345
+ schemaAvailable: true,
346
+ plan: {
347
+ ...basePlan,
348
+ title: 'Updated launch plan',
349
+ },
350
+ });
351
+
352
+ renderPlanner();
353
+
354
+ await screen.findByDisplayValue('Launch plan');
355
+ const editTitle = screen
356
+ .getAllByPlaceholderText('plan_title_placeholder')
357
+ .find(
358
+ (element) => (element as HTMLInputElement).value === 'Launch plan'
359
+ )!;
360
+
361
+ fireEvent.change(editTitle, {
362
+ target: { value: 'Updated launch plan' },
363
+ });
364
+ fireEvent.change(screen.getAllByLabelText('mode_week').at(-1)!, {
365
+ target: { value: 'month' },
366
+ });
367
+ fireEvent.click(screen.getByText('save_plan'));
368
+
369
+ await waitFor(() => {
370
+ expect(mocks.updateWorkspaceTaskPlan).toHaveBeenCalledWith(
371
+ 'ws-personal',
372
+ 'plan-1',
373
+ expect.objectContaining({
374
+ period_type: 'month',
375
+ title: 'Updated launch plan',
376
+ })
377
+ );
378
+ });
379
+ });
380
+ });
@@ -0,0 +1,204 @@
1
+ 'use client';
2
+
3
+ import { CalendarDays } from '@tuturuuu/icons';
4
+ import { Badge } from '@tuturuuu/ui/badge';
5
+ import { Button } from '@tuturuuu/ui/button';
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from '@tuturuuu/ui/dialog';
14
+ import { useTranslations } from 'next-intl';
15
+ import { PlannerDigestPanel } from './planner-digest-panel';
16
+ import { PlannerItemStrip } from './planner-item-strip';
17
+ import {
18
+ PlannerCreatePlanPanel,
19
+ PlannerEditPlanPanel,
20
+ PlannerPlanBrowser,
21
+ } from './planner-plan-toolbar';
22
+ import { PlannerSection } from './planner-section';
23
+ import { PlannerSharePanel } from './planner-share-dialog';
24
+ import { PlannerTargetControls } from './planner-target-controls';
25
+ import { useKanbanPlannerState } from './use-kanban-planner-state';
26
+
27
+ interface KanbanPlannerDialogProps {
28
+ boardId: string | null;
29
+ isPersonalWorkspace: boolean;
30
+ onOpenChange: (open: boolean) => void;
31
+ open: boolean;
32
+ workspaceId: string;
33
+ }
34
+
35
+ export function KanbanPlannerDialog({
36
+ boardId,
37
+ isPersonalWorkspace,
38
+ onOpenChange,
39
+ open,
40
+ workspaceId,
41
+ }: KanbanPlannerDialogProps) {
42
+ const t = useTranslations('ws-task-plans');
43
+ const tCommon = useTranslations('common');
44
+ const planner = useKanbanPlannerState({
45
+ boardId,
46
+ enabled: open,
47
+ isPersonalWorkspace,
48
+ workspaceId,
49
+ });
50
+
51
+ if (!isPersonalWorkspace) return null;
52
+
53
+ const planCount = planner.plans.length;
54
+ const itemCount = planner.selectedPlan?.items?.length ?? 0;
55
+ const shareCount = planner.selectedPlan?.shares?.length ?? 0;
56
+ const hasSelectedPlan = Boolean(planner.selectedPlan);
57
+
58
+ return (
59
+ <Dialog open={open} onOpenChange={onOpenChange}>
60
+ <DialogContent className="max-h-[min(88dvh,720px)] overflow-y-auto sm:max-w-3xl">
61
+ <DialogHeader>
62
+ <DialogTitle className="flex items-center gap-2">
63
+ <CalendarDays className="h-5 w-5" />
64
+ {t('planner')}
65
+ </DialogTitle>
66
+ <DialogDescription className="sr-only">
67
+ {t('planner')}
68
+ </DialogDescription>
69
+ </DialogHeader>
70
+
71
+ {planner.schemaUnavailable ? (
72
+ <div className="rounded-md border border-dashed p-3 text-muted-foreground text-sm">
73
+ {t('schema_unavailable')}
74
+ </div>
75
+ ) : (
76
+ <div className="space-y-2">
77
+ <PlannerSection
78
+ title={t('plan_browser')}
79
+ defaultOpen
80
+ badge={
81
+ <Badge variant="secondary">
82
+ {planner.plansQuery.isLoading ? t('loading') : planCount}
83
+ </Badge>
84
+ }
85
+ >
86
+ <PlannerPlanBrowser
87
+ onSelectedPlanChange={planner.setSelectedPlanId}
88
+ plans={planner.plans}
89
+ plansLoading={planner.plansQuery.isLoading}
90
+ selectedPlan={planner.selectedPlan}
91
+ />
92
+ </PlannerSection>
93
+
94
+ <PlannerSection title={t('new_plan')}>
95
+ <PlannerCreatePlanPanel
96
+ createPending={planner.createPlanMutation.isPending}
97
+ mode={planner.mode}
98
+ onCreatePlan={() => planner.createPlanMutation.mutate()}
99
+ onModeChange={planner.setMode}
100
+ onPlanTitleChange={planner.setPlanTitle}
101
+ planTitle={planner.planTitle}
102
+ />
103
+ </PlannerSection>
104
+
105
+ <PlannerSection title={t('edit_plan')} disabled={!hasSelectedPlan}>
106
+ {planner.selectedPlan && (
107
+ <PlannerEditPlanPanel
108
+ editMode={planner.editMode}
109
+ editStatus={planner.editStatus}
110
+ editTitle={planner.editTitle}
111
+ onEditModeChange={planner.setEditMode}
112
+ onEditStatusChange={planner.setEditStatus}
113
+ onEditTitleChange={planner.setEditTitle}
114
+ onSavePlan={() => planner.updatePlanMutation.mutate()}
115
+ savePending={planner.updatePlanMutation.isPending}
116
+ />
117
+ )}
118
+ </PlannerSection>
119
+
120
+ <PlannerSection
121
+ title={t('target_workspace')}
122
+ disabled={!hasSelectedPlan}
123
+ badge={
124
+ hasSelectedPlan && planner.targetIsIntended ? (
125
+ <Badge variant="secondary">{t('intended_workspace')}</Badge>
126
+ ) : null
127
+ }
128
+ >
129
+ {planner.selectedPlan && (
130
+ <PlannerTargetControls
131
+ addWorkspacePending={planner.addWorkspaceMutation.isPending}
132
+ boards={planner.boards}
133
+ createItemPending={planner.createItemMutation.isPending}
134
+ lists={planner.lists}
135
+ onAddWorkspace={() => planner.addWorkspaceMutation.mutate()}
136
+ onCreateItem={() => planner.createItemMutation.mutate()}
137
+ onPlannedDateChange={planner.setPlannedDate}
138
+ onTargetBoardChange={planner.setTargetBoardId}
139
+ onTargetListChange={planner.setTargetListId}
140
+ onTargetWorkspaceChange={planner.setTargetWorkspaceId}
141
+ onTaskTitleChange={planner.setTaskTitle}
142
+ plannedDate={planner.plannedDate}
143
+ targetBoardId={planner.targetBoardId}
144
+ targetIsIntended={planner.targetIsIntended}
145
+ targetListId={planner.targetListId}
146
+ targetWorkspaceId={planner.targetWorkspaceId}
147
+ taskTitle={planner.taskTitle}
148
+ workspaces={planner.workspaces}
149
+ />
150
+ )}
151
+ </PlannerSection>
152
+
153
+ <PlannerSection
154
+ title={t('planned_tasks')}
155
+ disabled={!hasSelectedPlan}
156
+ badge={<Badge variant="secondary">{itemCount}</Badge>}
157
+ >
158
+ {planner.selectedPlan && (
159
+ <PlannerItemStrip
160
+ personalWorkspaceId={workspaceId}
161
+ plan={planner.selectedPlan}
162
+ plansLoading={planner.plansQuery.isLoading}
163
+ />
164
+ )}
165
+ </PlannerSection>
166
+
167
+ <PlannerSection title={t('digest')} disabled={!hasSelectedPlan}>
168
+ {planner.selectedPlan && (
169
+ <PlannerDigestPanel
170
+ plan={planner.selectedPlan}
171
+ workspaceId={workspaceId}
172
+ />
173
+ )}
174
+ </PlannerSection>
175
+
176
+ <PlannerSection
177
+ title={t('share_plan')}
178
+ disabled={!hasSelectedPlan}
179
+ badge={<Badge variant="secondary">{shareCount}</Badge>}
180
+ >
181
+ {planner.selectedPlan && (
182
+ <PlannerSharePanel
183
+ onShared={planner.invalidatePlans}
184
+ plan={planner.selectedPlan}
185
+ workspaceId={workspaceId}
186
+ targetWorkspaceId={
187
+ planner.targetIsIntended ? planner.targetWorkspaceId : null
188
+ }
189
+ targetWorkspaceName={planner.targetWorkspace?.name ?? null}
190
+ />
191
+ )}
192
+ </PlannerSection>
193
+ </div>
194
+ )}
195
+
196
+ <DialogFooter>
197
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
198
+ {tCommon('close')}
199
+ </Button>
200
+ </DialogFooter>
201
+ </DialogContent>
202
+ </Dialog>
203
+ );
204
+ }
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+
3
+ import { useMutation } from '@tanstack/react-query';
4
+ import { ClipboardList, Loader2 } from '@tuturuuu/icons';
5
+ import {
6
+ getWorkspaceTaskPlanDigest,
7
+ isTaskPlanSchemaUnavailable,
8
+ type TaskPlan,
9
+ } from '@tuturuuu/internal-api';
10
+ import { Button } from '@tuturuuu/ui/button';
11
+ import { toast } from '@tuturuuu/ui/sonner';
12
+ import { Textarea } from '@tuturuuu/ui/textarea';
13
+ import { useTranslations } from 'next-intl';
14
+
15
+ interface PlannerDigestPanelProps {
16
+ plan: TaskPlan;
17
+ workspaceId: string;
18
+ }
19
+
20
+ export function PlannerDigestPanel({
21
+ plan,
22
+ workspaceId,
23
+ }: PlannerDigestPanelProps) {
24
+ const t = useTranslations('ws-task-plans');
25
+ const tCommon = useTranslations('common');
26
+
27
+ const digestMutation = useMutation({
28
+ mutationFn: () => getWorkspaceTaskPlanDigest(workspaceId, plan.id),
29
+ onError: () => toast.error(tCommon('error')),
30
+ });
31
+ const digest = digestMutation.data;
32
+
33
+ return (
34
+ <div className="min-w-0 space-y-2">
35
+ <Button
36
+ type="button"
37
+ variant="outline"
38
+ size="sm"
39
+ onClick={() => digestMutation.mutate()}
40
+ disabled={digestMutation.isPending}
41
+ className="h-8 gap-2"
42
+ >
43
+ {digestMutation.isPending ? (
44
+ <Loader2 className="h-4 w-4 animate-spin" />
45
+ ) : (
46
+ <ClipboardList className="h-4 w-4" />
47
+ )}
48
+ {t('generate_digest')}
49
+ </Button>
50
+
51
+ {digest && !isTaskPlanSchemaUnavailable(digest) && (
52
+ <Textarea
53
+ readOnly
54
+ value={digest.digest}
55
+ className="h-28 resize-none text-xs"
56
+ aria-label={t('digest')}
57
+ />
58
+ )}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import type { TaskPlan } from '@tuturuuu/internal-api';
4
+ import { Badge } from '@tuturuuu/ui/badge';
5
+ import { useTranslations } from 'next-intl';
6
+ import { PlannerScopeBadge } from './planner-scope-badge';
7
+
8
+ interface PlannerItemStripProps {
9
+ personalWorkspaceId: string;
10
+ plan: TaskPlan;
11
+ plansLoading: boolean;
12
+ }
13
+
14
+ export function PlannerItemStrip({
15
+ personalWorkspaceId,
16
+ plan,
17
+ plansLoading,
18
+ }: PlannerItemStripProps) {
19
+ const t = useTranslations('ws-task-plans');
20
+ const items = plan.items ?? [];
21
+
22
+ return (
23
+ <div className="flex min-h-20 gap-2 overflow-x-auto">
24
+ {items.length === 0 ? (
25
+ <div className="flex min-h-16 items-center rounded-md border border-dashed px-3 text-muted-foreground text-sm">
26
+ {plansLoading ? t('loading') : t('no_items')}
27
+ </div>
28
+ ) : (
29
+ items.map((item) => (
30
+ <div
31
+ key={item.id}
32
+ className="min-w-56 max-w-72 rounded-md border bg-card p-2 text-sm"
33
+ >
34
+ <div className="truncate font-medium">
35
+ {item.task?.name ?? item.snapshot_title ?? t('untitled_task')}
36
+ </div>
37
+ <div className="mt-2 flex flex-wrap gap-1">
38
+ {item.task_id ? (
39
+ <Badge variant="outline">{t('scope_team_source')}</Badge>
40
+ ) : null}
41
+ <PlannerScopeBadge
42
+ item={item}
43
+ personalWorkspaceId={personalWorkspaceId}
44
+ />
45
+ {item.notes ? (
46
+ <Badge variant="secondary">{t('scope_my_override')}</Badge>
47
+ ) : null}
48
+ </div>
49
+ </div>
50
+ ))
51
+ )}
52
+ </div>
53
+ );
54
+ }