@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
@@ -0,0 +1,218 @@
1
+ 'use client';
2
+
3
+ import { Clock, Eye, FileText, Loader2, RotateCcw } 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
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@tuturuuu/ui/dialog';
13
+ import { ScrollArea } from '@tuturuuu/ui/scroll-area';
14
+ import { format, formatDistanceToNow } from 'date-fns';
15
+ import { enUS, vi } from 'date-fns/locale';
16
+ import { useMemo } from 'react';
17
+ import { DescriptionDiffViewer } from './description-diff-viewer';
18
+ import {
19
+ buildRecoverableTaskDescriptionVersions,
20
+ extractRecoverableTaskDescriptionValue,
21
+ type RecoverableTaskDescriptionVersion,
22
+ } from './description-versions';
23
+ import type { TaskHistoryEntry } from './task-activity-section';
24
+
25
+ interface TaskDescriptionChangeDialogProps {
26
+ canRestore?: boolean;
27
+ entry: TaskHistoryEntry;
28
+ isOpen: boolean;
29
+ locale?: string;
30
+ onClose: () => void;
31
+ onRestoreVersion: (
32
+ version: RecoverableTaskDescriptionVersion
33
+ ) => Promise<void> | void;
34
+ restoringVersionId?: string | null;
35
+ t: (
36
+ key: string,
37
+ options?: { count?: number; defaultValue?: string }
38
+ ) => string;
39
+ }
40
+
41
+ export function TaskDescriptionChangeDialog({
42
+ canRestore = true,
43
+ entry,
44
+ isOpen,
45
+ locale = 'en',
46
+ onClose,
47
+ onRestoreVersion,
48
+ restoringVersionId,
49
+ t,
50
+ }: TaskDescriptionChangeDialogProps) {
51
+ const dateLocale = locale === 'vi' ? vi : enUS;
52
+ const versions = useMemo(
53
+ () => buildRecoverableTaskDescriptionVersions([entry]),
54
+ [entry]
55
+ );
56
+ const oldValue = useMemo(
57
+ () => extractRecoverableTaskDescriptionValue(entry.old_value),
58
+ [entry.old_value]
59
+ );
60
+ const newValue = useMemo(
61
+ () => extractRecoverableTaskDescriptionValue(entry.new_value),
62
+ [entry.new_value]
63
+ );
64
+ const time = new Date(entry.changed_at);
65
+ const exactTime = format(time, 'PPpp', { locale: dateLocale });
66
+ const relativeTime = formatDistanceToNow(time, {
67
+ addSuffix: true,
68
+ locale: dateLocale,
69
+ });
70
+
71
+ return (
72
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
73
+ <DialogContent className="grid max-h-[85vh] w-full max-w-2xl grid-rows-[auto_1fr] overflow-hidden md:max-w-3xl">
74
+ <DialogHeader>
75
+ <div className="flex items-center gap-2">
76
+ <div className="flex h-8 w-8 items-center justify-center rounded-full bg-dynamic-purple/10">
77
+ <FileText className="h-4 w-4 text-dynamic-purple" />
78
+ </div>
79
+ <div>
80
+ <DialogTitle>
81
+ {t('description_change_title', {
82
+ defaultValue: 'Description change',
83
+ })}
84
+ </DialogTitle>
85
+ <DialogDescription className="text-xs">
86
+ <span title={exactTime}>{relativeTime}</span>
87
+ {' - '}
88
+ {entry.user?.name ||
89
+ t('unknown_user', { defaultValue: 'Unknown user' })}
90
+ </DialogDescription>
91
+ </div>
92
+ </div>
93
+ </DialogHeader>
94
+
95
+ <ScrollArea className="-mx-6 min-h-0 px-6">
96
+ <div className="space-y-4 py-4">
97
+ <div className="grid gap-3 md:grid-cols-2">
98
+ <DescriptionValuePreview
99
+ emptyLabel={t('empty_description_value', {
100
+ defaultValue: 'Empty or not restorable',
101
+ })}
102
+ label={t('previous_description_value', {
103
+ defaultValue: 'Previous',
104
+ })}
105
+ preview={oldValue?.previewText}
106
+ />
107
+ <DescriptionValuePreview
108
+ emptyLabel={t('empty_description_value', {
109
+ defaultValue: 'Empty or not restorable',
110
+ })}
111
+ label={t('resulting_description_value', {
112
+ defaultValue: 'Resulting',
113
+ })}
114
+ preview={newValue?.previewText}
115
+ />
116
+ </div>
117
+
118
+ <div className="flex flex-wrap items-center gap-2">
119
+ <DescriptionDiffViewer
120
+ newValue={entry.new_value}
121
+ oldValue={entry.old_value}
122
+ t={t}
123
+ trigger={
124
+ <Button
125
+ className="h-8 gap-1.5 px-2.5 text-xs"
126
+ size="sm"
127
+ type="button"
128
+ variant="outline"
129
+ >
130
+ <Eye className="h-3.5 w-3.5" />
131
+ {t('view_description_diff', {
132
+ defaultValue: 'View diff',
133
+ })}
134
+ </Button>
135
+ }
136
+ />
137
+ <span
138
+ className="inline-flex items-center gap-1 text-muted-foreground text-xs"
139
+ title={exactTime}
140
+ >
141
+ <Clock className="h-3.5 w-3.5" />
142
+ {relativeTime}
143
+ </span>
144
+ </div>
145
+
146
+ {canRestore && (
147
+ <div className="space-y-2">
148
+ <p className="font-medium text-sm">
149
+ {t('restore_from_change', {
150
+ defaultValue: 'Restore from this change',
151
+ })}
152
+ </p>
153
+ {versions.length === 0 ? (
154
+ <div className="rounded-md border border-dashed p-3 text-muted-foreground text-sm">
155
+ {t('no_recoverable_description_versions', {
156
+ defaultValue:
157
+ 'No restorable description versions were found in history.',
158
+ })}
159
+ </div>
160
+ ) : (
161
+ <div className="flex flex-wrap gap-2">
162
+ {versions.map((version) => {
163
+ const isRestoring = restoringVersionId === version.id;
164
+ return (
165
+ <Button
166
+ className="h-8 gap-1.5 px-2.5 text-xs"
167
+ disabled={!!restoringVersionId}
168
+ key={version.id}
169
+ onClick={() => onRestoreVersion(version)}
170
+ size="sm"
171
+ type="button"
172
+ >
173
+ {isRestoring ? (
174
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
175
+ ) : (
176
+ <RotateCcw className="h-3.5 w-3.5" />
177
+ )}
178
+ {version.source === 'old_value'
179
+ ? t('restore_previous_description', {
180
+ defaultValue: 'Restore previous',
181
+ })
182
+ : t('restore_resulting_description', {
183
+ defaultValue: 'Restore resulting',
184
+ })}
185
+ </Button>
186
+ );
187
+ })}
188
+ </div>
189
+ )}
190
+ </div>
191
+ )}
192
+ </div>
193
+ </ScrollArea>
194
+ </DialogContent>
195
+ </Dialog>
196
+ );
197
+ }
198
+
199
+ function DescriptionValuePreview({
200
+ emptyLabel,
201
+ label,
202
+ preview,
203
+ }: {
204
+ emptyLabel: string;
205
+ label: string;
206
+ preview?: string;
207
+ }) {
208
+ return (
209
+ <div className="rounded-md border bg-muted/20 p-3">
210
+ <div className="mb-2 flex items-center justify-between gap-2">
211
+ <Badge variant="secondary">{label}</Badge>
212
+ </div>
213
+ <p className="line-clamp-4 whitespace-pre-wrap text-sm">
214
+ {preview || <span className="text-muted-foreground">{emptyLabel}</span>}
215
+ </p>
216
+ </div>
217
+ );
218
+ }
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import { AlertTriangle, History, Loader2, RotateCcw } from '@tuturuuu/icons';
4
+ import { Button } from '@tuturuuu/ui/button';
5
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
6
+
7
+ interface TaskDescriptionRestoreBannerProps {
8
+ latestVersion: RecoverableTaskDescriptionVersion | null;
9
+ versionCount: number;
10
+ isRestoring: boolean;
11
+ onRestoreLatest: () => void;
12
+ onViewVersions: () => void;
13
+ t: (
14
+ key: string,
15
+ options?: { count?: number; defaultValue?: string }
16
+ ) => string;
17
+ }
18
+
19
+ export function TaskDescriptionRestoreBanner({
20
+ latestVersion,
21
+ versionCount,
22
+ isRestoring,
23
+ onRestoreLatest,
24
+ onViewVersions,
25
+ t,
26
+ }: TaskDescriptionRestoreBannerProps) {
27
+ if (!latestVersion) return null;
28
+
29
+ return (
30
+ <div className="mx-4 mb-3 rounded-md border border-dynamic-orange/30 bg-dynamic-orange/5 p-3 md:mx-8">
31
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
32
+ <div className="flex min-w-0 gap-2">
33
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-dynamic-orange" />
34
+ <div className="min-w-0 space-y-1">
35
+ <p className="font-medium text-sm">
36
+ {t('description_restore_banner_title', {
37
+ defaultValue: 'A tracked description version is available',
38
+ })}
39
+ </p>
40
+ <p className="text-muted-foreground text-xs">
41
+ {t('description_restore_banner_description', {
42
+ count: versionCount,
43
+ defaultValue:
44
+ 'Current content differs from the latest tracked version. Restore it or compare all tracked versions.',
45
+ })}
46
+ </p>
47
+ </div>
48
+ </div>
49
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
50
+ <Button
51
+ className="h-8 gap-1.5 px-2.5 text-xs"
52
+ disabled={isRestoring}
53
+ onClick={onRestoreLatest}
54
+ size="sm"
55
+ type="button"
56
+ variant="default"
57
+ >
58
+ {isRestoring ? (
59
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
60
+ ) : (
61
+ <RotateCcw className="h-3.5 w-3.5" />
62
+ )}
63
+ {t('restore_latest_tracked', {
64
+ defaultValue: 'Restore latest tracked',
65
+ })}
66
+ </Button>
67
+ <Button
68
+ className="h-8 gap-1.5 px-2.5 text-xs"
69
+ onClick={onViewVersions}
70
+ size="sm"
71
+ type="button"
72
+ variant="outline"
73
+ >
74
+ <History className="h-3.5 w-3.5" />
75
+ {t('view_description_versions', {
76
+ defaultValue: 'View versions',
77
+ })}
78
+ </Button>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,120 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import type { ReactNode } from 'react';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
7
+ import { TaskDescriptionRestoreBanner } from './task-description-restore-banner';
8
+ import { TaskDescriptionVersionRestoreDialog } from './task-description-version-restore-dialog';
9
+
10
+ vi.mock('./description-diff-viewer', () => ({
11
+ DescriptionDiffViewer: ({ trigger }: { trigger?: ReactNode }) =>
12
+ trigger ?? <button type="button">compare</button>,
13
+ }));
14
+
15
+ const t = (_key: string, options?: { count?: number; defaultValue?: string }) =>
16
+ options?.defaultValue ?? _key;
17
+
18
+ const makeVersion = (
19
+ overrides: Partial<RecoverableTaskDescriptionVersion> = {}
20
+ ): RecoverableTaskDescriptionVersion => ({
21
+ id: 'history-1:new_value',
22
+ historyId: 'history-1',
23
+ changedAt: '2026-06-27T00:00:00.000Z',
24
+ source: 'new_value',
25
+ reason: 'tracked',
26
+ description:
27
+ '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Tracked description"}]}]}',
28
+ content: {
29
+ type: 'doc',
30
+ content: [
31
+ {
32
+ type: 'paragraph',
33
+ content: [{ type: 'text', text: 'Tracked description' }],
34
+ },
35
+ ],
36
+ },
37
+ previewText: 'Tracked description',
38
+ user: { id: 'user-1', name: 'User' },
39
+ ...overrides,
40
+ });
41
+
42
+ describe('task description restore UI', () => {
43
+ it('shows the recovery banner and wires restore/view actions', () => {
44
+ const onRestoreLatest = vi.fn();
45
+ const onViewVersions = vi.fn();
46
+
47
+ render(
48
+ <TaskDescriptionRestoreBanner
49
+ isRestoring={false}
50
+ latestVersion={makeVersion()}
51
+ onRestoreLatest={onRestoreLatest}
52
+ onViewVersions={onViewVersions}
53
+ t={t}
54
+ versionCount={1}
55
+ />
56
+ );
57
+
58
+ fireEvent.click(
59
+ screen.getByRole('button', { name: /restore latest tracked/i })
60
+ );
61
+ fireEvent.click(screen.getByRole('button', { name: /view versions/i }));
62
+
63
+ expect(onRestoreLatest).toHaveBeenCalledTimes(1);
64
+ expect(onViewVersions).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it('does not show the recovery banner without a newer tracked version', () => {
68
+ const { container } = render(
69
+ <TaskDescriptionRestoreBanner
70
+ isRestoring={false}
71
+ latestVersion={null}
72
+ onRestoreLatest={vi.fn()}
73
+ onViewVersions={vi.fn()}
74
+ t={t}
75
+ versionCount={0}
76
+ />
77
+ );
78
+
79
+ expect(container).toBeEmptyDOMElement();
80
+ });
81
+
82
+ it('shows an empty version picker state when history has no recoverable content', () => {
83
+ render(
84
+ <TaskDescriptionVersionRestoreDialog
85
+ currentDescription={null}
86
+ isOpen
87
+ onClose={vi.fn()}
88
+ onRestoreVersion={vi.fn()}
89
+ t={t}
90
+ versions={[]}
91
+ />
92
+ );
93
+
94
+ expect(
95
+ screen.getByText(/no restorable description versions were found/i)
96
+ ).toBeInTheDocument();
97
+ });
98
+
99
+ it('restores a selected version from the version picker', () => {
100
+ const onRestoreVersion = vi.fn();
101
+ const version = makeVersion();
102
+
103
+ render(
104
+ <TaskDescriptionVersionRestoreDialog
105
+ currentDescription={null}
106
+ isOpen
107
+ onClose={vi.fn()}
108
+ onRestoreVersion={onRestoreVersion}
109
+ t={t}
110
+ versions={[version]}
111
+ />
112
+ );
113
+
114
+ expect(screen.getByText('Tracked description')).toBeInTheDocument();
115
+
116
+ fireEvent.click(screen.getByRole('button', { name: /^restore$/i }));
117
+
118
+ expect(onRestoreVersion).toHaveBeenCalledWith(version);
119
+ });
120
+ });
@@ -0,0 +1,180 @@
1
+ 'use client';
2
+
3
+ import { Clock, Eye, Loader2, RotateCcw } 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
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@tuturuuu/ui/dialog';
13
+ import { ScrollArea } from '@tuturuuu/ui/scroll-area';
14
+ import { format, formatDistanceToNow } from 'date-fns';
15
+ import { enUS, vi } from 'date-fns/locale';
16
+ import { DescriptionDiffViewer } from './description-diff-viewer';
17
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
18
+
19
+ interface TaskDescriptionVersionRestoreDialogProps {
20
+ currentDescription: string | null;
21
+ isOpen: boolean;
22
+ locale?: string;
23
+ onClose: () => void;
24
+ onRestoreVersion: (
25
+ version: RecoverableTaskDescriptionVersion
26
+ ) => Promise<void> | void;
27
+ restoringVersionId?: string | null;
28
+ t: (
29
+ key: string,
30
+ options?: { count?: number; defaultValue?: string }
31
+ ) => string;
32
+ versions: RecoverableTaskDescriptionVersion[];
33
+ }
34
+
35
+ export function TaskDescriptionVersionRestoreDialog({
36
+ currentDescription,
37
+ isOpen,
38
+ locale = 'en',
39
+ onClose,
40
+ onRestoreVersion,
41
+ restoringVersionId,
42
+ t,
43
+ versions,
44
+ }: TaskDescriptionVersionRestoreDialogProps) {
45
+ const dateLocale = locale === 'vi' ? vi : enUS;
46
+
47
+ return (
48
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
49
+ <DialogContent className="grid max-h-[85vh] w-full max-w-2xl grid-rows-[auto_1fr] overflow-hidden md:max-w-3xl">
50
+ <DialogHeader>
51
+ <DialogTitle>
52
+ {t('description_versions_title', {
53
+ defaultValue: 'Description versions',
54
+ })}
55
+ </DialogTitle>
56
+ <DialogDescription>
57
+ {t('description_versions_description', {
58
+ defaultValue:
59
+ 'Compare tracked description versions and restore the one that should be current.',
60
+ })}
61
+ </DialogDescription>
62
+ </DialogHeader>
63
+
64
+ <ScrollArea className="-mx-6 min-h-0 px-6">
65
+ <div className="space-y-3 py-4">
66
+ {versions.length === 0 ? (
67
+ <div className="rounded-md border border-dashed p-4 text-center text-muted-foreground text-sm">
68
+ {t('no_recoverable_description_versions', {
69
+ defaultValue:
70
+ 'No restorable description versions were found in history.',
71
+ })}
72
+ </div>
73
+ ) : (
74
+ versions.map((version, index) => {
75
+ const isRestoring = restoringVersionId === version.id;
76
+ const time = new Date(version.changedAt);
77
+ const exactTime = format(time, 'PPpp', {
78
+ locale: dateLocale,
79
+ });
80
+ const relativeTime = formatDistanceToNow(time, {
81
+ addSuffix: true,
82
+ locale: dateLocale,
83
+ });
84
+
85
+ return (
86
+ <div
87
+ className="rounded-md border bg-card p-3"
88
+ key={version.id}
89
+ >
90
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
91
+ <div className="min-w-0 space-y-2">
92
+ <div className="flex flex-wrap items-center gap-2">
93
+ {index === 0 && (
94
+ <Badge variant="secondary">
95
+ {t('latest_version', {
96
+ defaultValue: 'Latest',
97
+ })}
98
+ </Badge>
99
+ )}
100
+ {version.reason === 'before_clear' && (
101
+ <Badge
102
+ className="border-dynamic-orange/30 text-dynamic-orange"
103
+ variant="outline"
104
+ >
105
+ {t('before_clear_version', {
106
+ defaultValue: 'Before clear',
107
+ })}
108
+ </Badge>
109
+ )}
110
+ <span
111
+ className="inline-flex items-center gap-1 text-muted-foreground text-xs"
112
+ title={exactTime}
113
+ >
114
+ <Clock className="h-3.5 w-3.5" />
115
+ {relativeTime}
116
+ </span>
117
+ </div>
118
+ <p className="line-clamp-2 text-sm">
119
+ {version.previewText ||
120
+ t('description_version_no_preview', {
121
+ defaultValue: 'No text preview',
122
+ })}
123
+ </p>
124
+ {version.user?.name && (
125
+ <p className="text-muted-foreground text-xs">
126
+ {t('tracked_by_user', {
127
+ defaultValue: 'Tracked by',
128
+ })}
129
+ {` ${version.user.name}`}
130
+ </p>
131
+ )}
132
+ </div>
133
+
134
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
135
+ <DescriptionDiffViewer
136
+ newValue={version.description}
137
+ oldValue={currentDescription}
138
+ t={t}
139
+ trigger={
140
+ <Button
141
+ className="h-8 gap-1.5 px-2.5 text-xs"
142
+ size="sm"
143
+ type="button"
144
+ variant="outline"
145
+ >
146
+ <Eye className="h-3.5 w-3.5" />
147
+ {t('compare_version', {
148
+ defaultValue: 'Compare',
149
+ })}
150
+ </Button>
151
+ }
152
+ />
153
+ <Button
154
+ className="h-8 gap-1.5 px-2.5 text-xs"
155
+ disabled={!!restoringVersionId}
156
+ onClick={() => onRestoreVersion(version)}
157
+ size="sm"
158
+ type="button"
159
+ >
160
+ {isRestoring ? (
161
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
162
+ ) : (
163
+ <RotateCcw className="h-3.5 w-3.5" />
164
+ )}
165
+ {t('restore_version', {
166
+ defaultValue: 'Restore',
167
+ })}
168
+ </Button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ );
173
+ })
174
+ )}
175
+ </div>
176
+ </ScrollArea>
177
+ </DialogContent>
178
+ </Dialog>
179
+ );
180
+ }
@@ -48,6 +48,8 @@ interface TaskDialogActionsProps {
48
48
  onOpenShareDialog?: () => void;
49
49
  disabled?: boolean;
50
50
  controlsDisabled?: boolean;
51
+ moreMenuOpen?: boolean;
52
+ onMoreMenuOpenChange?: (open: boolean) => void;
51
53
  }
52
54
 
53
55
  export function TaskDialogActions({
@@ -66,10 +68,15 @@ export function TaskDialogActions({
66
68
  onOpenShareDialog,
67
69
  disabled = false,
68
70
  controlsDisabled = false,
71
+ moreMenuOpen,
72
+ onMoreMenuOpenChange,
69
73
  }: TaskDialogActionsProps) {
70
74
  const t = useTranslations();
71
75
  const tasksHref = useTasksHref();
72
- const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false);
76
+ const [uncontrolledMoreMenuOpen, setUncontrolledMoreMenuOpen] =
77
+ useState(false);
78
+ const isMoreMenuOpen = moreMenuOpen ?? uncontrolledMoreMenuOpen;
79
+ const setIsMoreMenuOpen = onMoreMenuOpenChange ?? setUncontrolledMoreMenuOpen;
73
80
 
74
81
  // Determine if we should show the back button (create mode with a pending relationship)
75
82
  const showBackButton = isCreateMode && onNavigateBack && navigateBackTaskName;