@tuturuuu/ui 0.6.2 → 0.8.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 (108) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/biome.json +1 -1
  3. package/package.json +11 -11
  4. package/src/components/ui/calendar-app/components/calendar-connections.tsx +17 -13
  5. package/src/components/ui/calendar-app/components/connected-accounts-dialog.tsx +2 -5
  6. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +2 -5
  7. package/src/components/ui/calendar.test.tsx +24 -0
  8. package/src/components/ui/calendar.tsx +1 -0
  9. package/src/components/ui/currency-input.test.tsx +43 -0
  10. package/src/components/ui/currency-input.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  12. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  13. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  14. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  15. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  16. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  17. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  18. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  19. package/src/components/ui/date-time-picker.tsx +352 -234
  20. package/src/components/ui/finance/categories-tags-tabs.tsx +23 -1
  21. package/src/components/ui/finance/command/finance-command-actions.test.tsx +48 -0
  22. package/src/components/ui/finance/command/finance-command-actions.tsx +200 -0
  23. package/src/components/ui/finance/command/finance-command-provider.test.tsx +151 -0
  24. package/src/components/ui/finance/command/finance-command-provider.tsx +250 -0
  25. package/src/components/ui/finance/command/finance-command-results.tsx +262 -0
  26. package/src/components/ui/finance/invoices/pending-invoices-table.tsx +22 -9
  27. package/src/components/ui/finance/shared/quick-actions.tsx +39 -90
  28. package/src/components/ui/finance/tags/tag-manager.tsx +24 -5
  29. package/src/components/ui/finance/transactions/form-basic-tab.tsx +33 -49
  30. package/src/components/ui/finance/transactions/form-types.ts +5 -0
  31. package/src/components/ui/finance/transactions/form.test.tsx +105 -22
  32. package/src/components/ui/finance/transactions/form.tsx +116 -20
  33. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +13 -6
  34. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  35. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +25 -1
  36. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +16 -3
  37. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +3 -0
  38. package/src/components/ui/finance/transactions/transactionId/transaction-details-page.tsx +3 -0
  39. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +6 -0
  40. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +20 -2
  41. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -0
  42. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +7 -2
  43. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +7 -2
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +38 -1
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +5 -0
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +18 -2
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +3 -0
  48. package/src/components/ui/finance/wallets/wallets-page.tsx +3 -0
  49. package/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx +2 -9
  50. package/src/components/ui/money-input.test.tsx +64 -0
  51. package/src/components/ui/money-input.tsx +63 -0
  52. package/src/components/ui/optional-time-picker.tsx +95 -0
  53. package/src/components/ui/quick-command-center.test.tsx +90 -0
  54. package/src/components/ui/quick-command-center.tsx +190 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +126 -50
  56. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +23 -20
  58. package/src/components/ui/storefront/image-panel.tsx +6 -0
  59. package/src/components/ui/storefront/index.ts +11 -0
  60. package/src/components/ui/storefront/listing-card.tsx +84 -22
  61. package/src/components/ui/storefront/product-detail.tsx +289 -0
  62. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  63. package/src/components/ui/storefront/storefront-surface.test.tsx +132 -5
  64. package/src/components/ui/storefront/storefront-surface.tsx +371 -128
  65. package/src/components/ui/storefront/types.ts +25 -1
  66. package/src/components/ui/storefront/utils.ts +118 -13
  67. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  68. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  69. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  70. package/src/components/ui/text-editor/content-migration.ts +41 -18
  71. package/src/components/ui/text-editor/extensions.ts +1 -1
  72. package/src/components/ui/text-editor/image-extension.ts +40 -18
  73. package/src/components/ui/text-editor/video-extension.ts +11 -2
  74. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  75. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  76. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  77. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  78. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  79. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  80. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  81. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  82. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  83. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  84. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  85. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  86. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  87. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  88. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  89. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  90. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  91. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  92. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  93. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  94. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  95. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.test.ts +171 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +200 -36
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +21 -2
  102. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  103. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  104. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  105. package/src/hooks/useBoardRealtime.ts +6 -3
  106. package/src/hooks/useBoardRealtime.types.ts +11 -0
  107. package/src/hooks/useCursorTracking.ts +91 -27
  108. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveTaskDialogOpeningPresentation } from './task-dialog-presentation';
3
+
4
+ describe('resolveTaskDialogOpeningPresentation', () => {
5
+ it('opens existing document-list tasks fullscreen', () => {
6
+ expect(
7
+ resolveTaskDialogOpeningPresentation({
8
+ defaultPresentation: 'compact',
9
+ mode: 'edit',
10
+ selectedListStatus: 'documents',
11
+ })
12
+ ).toBe('fullscreen');
13
+ });
14
+
15
+ it('keeps create mode compact even in document lists', () => {
16
+ expect(
17
+ resolveTaskDialogOpeningPresentation({
18
+ defaultPresentation: 'fullscreen',
19
+ mode: 'create',
20
+ selectedListStatus: 'documents',
21
+ })
22
+ ).toBe('compact');
23
+ });
24
+
25
+ it('respects the user default for existing non-document tasks', () => {
26
+ expect(
27
+ resolveTaskDialogOpeningPresentation({
28
+ defaultPresentation: 'compact',
29
+ mode: 'edit',
30
+ selectedListStatus: 'active',
31
+ })
32
+ ).toBe('compact');
33
+
34
+ expect(
35
+ resolveTaskDialogOpeningPresentation({
36
+ defaultPresentation: 'fullscreen',
37
+ mode: 'edit',
38
+ selectedListStatus: 'not_started',
39
+ })
40
+ ).toBe('fullscreen');
41
+ });
42
+
43
+ it('keeps drafts fullscreen', () => {
44
+ expect(
45
+ resolveTaskDialogOpeningPresentation({
46
+ defaultPresentation: 'compact',
47
+ draftId: 'draft-1',
48
+ mode: 'create',
49
+ selectedListStatus: 'documents',
50
+ })
51
+ ).toBe('fullscreen');
52
+ });
53
+ });
@@ -2,6 +2,7 @@ export const TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID =
2
2
  'TASK_DIALOG_DEFAULT_PRESENTATION';
3
3
 
4
4
  export type TaskDialogPresentation = 'compact' | 'fullscreen';
5
+ export type TaskDialogMode = 'edit' | 'create';
5
6
 
6
7
  export function normalizeTaskDialogPresentation(
7
8
  value: unknown,
@@ -9,3 +10,21 @@ export function normalizeTaskDialogPresentation(
9
10
  ): TaskDialogPresentation {
10
11
  return value === 'fullscreen' || value === 'compact' ? value : fallback;
11
12
  }
13
+
14
+ export function resolveTaskDialogOpeningPresentation({
15
+ defaultPresentation,
16
+ draftId,
17
+ mode = 'edit',
18
+ selectedListStatus,
19
+ }: {
20
+ defaultPresentation?: unknown;
21
+ draftId?: string;
22
+ mode?: TaskDialogMode;
23
+ selectedListStatus?: string | null;
24
+ }): TaskDialogPresentation {
25
+ if (draftId) return 'fullscreen';
26
+ if (mode === 'create') return 'compact';
27
+ if (selectedListStatus === 'documents') return 'fullscreen';
28
+
29
+ return normalizeTaskDialogPresentation(defaultPresentation);
30
+ }
@@ -178,6 +178,63 @@ describe('CompactTaskCreatePopover', () => {
178
178
  ).not.toBeInTheDocument();
179
179
  });
180
180
 
181
+ it('renders compact description preview without affecting panel layout', () => {
182
+ const onDescriptionPreviewClick = vi.fn();
183
+
184
+ render(
185
+ <Dialog open={true}>
186
+ <CompactTaskCreatePopover
187
+ title="Edit task"
188
+ titleInput={<input aria-label="Task title" defaultValue="Existing" />}
189
+ propertyControls={
190
+ <button type="button" aria-label="List: Inbox">
191
+ List
192
+ </button>
193
+ }
194
+ descriptionPreview="Confirm the plan and publish the final notes."
195
+ descriptionPreviewLabel="Open full task"
196
+ onDescriptionPreviewClick={onDescriptionPreviewClick}
197
+ onClose={vi.fn()}
198
+ onFullscreen={vi.fn()}
199
+ />
200
+ </Dialog>
201
+ );
202
+
203
+ const preview = screen.getByTestId('compact-task-description-preview');
204
+
205
+ expect(preview).toHaveTextContent(
206
+ 'Confirm the plan and publish the final notes.'
207
+ );
208
+ expect(preview).toHaveAttribute('aria-label', 'Open full task');
209
+ expect(preview).toHaveClass('absolute', 'top-full');
210
+
211
+ fireEvent.click(preview);
212
+
213
+ expect(onDescriptionPreviewClick).toHaveBeenCalledTimes(1);
214
+ });
215
+
216
+ it('omits compact description preview when the caller does not provide one', () => {
217
+ render(
218
+ <Dialog open={true}>
219
+ <CompactTaskCreatePopover
220
+ title="Create task"
221
+ titleInput={<input aria-label="Task title" defaultValue="New" />}
222
+ propertyControls={
223
+ <button type="button" aria-label="List: Inbox">
224
+ List
225
+ </button>
226
+ }
227
+ onClose={vi.fn()}
228
+ onFullscreen={vi.fn()}
229
+ />
230
+ </Dialog>
231
+ );
232
+
233
+ expect(
234
+ screen.queryByTestId('compact-task-description-preview')
235
+ ).not.toBeInTheDocument();
236
+ });
237
+
181
238
  it('renders compact edit actions when provided', () => {
182
239
  const onDelete = vi.fn();
183
240
  const onDone = vi.fn();
@@ -25,6 +25,8 @@ interface CompactTaskDialogPanelProps {
25
25
  iconRingClass?: string;
26
26
  titleInput: ReactNode;
27
27
  showHeaderTitle?: boolean;
28
+ descriptionPreview?: string | null;
29
+ descriptionPreviewLabel?: string;
28
30
  taskStatus?: ReactNode;
29
31
  propertyControls: ReactNode;
30
32
  editActions?: ReactNode;
@@ -39,6 +41,7 @@ interface CompactTaskDialogPanelProps {
39
41
  onCreateMultipleChange?: (value: boolean) => void;
40
42
  onClose: () => void;
41
43
  onFullscreen: () => void;
44
+ onDescriptionPreviewClick?: () => void;
42
45
  onSave?: () => void;
43
46
  }
44
47
 
@@ -84,6 +87,8 @@ export function CompactTaskDialogPanel({
84
87
  iconRingClass = 'ring-dynamic-orange/20',
85
88
  titleInput,
86
89
  showHeaderTitle = true,
90
+ descriptionPreview,
91
+ descriptionPreviewLabel,
87
92
  taskStatus,
88
93
  propertyControls,
89
94
  editActions,
@@ -98,6 +103,7 @@ export function CompactTaskDialogPanel({
98
103
  onCreateMultipleChange,
99
104
  onClose,
100
105
  onFullscreen,
106
+ onDescriptionPreviewClick,
101
107
  onSave,
102
108
  }: CompactTaskDialogPanelProps) {
103
109
  const t = useTranslations();
@@ -114,127 +120,146 @@ export function CompactTaskDialogPanel({
114
120
  const hasHeaderTitle = showHeaderTitle;
115
121
 
116
122
  return (
117
- <div
118
- data-testid="compact-task-dialog-panel"
119
- className="flex max-h-[calc(100vh-2rem)] min-h-0 flex-col overflow-hidden rounded-lg bg-background"
120
- >
123
+ <div className="relative">
121
124
  <div
122
- className={cn(
123
- 'flex items-start gap-3 border-b px-4 py-3',
124
- hasHeaderTitle ? 'justify-between' : 'justify-end'
125
- )}
125
+ data-testid="compact-task-dialog-panel"
126
+ className="flex max-h-[calc(100vh-2rem)] min-h-0 flex-col overflow-hidden rounded-lg bg-background"
126
127
  >
127
- {hasHeaderTitle ? (
128
- <div className="flex min-w-0 items-start gap-2.5">
129
- <div
130
- className={cn(
131
- 'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
132
- iconBgClass,
133
- iconRingClass
134
- )}
135
- >
136
- {icon ?? <ListTodo className="h-4 w-4 text-dynamic-orange" />}
137
- </div>
138
- <div className="min-w-0 space-y-0.5">
139
- <DialogTitle className="truncate font-semibold text-base">
140
- {title}
141
- </DialogTitle>
142
- {description && (
143
- <DialogDescription className="truncate text-muted-foreground text-xs">
144
- {description}
145
- </DialogDescription>
146
- )}
128
+ <div
129
+ className={cn(
130
+ 'flex items-start gap-3 border-b px-4 py-3',
131
+ hasHeaderTitle ? 'justify-between' : 'justify-end'
132
+ )}
133
+ >
134
+ {hasHeaderTitle ? (
135
+ <div className="flex min-w-0 items-start gap-2.5">
136
+ <div
137
+ className={cn(
138
+ 'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
139
+ iconBgClass,
140
+ iconRingClass
141
+ )}
142
+ >
143
+ {icon ?? <ListTodo className="h-4 w-4 text-dynamic-orange" />}
144
+ </div>
145
+ <div className="min-w-0 space-y-0.5">
146
+ <DialogTitle className="truncate font-semibold text-base">
147
+ {title}
148
+ </DialogTitle>
149
+ {description && (
150
+ <DialogDescription className="truncate text-muted-foreground text-xs">
151
+ {description}
152
+ </DialogDescription>
153
+ )}
154
+ </div>
147
155
  </div>
156
+ ) : (
157
+ <DialogTitle className="sr-only">{title}</DialogTitle>
158
+ )}
159
+ <div className="flex shrink-0 items-center gap-1">
160
+ {smartAction}
161
+ {editActions}
162
+ <Tooltip>
163
+ <TooltipTrigger asChild>
164
+ <Button
165
+ type="button"
166
+ variant="ghost"
167
+ size="icon"
168
+ aria-label={t('ws-task-boards.dialog.open_fullscreen')}
169
+ className="h-8 w-8 text-muted-foreground hover:text-foreground"
170
+ onClick={onFullscreen}
171
+ >
172
+ <Maximize2 className="h-4 w-4" />
173
+ </Button>
174
+ </TooltipTrigger>
175
+ <TooltipContent side="bottom">
176
+ {t('ws-task-boards.dialog.open_fullscreen')}
177
+ </TooltipContent>
178
+ </Tooltip>
179
+ <Tooltip>
180
+ <TooltipTrigger asChild>
181
+ <Button
182
+ type="button"
183
+ variant="ghost"
184
+ size="icon"
185
+ aria-label={t('common.close')}
186
+ className="h-8 w-8 text-muted-foreground hover:text-foreground"
187
+ onClick={onClose}
188
+ >
189
+ <X className="h-4 w-4" />
190
+ </Button>
191
+ </TooltipTrigger>
192
+ <TooltipContent side="bottom">{t('common.close')}</TooltipContent>
193
+ </Tooltip>
148
194
  </div>
149
- ) : (
150
- <DialogTitle className="sr-only">{title}</DialogTitle>
151
- )}
152
- <div className="flex shrink-0 items-center gap-1">
153
- {smartAction}
154
- {editActions}
155
- <Tooltip>
156
- <TooltipTrigger asChild>
157
- <Button
158
- type="button"
159
- variant="ghost"
160
- size="icon"
161
- aria-label={t('ws-task-boards.dialog.open_fullscreen')}
162
- className="h-8 w-8 text-muted-foreground hover:text-foreground"
163
- onClick={onFullscreen}
164
- >
165
- <Maximize2 className="h-4 w-4" />
166
- </Button>
167
- </TooltipTrigger>
168
- <TooltipContent side="bottom">
169
- {t('ws-task-boards.dialog.open_fullscreen')}
170
- </TooltipContent>
171
- </Tooltip>
172
- <Tooltip>
173
- <TooltipTrigger asChild>
174
- <Button
175
- type="button"
176
- variant="ghost"
177
- size="icon"
178
- aria-label={t('common.close')}
179
- className="h-8 w-8 text-muted-foreground hover:text-foreground"
180
- onClick={onClose}
181
- >
182
- <X className="h-4 w-4" />
183
- </Button>
184
- </TooltipTrigger>
185
- <TooltipContent side="bottom">{t('common.close')}</TooltipContent>
186
- </Tooltip>
187
195
  </div>
188
- </div>
189
196
 
190
- <div className="min-h-0 space-y-3 overflow-y-auto px-4 py-3">
191
- {titleInput}
192
- {taskStatus}
193
- <div className="flex flex-wrap items-center gap-1.5">
194
- {propertyControls}
197
+ <div className="min-h-0 space-y-3 overflow-y-auto px-4 py-3">
198
+ {titleInput}
199
+ {taskStatus}
200
+ <div className="flex flex-wrap items-center gap-1.5">
201
+ {propertyControls}
202
+ </div>
203
+ {smartPanel}
195
204
  </div>
196
- {smartPanel}
197
- </div>
198
205
 
199
- {hasCreateActions && (
200
- <div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-4 py-3">
201
- <div className="flex items-center gap-1">
202
- <CompactIconButton
203
- active={!!saveAsDraft}
204
- label={t('task-drafts.save_as_draft')}
205
- onClick={() => onSaveAsDraftChange?.(!saveAsDraft)}
206
- >
207
- <FileEdit className="h-4 w-4" />
208
- </CompactIconButton>
209
- <CompactIconButton
210
- active={!!createMultiple}
211
- label={t('ws-task-boards.dialog.create_multiple')}
212
- onClick={() => onCreateMultipleChange?.(!createMultiple)}
206
+ {hasCreateActions && (
207
+ <div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-4 py-3">
208
+ <div className="flex items-center gap-1">
209
+ <CompactIconButton
210
+ active={!!saveAsDraft}
211
+ label={t('task-drafts.save_as_draft')}
212
+ onClick={() => onSaveAsDraftChange?.(!saveAsDraft)}
213
+ >
214
+ <FileEdit className="h-4 w-4" />
215
+ </CompactIconButton>
216
+ <CompactIconButton
217
+ active={!!createMultiple}
218
+ label={t('ws-task-boards.dialog.create_multiple')}
219
+ onClick={() => onCreateMultipleChange?.(!createMultiple)}
220
+ >
221
+ <Copy className="h-4 w-4" />
222
+ </CompactIconButton>
223
+ <QuickSettingsPopover isPersonalWorkspace={isPersonalWorkspace} />
224
+ </div>
225
+ <Button
226
+ type="button"
227
+ size="sm"
228
+ disabled={!canSave}
229
+ onClick={() => onSave?.()}
230
+ className="min-w-28"
213
231
  >
214
- <Copy className="h-4 w-4" />
215
- </CompactIconButton>
216
- <QuickSettingsPopover isPersonalWorkspace={isPersonalWorkspace} />
232
+ {isLoading ? (
233
+ <>
234
+ <Loader2 className="h-4 w-4 animate-spin" />
235
+ {t('ws-task-boards.dialog.saving')}
236
+ </>
237
+ ) : (
238
+ <>
239
+ <Check className="h-4 w-4" />
240
+ {saveLabel}
241
+ </>
242
+ )}
243
+ </Button>
217
244
  </div>
218
- <Button
219
- type="button"
220
- size="sm"
221
- disabled={!canSave}
222
- onClick={() => onSave?.()}
223
- className="min-w-28"
224
- >
225
- {isLoading ? (
226
- <>
227
- <Loader2 className="h-4 w-4 animate-spin" />
228
- {t('ws-task-boards.dialog.saving')}
229
- </>
230
- ) : (
231
- <>
232
- <Check className="h-4 w-4" />
233
- {saveLabel}
234
- </>
235
- )}
236
- </Button>
237
- </div>
245
+ )}
246
+ </div>
247
+
248
+ {descriptionPreview && onDescriptionPreviewClick && (
249
+ <button
250
+ type="button"
251
+ data-testid="compact-task-description-preview"
252
+ aria-label={
253
+ descriptionPreviewLabel ??
254
+ t('ws-task-boards.dialog.open_fullscreen')
255
+ }
256
+ className="absolute top-full left-1/2 mt-2 w-full max-w-[30rem] -translate-x-1/2 rounded-lg border bg-background/95 px-4 py-3 text-left opacity-70 shadow-xl ring-1 ring-border/60 backdrop-blur transition hover:bg-muted/70 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
257
+ onClick={onDescriptionPreviewClick}
258
+ >
259
+ <span className="line-clamp-3 whitespace-pre-line text-muted-foreground text-sm leading-relaxed">
260
+ {descriptionPreview}
261
+ </span>
262
+ </button>
238
263
  )}
239
264
  </div>
240
265
  );
@@ -4,6 +4,7 @@ import type { QueryClient } from '@tanstack/react-query';
4
4
  import type { Editor, JSONContent } from '@tiptap/react';
5
5
  import { Loader2 } from '@tuturuuu/icons';
6
6
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
7
+ import { getBoardRealtimeChannelName } from '@tuturuuu/ui/hooks/useBoardRealtime.types';
7
8
  import { toast } from '@tuturuuu/ui/sonner';
8
9
  import { cn } from '@tuturuuu/utils/format';
9
10
  import { useTranslations } from 'next-intl';
@@ -352,8 +353,9 @@ export function TaskDescriptionEditor({
352
353
 
353
354
  {showCollaborationCursors && taskId && (
354
355
  <CursorOverlayMultiWrapper
355
- channelName={`task-cursor-${taskId}`}
356
+ channelName={getBoardRealtimeChannelName(boardId)}
356
357
  containerRef={richTextEditorRef}
358
+ cursorScope={{ taskId, type: 'task-description' }}
357
359
  />
358
360
  )}
359
361
 
@@ -0,0 +1,171 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const internalApiMocks = vi.hoisted(() => ({
4
+ abortWorkspaceTaskDescriptionChunks: vi.fn(),
5
+ appendWorkspaceTaskDescriptionChunk: vi.fn(),
6
+ beginWorkspaceTaskDescriptionChunks: vi.fn(),
7
+ commitWorkspaceTaskDescriptionChunks: vi.fn(),
8
+ createWorkspaceTaskProject: vi.fn(),
9
+ getWorkspaceTask: vi.fn(),
10
+ getWorkspaceTaskDescription: vi.fn(),
11
+ updateWorkspaceTask: vi.fn(),
12
+ updateWorkspaceTaskDescription: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('@tuturuuu/internal-api/tasks', () => internalApiMocks);
16
+
17
+ import {
18
+ shouldChunkTaskDescriptionPayload,
19
+ updateWorkspaceTaskDescription,
20
+ } from './task-api';
21
+
22
+ describe('task-api description persistence', () => {
23
+ beforeEach(() => {
24
+ vi.resetAllMocks();
25
+ internalApiMocks.abortWorkspaceTaskDescriptionChunks.mockResolvedValue({
26
+ success: true,
27
+ });
28
+ internalApiMocks.appendWorkspaceTaskDescriptionChunk.mockResolvedValue({
29
+ success: true,
30
+ });
31
+ internalApiMocks.beginWorkspaceTaskDescriptionChunks.mockResolvedValue({
32
+ session_id: 'chunk-session-1',
33
+ });
34
+ internalApiMocks.commitWorkspaceTaskDescriptionChunks.mockResolvedValue({
35
+ description: 'persisted',
36
+ description_yjs_state: [1, 2, 3],
37
+ });
38
+ internalApiMocks.updateWorkspaceTaskDescription.mockResolvedValue({
39
+ description: 'small',
40
+ description_yjs_state: [1, 2, 3],
41
+ });
42
+ });
43
+
44
+ it('uses the direct description update for small payloads', async () => {
45
+ const payload = {
46
+ description: JSON.stringify({
47
+ type: 'doc',
48
+ content: [{ type: 'paragraph', content: [{ text: 'Small' }] }],
49
+ }),
50
+ description_yjs_state: [1, 2, 3],
51
+ };
52
+
53
+ await expect(
54
+ updateWorkspaceTaskDescription('ws-1', 'task-1', payload)
55
+ ).resolves.toEqual({
56
+ description: 'small',
57
+ description_yjs_state: [1, 2, 3],
58
+ });
59
+
60
+ expect(
61
+ internalApiMocks.updateWorkspaceTaskDescription
62
+ ).toHaveBeenCalledWith('ws-1', 'task-1', payload);
63
+ expect(
64
+ internalApiMocks.beginWorkspaceTaskDescriptionChunks
65
+ ).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it('uploads large description updates through ordered chunks', async () => {
69
+ const yjsState = Array.from({ length: 220_000 }, (_, index) => index % 256);
70
+ const payload = {
71
+ description: JSON.stringify({
72
+ type: 'doc',
73
+ content: [
74
+ {
75
+ type: 'paragraph',
76
+ content: [{ type: 'text', text: 'Large paste' }],
77
+ },
78
+ ],
79
+ }),
80
+ description_yjs_state: yjsState,
81
+ };
82
+
83
+ expect(shouldChunkTaskDescriptionPayload(payload)).toBe(true);
84
+
85
+ await updateWorkspaceTaskDescription('ws-1', 'task-1', payload);
86
+
87
+ expect(
88
+ internalApiMocks.updateWorkspaceTaskDescription
89
+ ).not.toHaveBeenCalled();
90
+ expect(
91
+ internalApiMocks.beginWorkspaceTaskDescriptionChunks
92
+ ).toHaveBeenCalledWith(
93
+ 'ws-1',
94
+ 'task-1',
95
+ expect.objectContaining({
96
+ description: expect.objectContaining({
97
+ chunk_count: 1,
98
+ }),
99
+ description_yjs_state: expect.objectContaining({
100
+ chunk_count: expect.any(Number),
101
+ }),
102
+ })
103
+ );
104
+
105
+ const appendCalls =
106
+ internalApiMocks.appendWorkspaceTaskDescriptionChunk.mock.calls;
107
+ expect(appendCalls.length).toBeGreaterThan(2);
108
+ expect(appendCalls[0]).toEqual([
109
+ 'ws-1',
110
+ 'task-1',
111
+ expect.objectContaining({
112
+ chunk_index: 0,
113
+ field: 'description',
114
+ session_id: 'chunk-session-1',
115
+ }),
116
+ ]);
117
+ expect(
118
+ appendCalls
119
+ .filter((call) => call[2].field === 'description_yjs_state')
120
+ .map((call) => call[2].chunk_index)
121
+ ).toEqual(
122
+ appendCalls
123
+ .filter((call) => call[2].field === 'description_yjs_state')
124
+ .map((_, index) => index)
125
+ );
126
+ expect(
127
+ internalApiMocks.commitWorkspaceTaskDescriptionChunks
128
+ ).toHaveBeenCalledWith('ws-1', 'task-1', 'chunk-session-1');
129
+ });
130
+
131
+ it('falls back to chunked upload when a direct save hits the proxy body limit', async () => {
132
+ internalApiMocks.updateWorkspaceTaskDescription.mockRejectedValueOnce({
133
+ status: 413,
134
+ });
135
+
136
+ await updateWorkspaceTaskDescription('ws-1', 'task-1', {
137
+ description: 'small enough to try directly',
138
+ description_yjs_state: [1, 2, 3],
139
+ });
140
+
141
+ expect(
142
+ internalApiMocks.beginWorkspaceTaskDescriptionChunks
143
+ ).toHaveBeenCalled();
144
+ expect(
145
+ internalApiMocks.commitWorkspaceTaskDescriptionChunks
146
+ ).toHaveBeenCalled();
147
+ });
148
+
149
+ it('aborts the chunk session when an append fails', async () => {
150
+ internalApiMocks.appendWorkspaceTaskDescriptionChunk.mockRejectedValueOnce(
151
+ new Error('network down')
152
+ );
153
+
154
+ await expect(
155
+ updateWorkspaceTaskDescription('ws-1', 'task-1', {
156
+ description: 'x'.repeat(10),
157
+ description_yjs_state: Array.from(
158
+ { length: 220_000 },
159
+ (_, index) => index % 256
160
+ ),
161
+ })
162
+ ).rejects.toThrow('network down');
163
+
164
+ expect(
165
+ internalApiMocks.abortWorkspaceTaskDescriptionChunks
166
+ ).toHaveBeenCalledWith('ws-1', 'task-1', 'chunk-session-1');
167
+ expect(
168
+ internalApiMocks.commitWorkspaceTaskDescriptionChunks
169
+ ).not.toHaveBeenCalled();
170
+ });
171
+ });