@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.
- package/CHANGELOG.md +25 -0
- package/package.json +47 -47
- 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/text-editor/__tests__/extensions.test.ts +22 -0
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -0
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +37 -0
- package/src/components/ui/tu-do/shared/board-client.tsx +3 -1
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +55 -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-list-selector.tsx +18 -2
- 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/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-properties-section.test.tsx +157 -4
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +178 -14
- 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 +381 -34
- 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.test.tsx +52 -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
|
@@ -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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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={() =>
|
|
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>
|
package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx
ADDED
|
@@ -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
|
+
}
|