@tuturuuu/ui 0.10.0 → 0.11.1

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 (41) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +47 -47
  3. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  4. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  5. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  6. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  7. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  8. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  9. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  10. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  11. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  12. package/src/components/ui/finance/invoices/utils.ts +240 -7
  13. package/src/components/ui/text-editor/__tests__/extensions.test.ts +22 -0
  14. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -0
  15. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +37 -0
  16. package/src/components/ui/tu-do/shared/board-client.tsx +3 -1
  17. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +55 -1
  18. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  19. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +18 -2
  20. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  21. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  22. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  23. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  24. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  25. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  26. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  27. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  28. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +157 -4
  29. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +178 -14
  30. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  31. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  32. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +381 -34
  33. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  34. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  35. package/src/components/ui/tu-do/templates/task-template-client.test.tsx +52 -0
  36. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  37. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  38. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  39. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  40. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  41. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
@@ -0,0 +1,210 @@
1
+ import type { JSONContent } from '@tiptap/react';
2
+ import {
3
+ getDescriptionContent,
4
+ getTaskDescriptionPreviewText,
5
+ normalizeTaskDescriptionSnapshot,
6
+ serializeTaskDescriptionPersistenceSnapshot,
7
+ } from './utils';
8
+
9
+ export interface TaskDescriptionHistoryUser {
10
+ id: string;
11
+ name: string;
12
+ avatar_url?: string | null;
13
+ }
14
+
15
+ export interface TaskDescriptionHistoryEntryLike {
16
+ id: string;
17
+ changed_at: string;
18
+ change_type?: string | null;
19
+ field_name?: string | null;
20
+ old_value?: unknown;
21
+ new_value?: unknown;
22
+ user?: TaskDescriptionHistoryUser | null;
23
+ }
24
+
25
+ export type TaskDescriptionVersionSource = 'new_value' | 'old_value';
26
+ export type TaskDescriptionVersionReason = 'before_clear' | 'tracked';
27
+
28
+ export interface RecoverableTaskDescriptionVersion {
29
+ id: string;
30
+ historyId: string;
31
+ changedAt: string;
32
+ source: TaskDescriptionVersionSource;
33
+ reason: TaskDescriptionVersionReason;
34
+ description: string;
35
+ content: JSONContent;
36
+ previewText: string;
37
+ user: TaskDescriptionHistoryUser | null;
38
+ }
39
+
40
+ const LEGACY_DESCRIPTION_PLACEHOLDERS = new Set(['has_content']);
41
+
42
+ function isLegacyDescriptionPlaceholder(value: string) {
43
+ return LEGACY_DESCRIPTION_PLACEHOLDERS.has(value.trim().toLowerCase());
44
+ }
45
+
46
+ function hasObjectShape(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ function textToDescriptionContent(text: string): JSONContent {
51
+ return {
52
+ type: 'doc',
53
+ content: [
54
+ {
55
+ type: 'paragraph',
56
+ content: [{ type: 'text', text }],
57
+ },
58
+ ],
59
+ };
60
+ }
61
+
62
+ function coerceDescriptionValueToContent(value: unknown): JSONContent | null {
63
+ if (value === null || value === undefined) return null;
64
+
65
+ if (typeof value === 'string') {
66
+ const trimmed = value.trim();
67
+ if (!trimmed || isLegacyDescriptionPlaceholder(trimmed)) return null;
68
+
69
+ try {
70
+ const parsed = JSON.parse(trimmed) as unknown;
71
+
72
+ if (typeof parsed === 'string') {
73
+ const parsedText = parsed.trim();
74
+ if (!parsedText || isLegacyDescriptionPlaceholder(parsedText)) {
75
+ return null;
76
+ }
77
+ return textToDescriptionContent(parsed);
78
+ }
79
+
80
+ if (hasObjectShape(parsed)) {
81
+ return getDescriptionContent(parsed);
82
+ }
83
+
84
+ return null;
85
+ } catch {
86
+ return textToDescriptionContent(value);
87
+ }
88
+ }
89
+
90
+ if (hasObjectShape(value)) {
91
+ return getDescriptionContent(value);
92
+ }
93
+
94
+ return null;
95
+ }
96
+
97
+ export function extractRecoverableTaskDescriptionValue(value: unknown): {
98
+ content: JSONContent;
99
+ description: string;
100
+ previewText: string;
101
+ } | null {
102
+ const content = normalizeTaskDescriptionSnapshot(
103
+ coerceDescriptionValueToContent(value)
104
+ );
105
+ if (!content) return null;
106
+
107
+ const description = serializeTaskDescriptionPersistenceSnapshot(content);
108
+ if (!description) return null;
109
+
110
+ return {
111
+ content,
112
+ description,
113
+ previewText: getTaskDescriptionPreviewText(content).trim(),
114
+ };
115
+ }
116
+
117
+ export function isTaskDescriptionHistoryEntry(
118
+ entry: TaskDescriptionHistoryEntryLike
119
+ ) {
120
+ return (
121
+ entry.change_type === 'field_updated' && entry.field_name === 'description'
122
+ );
123
+ }
124
+
125
+ export function isTaskDescriptionHistoryValueEmpty(value: unknown) {
126
+ return extractRecoverableTaskDescriptionValue(value) === null;
127
+ }
128
+
129
+ function addDescriptionVersion({
130
+ entry,
131
+ reason,
132
+ seenDescriptions,
133
+ source,
134
+ value,
135
+ versions,
136
+ }: {
137
+ entry: TaskDescriptionHistoryEntryLike;
138
+ reason: TaskDescriptionVersionReason;
139
+ seenDescriptions: Set<string>;
140
+ source: TaskDescriptionVersionSource;
141
+ value: unknown;
142
+ versions: RecoverableTaskDescriptionVersion[];
143
+ }) {
144
+ const recoverableValue = extractRecoverableTaskDescriptionValue(value);
145
+ if (!recoverableValue) return;
146
+ if (seenDescriptions.has(recoverableValue.description)) return;
147
+
148
+ seenDescriptions.add(recoverableValue.description);
149
+ versions.push({
150
+ id: `${entry.id}:${source}`,
151
+ historyId: entry.id,
152
+ changedAt: entry.changed_at,
153
+ source,
154
+ reason,
155
+ description: recoverableValue.description,
156
+ content: recoverableValue.content,
157
+ previewText: recoverableValue.previewText,
158
+ user: entry.user ?? null,
159
+ });
160
+ }
161
+
162
+ export function buildRecoverableTaskDescriptionVersions(
163
+ entries: TaskDescriptionHistoryEntryLike[]
164
+ ) {
165
+ const versions: RecoverableTaskDescriptionVersion[] = [];
166
+ const seenDescriptions = new Set<string>();
167
+ const sortedEntries = [...entries]
168
+ .filter(isTaskDescriptionHistoryEntry)
169
+ .sort(
170
+ (a, b) =>
171
+ new Date(b.changed_at).getTime() - new Date(a.changed_at).getTime()
172
+ );
173
+
174
+ for (const entry of sortedEntries) {
175
+ const oldIsRecoverable =
176
+ extractRecoverableTaskDescriptionValue(entry.old_value) !== null;
177
+ const newIsEmpty = isTaskDescriptionHistoryValueEmpty(entry.new_value);
178
+
179
+ if (oldIsRecoverable && newIsEmpty) {
180
+ addDescriptionVersion({
181
+ entry,
182
+ reason: 'before_clear',
183
+ seenDescriptions,
184
+ source: 'old_value',
185
+ value: entry.old_value,
186
+ versions,
187
+ });
188
+ continue;
189
+ }
190
+
191
+ addDescriptionVersion({
192
+ entry,
193
+ reason: 'tracked',
194
+ seenDescriptions,
195
+ source: 'new_value',
196
+ value: entry.new_value,
197
+ versions,
198
+ });
199
+ addDescriptionVersion({
200
+ entry,
201
+ reason: 'tracked',
202
+ seenDescriptions,
203
+ source: 'old_value',
204
+ value: entry.old_value,
205
+ versions,
206
+ });
207
+ }
208
+
209
+ return versions;
210
+ }
@@ -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
+ });
@@ -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
+ }