@tuturuuu/ui 0.10.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 +16 -0
- package/package.json +4 -4
- 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/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/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/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.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,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
|
+
}
|
|
@@ -17,12 +17,16 @@ vi.mock('./hooks/task-api', () => ({
|
|
|
17
17
|
import {
|
|
18
18
|
broadcastTaskDescriptionUpsert,
|
|
19
19
|
buildTaskDescriptionUpdatePayload,
|
|
20
|
+
canPersistTaskDescriptionSnapshot,
|
|
21
|
+
createTaskDescriptionPersistenceGuardState,
|
|
20
22
|
getTaskDescriptionPercentLeft,
|
|
21
23
|
getTaskDescriptionPreviewText,
|
|
22
24
|
getTaskDescriptionStorageLength,
|
|
25
|
+
recordTaskDescriptionEditorSnapshot,
|
|
23
26
|
saveAndVerifyYjsDescriptionToDatabase,
|
|
24
27
|
saveYjsDescriptionToDatabase,
|
|
25
28
|
serializeTaskDescriptionContent,
|
|
29
|
+
serializeTaskDescriptionPersistenceSnapshot,
|
|
26
30
|
updateTaskDescriptionCaches,
|
|
27
31
|
} from './utils';
|
|
28
32
|
|
|
@@ -36,6 +40,10 @@ describe('task edit dialog utils', () => {
|
|
|
36
40
|
},
|
|
37
41
|
],
|
|
38
42
|
};
|
|
43
|
+
const emptyContent: JSONContent = {
|
|
44
|
+
type: 'doc',
|
|
45
|
+
content: [{ type: 'paragraph' }],
|
|
46
|
+
};
|
|
39
47
|
|
|
40
48
|
beforeEach(() => {
|
|
41
49
|
vi.resetAllMocks();
|
|
@@ -47,6 +55,98 @@ describe('task edit dialog utils', () => {
|
|
|
47
55
|
);
|
|
48
56
|
});
|
|
49
57
|
|
|
58
|
+
it('blocks untrusted empty snapshots from clearing persisted content', () => {
|
|
59
|
+
const guardState = createTaskDescriptionPersistenceGuardState({
|
|
60
|
+
persistedDescription: JSON.stringify(content),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
canPersistTaskDescriptionSnapshot({
|
|
65
|
+
currentSerializedDescription: null,
|
|
66
|
+
guardState,
|
|
67
|
+
})
|
|
68
|
+
).toBe(false);
|
|
69
|
+
expect(
|
|
70
|
+
canPersistTaskDescriptionSnapshot({
|
|
71
|
+
currentSerializedDescription:
|
|
72
|
+
serializeTaskDescriptionPersistenceSnapshot(emptyContent),
|
|
73
|
+
guardState,
|
|
74
|
+
})
|
|
75
|
+
).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('allows no-op empty saves when the persisted description is already empty', () => {
|
|
79
|
+
const guardState = createTaskDescriptionPersistenceGuardState({
|
|
80
|
+
persistedDescription: null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(
|
|
84
|
+
canPersistTaskDescriptionSnapshot({
|
|
85
|
+
currentSerializedDescription: null,
|
|
86
|
+
guardState,
|
|
87
|
+
})
|
|
88
|
+
).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('allows confirmed intentional clears after observing non-empty editor content', () => {
|
|
92
|
+
const initialGuardState = createTaskDescriptionPersistenceGuardState({
|
|
93
|
+
persistedDescription: JSON.stringify(content),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const nonEmptySeenState = recordTaskDescriptionEditorSnapshot(
|
|
97
|
+
initialGuardState,
|
|
98
|
+
content
|
|
99
|
+
);
|
|
100
|
+
const confirmedClearState = recordTaskDescriptionEditorSnapshot(
|
|
101
|
+
nonEmptySeenState,
|
|
102
|
+
null,
|
|
103
|
+
{ canConfirmEmptySnapshot: true }
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(
|
|
107
|
+
canPersistTaskDescriptionSnapshot({
|
|
108
|
+
currentSerializedDescription: null,
|
|
109
|
+
guardState: confirmedClearState,
|
|
110
|
+
})
|
|
111
|
+
).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not confirm empty snapshots before editor clears are trusted', () => {
|
|
115
|
+
const initialGuardState = createTaskDescriptionPersistenceGuardState({
|
|
116
|
+
persistedDescription: JSON.stringify(content),
|
|
117
|
+
});
|
|
118
|
+
const nonEmptySeenState = recordTaskDescriptionEditorSnapshot(
|
|
119
|
+
initialGuardState,
|
|
120
|
+
content
|
|
121
|
+
);
|
|
122
|
+
const untrustedEmptyState = recordTaskDescriptionEditorSnapshot(
|
|
123
|
+
nonEmptySeenState,
|
|
124
|
+
null,
|
|
125
|
+
{ canConfirmEmptySnapshot: false }
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(
|
|
129
|
+
canPersistTaskDescriptionSnapshot({
|
|
130
|
+
currentSerializedDescription: null,
|
|
131
|
+
guardState: untrustedEmptyState,
|
|
132
|
+
})
|
|
133
|
+
).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('allows non-empty saves even when the guard has not observed hydration', () => {
|
|
137
|
+
const guardState = createTaskDescriptionPersistenceGuardState({
|
|
138
|
+
persistedDescription: JSON.stringify(content),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(
|
|
142
|
+
canPersistTaskDescriptionSnapshot({
|
|
143
|
+
currentSerializedDescription:
|
|
144
|
+
serializeTaskDescriptionPersistenceSnapshot(content),
|
|
145
|
+
guardState,
|
|
146
|
+
})
|
|
147
|
+
).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
50
150
|
it('returns zero for empty description storage length', () => {
|
|
51
151
|
expect(getTaskDescriptionStorageLength(null)).toBe(0);
|
|
52
152
|
});
|
|
@@ -45,6 +45,115 @@ export function serializeTaskDescriptionContent(
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function hasTaskDescriptionContent(content: JSONContent | null): boolean {
|
|
49
|
+
if (!content) return false;
|
|
50
|
+
|
|
51
|
+
if (content.text && content.text.trim().length > 0) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
content.type &&
|
|
57
|
+
['image', 'imageResize', 'youtube', 'video', 'mention', 'table'].includes(
|
|
58
|
+
content.type
|
|
59
|
+
)
|
|
60
|
+
) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return content.content?.some(hasTaskDescriptionContent) ?? false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isSerializedTaskDescriptionEmpty(description: string | null): boolean {
|
|
68
|
+
if (description === null) return true;
|
|
69
|
+
if (description.trim().length === 0) return true;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return !hasTaskDescriptionContent(JSON.parse(description) as JSONContent);
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeTaskDescriptionSnapshot(
|
|
79
|
+
content: JSONContent | null
|
|
80
|
+
): JSONContent | null {
|
|
81
|
+
return hasTaskDescriptionContent(content) ? content : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function serializeTaskDescriptionPersistenceSnapshot(
|
|
85
|
+
content: JSONContent | null
|
|
86
|
+
): string | null {
|
|
87
|
+
return serializeTaskDescriptionContent(
|
|
88
|
+
normalizeTaskDescriptionSnapshot(content)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type TaskDescriptionPersistenceGuardState = {
|
|
93
|
+
persistedDescription: string | null;
|
|
94
|
+
hasSeenNonEmptyEditorSnapshot: boolean;
|
|
95
|
+
hasConfirmedEmptyEditorSnapshot: boolean;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export function createTaskDescriptionPersistenceGuardState({
|
|
99
|
+
persistedDescription,
|
|
100
|
+
trustPersistedDescription = false,
|
|
101
|
+
}: {
|
|
102
|
+
persistedDescription: string | null;
|
|
103
|
+
trustPersistedDescription?: boolean;
|
|
104
|
+
}): TaskDescriptionPersistenceGuardState {
|
|
105
|
+
const persistedDescriptionIsEmpty =
|
|
106
|
+
isSerializedTaskDescriptionEmpty(persistedDescription);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
persistedDescription,
|
|
110
|
+
hasSeenNonEmptyEditorSnapshot:
|
|
111
|
+
trustPersistedDescription && !persistedDescriptionIsEmpty,
|
|
112
|
+
hasConfirmedEmptyEditorSnapshot: persistedDescriptionIsEmpty,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function recordTaskDescriptionEditorSnapshot(
|
|
117
|
+
state: TaskDescriptionPersistenceGuardState,
|
|
118
|
+
content: JSONContent | null,
|
|
119
|
+
options: { canConfirmEmptySnapshot?: boolean } = {}
|
|
120
|
+
): TaskDescriptionPersistenceGuardState {
|
|
121
|
+
const serializedDescription =
|
|
122
|
+
serializeTaskDescriptionPersistenceSnapshot(content);
|
|
123
|
+
|
|
124
|
+
if (serializedDescription !== null) {
|
|
125
|
+
return {
|
|
126
|
+
...state,
|
|
127
|
+
hasSeenNonEmptyEditorSnapshot: true,
|
|
128
|
+
hasConfirmedEmptyEditorSnapshot: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (options.canConfirmEmptySnapshot && state.hasSeenNonEmptyEditorSnapshot) {
|
|
133
|
+
return {
|
|
134
|
+
...state,
|
|
135
|
+
hasConfirmedEmptyEditorSnapshot: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return state;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function canPersistTaskDescriptionSnapshot({
|
|
143
|
+
currentSerializedDescription,
|
|
144
|
+
guardState,
|
|
145
|
+
}: {
|
|
146
|
+
currentSerializedDescription: string | null;
|
|
147
|
+
guardState: TaskDescriptionPersistenceGuardState;
|
|
148
|
+
}): boolean {
|
|
149
|
+
if (currentSerializedDescription !== null) return true;
|
|
150
|
+
if (isSerializedTaskDescriptionEmpty(guardState.persistedDescription)) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return guardState.hasConfirmedEmptyEditorSnapshot;
|
|
155
|
+
}
|
|
156
|
+
|
|
48
157
|
export function getTaskDescriptionStorageLength(
|
|
49
158
|
content: JSONContent | null
|
|
50
159
|
): number {
|