@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.
- package/CHANGELOG.md +45 -0
- package/package.json +6 -5
- package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
- package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
- package/src/components/ui/custom/nav-link.test.tsx +165 -0
- package/src/components/ui/custom/nav-link.tsx +69 -11
- package/src/components/ui/custom/navigation.tsx +1 -0
- package/src/components/ui/custom/settings/task-settings.tsx +104 -0
- package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
- package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
- package/src/components/ui/custom/settings-dialog-search.ts +75 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
- package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
- package/src/components/ui/custom/workspace-select.tsx +17 -16
- package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
- package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
- package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
- package/src/components/ui/finance/invoices/hooks.ts +11 -2
- package/src/components/ui/finance/invoices/internal-api.ts +5 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
- package/src/components/ui/finance/invoices/utils.test.ts +82 -0
- package/src/components/ui/finance/invoices/utils.ts +240 -7
- package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
- package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
- package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
- package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
- package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
- package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
- package/src/components/ui/tu-do/boards/form.tsx +1 -1
- package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
- package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
- package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
- package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
- package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
- package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +123 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
- package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +58 -0
- package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
- package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +16 -4
- package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
- package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
- package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
- package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
- package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +58 -4
- package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
- package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +390 -34
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
- package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
- package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
- package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
- package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
- package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
- package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
- package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
- package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
- package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
- package/src/hooks/useBoardPresence.ts +364 -0
- package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
- 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
|
+
});
|
package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx
ADDED
|
@@ -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 [
|
|
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;
|