@tuturuuu/ui 0.9.0 → 0.11.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 (123) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +6 -5
  3. package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
  5. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
  6. package/src/components/ui/custom/nav-link.test.tsx +165 -0
  7. package/src/components/ui/custom/nav-link.tsx +69 -11
  8. package/src/components/ui/custom/navigation.tsx +1 -0
  9. package/src/components/ui/custom/settings/task-settings.tsx +104 -0
  10. package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
  11. package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
  12. package/src/components/ui/custom/settings-dialog-search.ts +75 -0
  13. package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
  14. package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
  15. package/src/components/ui/custom/workspace-select.tsx +17 -16
  16. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  17. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  18. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  21. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  22. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  23. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  24. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  25. package/src/components/ui/finance/invoices/utils.ts +240 -7
  26. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  27. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  28. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  29. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  30. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  31. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  33. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  35. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  36. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  37. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  38. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  39. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  40. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  41. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  46. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  50. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  51. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  52. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  53. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  54. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  55. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  56. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  57. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  58. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  59. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  60. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  61. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  62. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  63. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +123 -1
  64. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  65. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  66. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  67. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +58 -0
  68. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  69. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  70. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  71. package/src/components/ui/tu-do/shared/board-client.tsx +16 -4
  72. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  73. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  74. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  75. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  76. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  77. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +58 -4
  78. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  79. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  102. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  103. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  104. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  105. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  106. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  107. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  108. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +390 -34
  109. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  110. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  111. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  112. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  113. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  114. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  115. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  116. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  117. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  118. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
  119. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  120. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  121. package/src/hooks/useBoardPresence.ts +364 -0
  122. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  123. package/src/lib/workspace-actions.ts +2 -6
@@ -1,7 +1,5 @@
1
1
  import { type QueryClient, useQueryClient } from '@tanstack/react-query';
2
- import type { Task } from '@tuturuuu/types/primitives/Task';
3
2
  import { toast } from '@tuturuuu/ui/sonner';
4
- import { invalidateTaskCaches } from '@tuturuuu/utils/task-helper';
5
3
  import {
6
4
  type Dispatch,
7
5
  type SetStateAction,
@@ -11,9 +9,11 @@ import {
11
9
  import { getRandomNewLabelColor } from '../../../utils/taskConstants';
12
10
  import {
13
11
  type BoardBroadcastFn,
12
+ getActiveBoardRefresh,
14
13
  getActiveBroadcast,
15
14
  useBoardBroadcast,
16
15
  } from '../../board-broadcast-context';
16
+ import { patchTaskInVisibleCaches } from '../../task-cache-patches';
17
17
  import type {
18
18
  WorkspaceTaskAssignee,
19
19
  WorkspaceTaskLabel,
@@ -129,6 +129,32 @@ function upsertWorkspaceLabelCaches({
129
129
  }
130
130
  }
131
131
 
132
+ function normalizeTaskAssignees(assignees: WorkspaceTaskAssignee[]) {
133
+ return assignees
134
+ .map((assignee) => {
135
+ const id = assignee.user_id || assignee.id;
136
+ if (!id) return null;
137
+
138
+ return {
139
+ id,
140
+ display_name: assignee.display_name ?? undefined,
141
+ email: assignee.email ?? undefined,
142
+ avatar_url: assignee.avatar_url ?? undefined,
143
+ };
144
+ })
145
+ .filter((assignee): assignee is NonNullable<typeof assignee> =>
146
+ Boolean(assignee)
147
+ );
148
+ }
149
+
150
+ function normalizeTaskProjects(projects: WorkspaceTaskProject[]) {
151
+ return projects.map((project) => ({
152
+ id: project.id,
153
+ name: project.name,
154
+ status: project.status ?? 'unknown',
155
+ }));
156
+ }
157
+
132
158
  export function useTaskRelationships({
133
159
  wsId,
134
160
  labelCacheWorkspaceId,
@@ -189,9 +215,18 @@ export function useTaskRelationships({
189
215
  });
190
216
 
191
217
  setSelectedLabels(nextSelectedLabels);
192
- await invalidateTaskCaches(queryClient, boardId);
218
+ patchTaskInVisibleCaches({
219
+ queryClient,
220
+ boardId,
221
+ taskId,
222
+ updater: (task) => ({
223
+ ...task,
224
+ labels: nextSelectedLabels,
225
+ }),
226
+ });
193
227
  queryClient.invalidateQueries({ queryKey: ['task-history'] });
194
228
  broadcast?.('task:relations-changed', { taskId });
229
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
195
230
  onUpdate();
196
231
  } catch (error: unknown) {
197
232
  toast.error('Label update failed', {
@@ -246,30 +281,18 @@ export function useTaskRelationships({
246
281
  });
247
282
 
248
283
  setSelectedAssignees(nextSelectedAssignees);
249
- queryClient.setQueryData(
250
- ['tasks', boardId],
251
- (old: Task[] | undefined) => {
252
- if (!old) return old;
253
- return old.map((task) => {
254
- if (task.id !== taskId) return task;
255
- const currentAssignees = task.assignees || [];
256
- const newAssignees = exists
257
- ? currentAssignees.filter((assignee) => assignee.id !== userId)
258
- : [
259
- ...currentAssignees,
260
- {
261
- id: userId,
262
- display_name: member.display_name,
263
- email: member.email,
264
- avatar_url: member.avatar_url,
265
- },
266
- ];
267
- return { ...task, assignees: newAssignees };
268
- });
269
- }
270
- );
284
+ patchTaskInVisibleCaches({
285
+ queryClient,
286
+ boardId,
287
+ taskId,
288
+ updater: (task) => ({
289
+ ...task,
290
+ assignees: normalizeTaskAssignees(nextSelectedAssignees),
291
+ }),
292
+ });
271
293
  queryClient.invalidateQueries({ queryKey: ['task-history'] });
272
294
  broadcast?.('task:relations-changed', { taskId });
295
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
273
296
  onUpdate();
274
297
  } catch (error: unknown) {
275
298
  toast.error('Assignee update failed', {
@@ -315,22 +338,18 @@ export function useTaskRelationships({
315
338
  });
316
339
 
317
340
  setSelectedProjects(nextSelectedProjects);
318
- queryClient.setQueryData(
319
- ['tasks', boardId],
320
- (old: Task[] | undefined) => {
321
- if (!old) return old;
322
- return old.map((task) => {
323
- if (task.id !== taskId) return task;
324
- const currentProjects = task.projects || [];
325
- const newProjects = exists
326
- ? currentProjects.filter((entry) => entry.id !== project.id)
327
- : [...currentProjects, project];
328
- return { ...task, projects: newProjects };
329
- });
330
- }
331
- );
341
+ patchTaskInVisibleCaches({
342
+ queryClient,
343
+ boardId,
344
+ taskId,
345
+ updater: (task) => ({
346
+ ...task,
347
+ projects: normalizeTaskProjects(nextSelectedProjects),
348
+ }),
349
+ });
332
350
  queryClient.invalidateQueries({ queryKey: ['task-history'] });
333
351
  broadcast?.('task:relations-changed', { taskId });
352
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
334
353
  onUpdate();
335
354
  } catch (error: unknown) {
336
355
  toast.error('Project update failed', {
@@ -393,23 +412,17 @@ export function useTaskRelationships({
393
412
  });
394
413
 
395
414
  setSelectedLabels(nextSelectedLabels);
396
- queryClient.setQueryData(
397
- ['tasks', boardId],
398
- (old: Task[] | undefined) => {
399
- if (!old) return old;
400
- return old.map((task) => {
401
- if (task.id !== taskId) return task;
402
- const currentLabels = task.labels || [];
403
- return {
404
- ...task,
405
- labels: [...currentLabels, newLabel].sort((a, b) =>
406
- compareLabelsByName(a, b)
407
- ),
408
- };
409
- });
410
- }
411
- );
415
+ patchTaskInVisibleCaches({
416
+ queryClient,
417
+ boardId,
418
+ taskId,
419
+ updater: (task) => ({
420
+ ...task,
421
+ labels: nextSelectedLabels,
422
+ }),
423
+ });
412
424
  broadcast?.('task:relations-changed', { taskId });
425
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
413
426
  onUpdate();
414
427
  toast.success('Label created & linked', {
415
428
  description: 'New label added and attached to this task.',
@@ -485,21 +498,17 @@ export function useTaskRelationships({
485
498
  });
486
499
 
487
500
  setSelectedProjects(nextSelectedProjects);
488
- queryClient.setQueryData(
489
- ['tasks', boardId],
490
- (old: Task[] | undefined) => {
491
- if (!old) return old;
492
- return old.map((task) => {
493
- if (task.id !== taskId) return task;
494
- const currentProjects = task.projects || [];
495
- return {
496
- ...task,
497
- projects: [...currentProjects, newProject],
498
- };
499
- });
500
- }
501
- );
501
+ patchTaskInVisibleCaches({
502
+ queryClient,
503
+ boardId,
504
+ taskId,
505
+ updater: (task) => ({
506
+ ...task,
507
+ projects: normalizeTaskProjects(nextSelectedProjects),
508
+ }),
509
+ });
502
510
  broadcast?.('task:relations-changed', { taskId });
511
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
503
512
  onUpdate();
504
513
  toast.success('Project created & linked', {
505
514
  description: 'New project added and attached to this task.',
@@ -38,6 +38,8 @@ interface PersonalOverridesSectionProps {
38
38
  onUpdate?: () => void;
39
39
  }
40
40
 
41
+ type PersonalOverridePopoverId = 'priority' | 'estimation';
42
+
41
43
  export function PersonalOverridesSection({
42
44
  taskId,
43
45
  isCreateMode,
@@ -51,10 +53,22 @@ export function PersonalOverridesSection({
51
53
  onUpdate
52
54
  );
53
55
  const [isExpanded, setIsExpanded] = useState(false);
54
- const [isPriorityOpen, setIsPriorityOpen] = useState(false);
55
- const [isEstimationOpen, setIsEstimationOpen] = useState(false);
56
+ const [activePopover, setActivePopover] =
57
+ useState<PersonalOverridePopoverId | null>(null);
56
58
  const [notes, setNotes] = useState('');
57
59
 
60
+ const isPopoverOpen = (popoverId: PersonalOverridePopoverId) =>
61
+ activePopover === popoverId;
62
+ const setPopoverOpen = (
63
+ popoverId: PersonalOverridePopoverId,
64
+ open: boolean
65
+ ) => {
66
+ setActivePopover((currentPopover) => {
67
+ if (open) return popoverId;
68
+ return currentPopover === popoverId ? null : currentPopover;
69
+ });
70
+ };
71
+
58
72
  // Don't render for new tasks
59
73
  if (isCreateMode || !taskId) return null;
60
74
 
@@ -75,7 +89,7 @@ export function PersonalOverridesSection({
75
89
 
76
90
  const handlePriorityChange = (priority: TaskPriority | null) => {
77
91
  upsert({ priority_override: priority });
78
- setIsPriorityOpen(false);
92
+ setPopoverOpen('priority', false);
79
93
  };
80
94
 
81
95
  const handleDueDateChange = (date: Date | undefined) => {
@@ -86,7 +100,7 @@ export function PersonalOverridesSection({
86
100
 
87
101
  const handleEstimationChange = (points: number | null) => {
88
102
  upsert({ estimation_override: points });
89
- setIsEstimationOpen(false);
103
+ setPopoverOpen('estimation', false);
90
104
  };
91
105
 
92
106
  const handleNotesBlur = () => {
@@ -112,7 +126,10 @@ export function PersonalOverridesSection({
112
126
  <div className="border-t bg-muted/20">
113
127
  <button
114
128
  type="button"
115
- onClick={() => setIsExpanded(!isExpanded)}
129
+ onClick={() => {
130
+ setIsExpanded(!isExpanded);
131
+ setActivePopover(null);
132
+ }}
116
133
  className="flex w-full items-center justify-between px-4 py-2 text-left transition-colors hover:bg-muted/40 md:px-8"
117
134
  >
118
135
  <div className="flex items-center gap-2">
@@ -167,7 +184,10 @@ export function PersonalOverridesSection({
167
184
  <Flag className="h-4 w-4 text-dynamic-orange" />
168
185
  {t('ws-tasks.my_priority')}
169
186
  </Label>
170
- <Popover open={isPriorityOpen} onOpenChange={setIsPriorityOpen}>
187
+ <Popover
188
+ open={isPopoverOpen('priority')}
189
+ onOpenChange={(open) => setPopoverOpen('priority', open)}
190
+ >
171
191
  <PopoverTrigger asChild>
172
192
  <Button
173
193
  variant="outline"
@@ -255,8 +275,8 @@ export function PersonalOverridesSection({
255
275
  {t('ws-tasks.my_estimate')}
256
276
  </Label>
257
277
  <Popover
258
- open={isEstimationOpen}
259
- onOpenChange={setIsEstimationOpen}
278
+ open={isPopoverOpen('estimation')}
279
+ onOpenChange={(open) => setPopoverOpen('estimation', open)}
260
280
  >
261
281
  <PopoverTrigger asChild>
262
282
  <Button
@@ -25,10 +25,15 @@ export function DependenciesSection({
25
25
  onNavigateToTask,
26
26
  onAddBlockingTaskDialog,
27
27
  onAddBlockedByTaskDialog,
28
+ searchOpen: controlledSearchOpen,
29
+ onSearchOpenChange,
28
30
  disabled,
29
31
  }: DependenciesSectionProps) {
30
32
  const [subTab, setSubTab] = React.useState<DependencySubTab>(initialSubTab);
31
- const [searchOpen, setSearchOpen] = React.useState(false);
33
+ const [uncontrolledSearchOpen, setUncontrolledSearchOpen] =
34
+ React.useState(false);
35
+ const searchOpen = controlledSearchOpen ?? uncontrolledSearchOpen;
36
+ const setSearchOpen = onSearchOpenChange ?? setUncontrolledSearchOpen;
32
37
 
33
38
  const allExcludeIds = React.useMemo(() => {
34
39
  const ids = new Set<string>();
@@ -56,7 +61,10 @@ export function DependenciesSection({
56
61
  <Button
57
62
  variant={subTab === 'blocks' ? 'default' : 'outline'}
58
63
  size="sm"
59
- onClick={() => setSubTab('blocks')}
64
+ onClick={() => {
65
+ setSubTab('blocks');
66
+ setSearchOpen(false);
67
+ }}
60
68
  className="h-7 text-xs"
61
69
  >
62
70
  Blocks ({blockingTasks.length})
@@ -64,7 +72,10 @@ export function DependenciesSection({
64
72
  <Button
65
73
  variant={subTab === 'blocked-by' ? 'default' : 'outline'}
66
74
  size="sm"
67
- onClick={() => setSubTab('blocked-by')}
75
+ onClick={() => {
76
+ setSubTab('blocked-by');
77
+ setSearchOpen(false);
78
+ }}
68
79
  className="h-7 text-xs"
69
80
  >
70
81
  Blocked By ({blockedByTasks.length})
@@ -16,9 +16,14 @@ export function ParentSection({
16
16
  onRemoveParent,
17
17
  onNavigateToTask,
18
18
  onAddParentTask,
19
+ searchOpen: controlledSearchOpen,
20
+ onSearchOpenChange,
19
21
  disabled,
20
22
  }: ParentSectionProps) {
21
- const [searchOpen, setSearchOpen] = React.useState(false);
23
+ const [uncontrolledSearchOpen, setUncontrolledSearchOpen] =
24
+ React.useState(false);
25
+ const searchOpen = controlledSearchOpen ?? uncontrolledSearchOpen;
26
+ const setSearchOpen = onSearchOpenChange ?? setUncontrolledSearchOpen;
22
27
 
23
28
  const excludeIds = React.useMemo(() => {
24
29
  const ids = taskId ? [taskId, ...childTaskIds] : childTaskIds;
@@ -16,9 +16,14 @@ export function RelatedSection({
16
16
  onRemoveRelated,
17
17
  onNavigateToTask,
18
18
  onAddRelatedTaskDialog,
19
+ searchOpen: controlledSearchOpen,
20
+ onSearchOpenChange,
19
21
  disabled,
20
22
  }: RelatedSectionProps) {
21
- const [searchOpen, setSearchOpen] = React.useState(false);
23
+ const [uncontrolledSearchOpen, setUncontrolledSearchOpen] =
24
+ React.useState(false);
25
+ const searchOpen = controlledSearchOpen ?? uncontrolledSearchOpen;
26
+ const setSearchOpen = onSearchOpenChange ?? setUncontrolledSearchOpen;
22
27
 
23
28
  const excludeIds = React.useMemo(() => {
24
29
  const ids = taskId ? [taskId] : [];
@@ -16,9 +16,14 @@ export function SubtasksSection({
16
16
  onAddSubtask,
17
17
  onAddExistingAsSubtask,
18
18
  isSaving,
19
+ searchOpen: controlledSearchOpen,
20
+ onSearchOpenChange,
19
21
  disabled,
20
22
  }: SubtasksSectionProps) {
21
- const [searchOpen, setSearchOpen] = React.useState(false);
23
+ const [uncontrolledSearchOpen, setUncontrolledSearchOpen] =
24
+ React.useState(false);
25
+ const searchOpen = controlledSearchOpen ?? uncontrolledSearchOpen;
26
+ const setSearchOpen = onSearchOpenChange ?? setUncontrolledSearchOpen;
22
27
 
23
28
  const excludeIds = React.useMemo(() => {
24
29
  const ids = taskId ? [taskId] : [];
@@ -122,6 +122,8 @@ export interface ParentSectionProps {
122
122
  onRemoveParent: () => void;
123
123
  onNavigateToTask: (taskId: string) => void;
124
124
  onAddParentTask?: () => void; // Opens dialog to create new parent task
125
+ searchOpen?: boolean;
126
+ onSearchOpenChange?: (open: boolean) => void;
125
127
  disabled?: boolean;
126
128
  }
127
129
 
@@ -137,6 +139,8 @@ export interface SubtasksSectionProps {
137
139
  onAddSubtask?: () => void;
138
140
  onAddExistingAsSubtask?: (task: RelatedTaskInfo) => Promise<void>;
139
141
  isSaving: boolean;
142
+ searchOpen?: boolean;
143
+ onSearchOpenChange?: (open: boolean) => void;
140
144
  disabled?: boolean;
141
145
  }
142
146
 
@@ -168,6 +172,8 @@ export interface DependenciesSectionProps {
168
172
  onNavigateToTask: (taskId: string) => void;
169
173
  onAddBlockingTaskDialog?: () => void; // Opens dialog to create new blocking task
170
174
  onAddBlockedByTaskDialog?: () => void; // Opens dialog to create new blocked-by task
175
+ searchOpen?: boolean;
176
+ onSearchOpenChange?: (open: boolean) => void;
171
177
  disabled?: boolean;
172
178
  }
173
179
 
@@ -182,5 +188,7 @@ export interface RelatedSectionProps {
182
188
  onRemoveRelated: (taskId: string) => void;
183
189
  onNavigateToTask: (taskId: string) => void;
184
190
  onAddRelatedTaskDialog?: () => void; // Opens dialog to create new related task;
191
+ searchOpen?: boolean;
192
+ onSearchOpenChange?: (open: boolean) => void;
185
193
  disabled?: boolean;
186
194
  }
@@ -22,6 +22,7 @@ import {
22
22
  UserMinus,
23
23
  UserPlus,
24
24
  } from '@tuturuuu/icons';
25
+ import { getWorkspaceTaskHistory } from '@tuturuuu/internal-api/tasks';
25
26
  import { Avatar, AvatarFallback, AvatarImage } from '@tuturuuu/ui/avatar';
26
27
  import { Badge } from '@tuturuuu/ui/badge';
27
28
  import { Button } from '@tuturuuu/ui/button';
@@ -34,6 +35,9 @@ import { useLocale, useTranslations } from 'next-intl';
34
35
  import { useState } from 'react';
35
36
  import type { EstimationType } from '../estimation-mapping';
36
37
  import { mapEstimationPoints } from '../estimation-mapping';
38
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
39
+ import { isTaskDescriptionHistoryEntry } from './description-versions';
40
+ import { TaskDescriptionChangeDialog } from './task-description-change-dialog';
37
41
  import { TaskSnapshotDialog } from './task-snapshot-dialog';
38
42
 
39
43
  /** Represents a task history entry from the API */
@@ -77,6 +81,11 @@ interface TaskActivitySectionProps {
77
81
  estimationType?: EstimationType;
78
82
  /** When true, disables the revert functionality (feature not stable) */
79
83
  revertDisabled?: boolean;
84
+ /** Restores a tracked description version through the Yjs-safe description path. */
85
+ onRestoreDescriptionVersion?: (
86
+ version: RecoverableTaskDescriptionVersion
87
+ ) => Promise<void> | void;
88
+ restoringDescriptionVersionId?: string | null;
80
89
  }
81
90
 
82
91
  // Task history section for showing activity logs and snapshots
@@ -91,6 +100,8 @@ export function TaskActivitySection({
91
100
  onTaskUpdate,
92
101
  estimationType,
93
102
  revertDisabled = false,
103
+ onRestoreDescriptionVersion,
104
+ restoringDescriptionVersionId,
94
105
  }: TaskActivitySectionProps) {
95
106
  const t = useTranslations('tasks-history');
96
107
  const snapshotT = useTranslations('tasks.history');
@@ -100,6 +111,8 @@ export function TaskActivitySection({
100
111
  const [snapshotEntry, setSnapshotEntry] = useState<TaskHistoryEntry | null>(
101
112
  null
102
113
  );
114
+ const [descriptionChangeEntry, setDescriptionChangeEntry] =
115
+ useState<TaskHistoryEntry | null>(null);
103
116
  const dateLocale = locale === 'vi' ? vi : enUS;
104
117
 
105
118
  // Fetch task history
@@ -107,12 +120,9 @@ export function TaskActivitySection({
107
120
  queryKey: ['task-history', wsId, taskId],
108
121
  queryFn: async () => {
109
122
  if (!taskId) return null;
110
- const res = await fetch(
111
- `/api/v1/workspaces/${wsId}/tasks/${taskId}/history?limit=50`,
112
- { cache: 'no-store' }
113
- );
114
- if (!res.ok) throw new Error('Failed to fetch task history');
115
- return res.json() as Promise<{
123
+ return getWorkspaceTaskHistory(wsId, taskId, {
124
+ limit: 50,
125
+ }) as Promise<{
116
126
  history: TaskHistoryEntry[];
117
127
  count: number;
118
128
  }>;
@@ -179,7 +189,21 @@ export function TaskActivitySection({
179
189
  }
180
190
  dateLocale={dateLocale}
181
191
  showActions={!!currentTask && !!boardId}
182
- onViewSnapshot={() => setSnapshotEntry(entry)}
192
+ onViewSnapshot={() => {
193
+ if (isTaskDescriptionHistoryEntry(entry)) {
194
+ setDescriptionChangeEntry(entry);
195
+ return;
196
+ }
197
+
198
+ setSnapshotEntry(entry);
199
+ }}
200
+ viewLabel={
201
+ isTaskDescriptionHistoryEntry(entry)
202
+ ? t('view_description_change', {
203
+ defaultValue: 'View description change',
204
+ })
205
+ : t('view_snapshot')
206
+ }
183
207
  estimationType={estimationType}
184
208
  />
185
209
  ))}
@@ -230,6 +254,28 @@ export function TaskActivitySection({
230
254
  revertDisabled={revertDisabled}
231
255
  />
232
256
  )}
257
+
258
+ {descriptionChangeEntry && (
259
+ <TaskDescriptionChangeDialog
260
+ canRestore={Boolean(onRestoreDescriptionVersion)}
261
+ entry={descriptionChangeEntry}
262
+ isOpen={!!descriptionChangeEntry}
263
+ locale={locale}
264
+ onClose={() => setDescriptionChangeEntry(null)}
265
+ onRestoreVersion={async (version) => {
266
+ await onRestoreDescriptionVersion?.(version);
267
+ setDescriptionChangeEntry(null);
268
+ onTaskUpdate?.();
269
+ }}
270
+ restoringVersionId={restoringDescriptionVersionId}
271
+ t={
272
+ snapshotT as (
273
+ key: string,
274
+ options?: { count?: number; defaultValue?: string }
275
+ ) => string
276
+ }
277
+ />
278
+ )}
233
279
  </div>
234
280
  );
235
281
  }
@@ -240,6 +286,7 @@ interface ActivityEntryProps {
240
286
  dateLocale: typeof enUS | typeof vi;
241
287
  showActions?: boolean;
242
288
  onViewSnapshot?: () => void;
289
+ viewLabel?: string;
243
290
  estimationType?: EstimationType;
244
291
  }
245
292
 
@@ -249,6 +296,7 @@ function ActivityEntry({
249
296
  dateLocale,
250
297
  showActions = false,
251
298
  onViewSnapshot,
299
+ viewLabel,
252
300
  estimationType,
253
301
  }: ActivityEntryProps) {
254
302
  const { icon, color } = getChangeIcon(entry.change_type, entry.field_name);
@@ -338,7 +386,7 @@ function ActivityEntry({
338
386
  </Button>
339
387
  </TooltipTrigger>
340
388
  <TooltipContent side="top" className="text-xs">
341
- {t('view_snapshot')}
389
+ {viewLabel ?? t('view_snapshot')}
342
390
  </TooltipContent>
343
391
  </Tooltip>
344
392
  </div>
@@ -0,0 +1,63 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { TaskDescriptionChangeDialog } from './task-description-change-dialog';
6
+
7
+ vi.mock('./description-diff-viewer', () => ({
8
+ DescriptionDiffViewer: () => <button type="button">view-diff</button>,
9
+ }));
10
+
11
+ const t = (key: string, options?: { defaultValue?: string }) =>
12
+ options?.defaultValue ?? key;
13
+
14
+ const makeDoc = (text: string) =>
15
+ JSON.stringify({
16
+ type: 'doc',
17
+ content: [
18
+ {
19
+ type: 'paragraph',
20
+ content: [{ type: 'text', text }],
21
+ },
22
+ ],
23
+ });
24
+
25
+ describe('TaskDescriptionChangeDialog', () => {
26
+ it('offers restoring the previous recoverable description from a wipe row', async () => {
27
+ const onRestoreVersion = vi.fn();
28
+
29
+ render(
30
+ <TaskDescriptionChangeDialog
31
+ entry={{
32
+ id: 'history-1',
33
+ task_id: 'task-1',
34
+ changed_by: 'user-1',
35
+ changed_at: '2026-06-27T00:00:00.000Z',
36
+ change_type: 'field_updated',
37
+ field_name: 'description',
38
+ old_value: makeDoc('Recover me'),
39
+ new_value: null,
40
+ metadata: {},
41
+ user: { id: 'user-1', name: 'User' },
42
+ }}
43
+ isOpen
44
+ onClose={vi.fn()}
45
+ onRestoreVersion={onRestoreVersion}
46
+ t={t}
47
+ />
48
+ );
49
+
50
+ expect(screen.getByText('Recover me')).toBeInTheDocument();
51
+
52
+ fireEvent.click(screen.getByRole('button', { name: /restore previous/i }));
53
+
54
+ await waitFor(() => {
55
+ expect(onRestoreVersion).toHaveBeenCalledWith(
56
+ expect.objectContaining({
57
+ previewText: 'Recover me',
58
+ source: 'old_value',
59
+ })
60
+ );
61
+ });
62
+ });
63
+ });