@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
@@ -42,6 +42,10 @@ import { computeAccessibleLabelStyles } from '@tuturuuu/utils/label-colors';
42
42
  import dayjs from 'dayjs';
43
43
  import { useTranslations } from 'next-intl';
44
44
  import {
45
+ type ComponentProps,
46
+ cloneElement,
47
+ isValidElement,
48
+ type ReactElement,
45
49
  type ReactNode,
46
50
  useCallback,
47
51
  useEffect,
@@ -162,29 +166,75 @@ type TaskPropertyPopoverId =
162
166
  | 'assignees'
163
167
  | 'scheduling';
164
168
 
169
+ const TASK_PROPERTY_POPOVER_TRIGGER_ATTRIBUTE =
170
+ 'data-task-property-popover-trigger';
171
+
172
+ const taskPropertyPopoverIds = new Set<TaskPropertyPopoverId>([
173
+ 'priority',
174
+ 'list',
175
+ 'dates',
176
+ 'estimation',
177
+ 'labels',
178
+ 'projects',
179
+ 'assignees',
180
+ 'scheduling',
181
+ ]);
182
+
183
+ function isTaskPropertyPopoverId(
184
+ value: string | null
185
+ ): value is TaskPropertyPopoverId {
186
+ return !!value && taskPropertyPopoverIds.has(value as TaskPropertyPopoverId);
187
+ }
188
+
189
+ function getTaskPropertyPopoverIdFromTarget(
190
+ target: EventTarget | null
191
+ ): TaskPropertyPopoverId | null {
192
+ if (typeof Element === 'undefined' || !(target instanceof Element)) {
193
+ return null;
194
+ }
195
+
196
+ const trigger = target.closest(
197
+ `[${TASK_PROPERTY_POPOVER_TRIGGER_ATTRIBUTE}]`
198
+ );
199
+ const popoverId =
200
+ trigger?.getAttribute(TASK_PROPERTY_POPOVER_TRIGGER_ATTRIBUTE) ?? null;
201
+
202
+ return isTaskPropertyPopoverId(popoverId) ? popoverId : null;
203
+ }
204
+
165
205
  function TaskPropertyPopoverTrigger({
166
206
  children,
167
207
  compact,
168
208
  label,
209
+ popoverId,
169
210
  }: {
170
211
  children: ReactNode;
171
212
  compact: boolean;
172
213
  label: ReactNode;
214
+ popoverId: TaskPropertyPopoverId;
173
215
  }) {
216
+ const trigger = isValidElement(children)
217
+ ? cloneElement(children as ReactElement<Record<string, unknown>>, {
218
+ [TASK_PROPERTY_POPOVER_TRIGGER_ATTRIBUTE]: popoverId,
219
+ })
220
+ : children;
221
+
174
222
  if (!compact) {
175
- return <PopoverTrigger asChild>{children}</PopoverTrigger>;
223
+ return <PopoverTrigger asChild>{trigger}</PopoverTrigger>;
176
224
  }
177
225
 
178
226
  return (
179
227
  <Tooltip>
180
228
  <TooltipTrigger asChild>
181
- <PopoverTrigger asChild>{children}</PopoverTrigger>
229
+ <PopoverTrigger asChild>{trigger}</PopoverTrigger>
182
230
  </TooltipTrigger>
183
231
  <TooltipContent side="bottom">{label}</TooltipContent>
184
232
  </Tooltip>
185
233
  );
186
234
  }
187
235
 
236
+ type TaskPropertyPopoverContentProps = ComponentProps<typeof PopoverContent>;
237
+
188
238
  // Calendar hours type options
189
239
  const getCalendarHoursOptions = (t: any) => [
190
240
  {
@@ -422,16 +472,92 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
422
472
  [activePopover]
423
473
  );
424
474
 
475
+ const openPropertyPopover = useCallback(
476
+ (popoverId: TaskPropertyPopoverId) => {
477
+ setActivePopover(popoverId);
478
+ },
479
+ []
480
+ );
481
+
482
+ const closePropertyPopover = useCallback(
483
+ (popoverId: TaskPropertyPopoverId) => {
484
+ setActivePopover((currentPopover) =>
485
+ currentPopover === popoverId ? null : currentPopover
486
+ );
487
+ },
488
+ []
489
+ );
490
+
425
491
  const setPopoverOpen = useCallback(
426
492
  (popoverId: TaskPropertyPopoverId, open: boolean) => {
427
- setActivePopover((currentPopover) => {
428
- if (open) return popoverId;
429
- return currentPopover === popoverId ? null : currentPopover;
430
- });
493
+ if (open) {
494
+ openPropertyPopover(popoverId);
495
+ return;
496
+ }
497
+
498
+ closePropertyPopover(popoverId);
431
499
  },
432
- []
500
+ [closePropertyPopover, openPropertyPopover]
501
+ );
502
+
503
+ const handlePropertyPopoverCloseAutoFocus = useCallback<
504
+ NonNullable<TaskPropertyPopoverContentProps['onCloseAutoFocus']>
505
+ >((event) => {
506
+ event.preventDefault();
507
+ }, []);
508
+
509
+ const handlePropertyPopoverInteractOutside = useCallback<
510
+ NonNullable<TaskPropertyPopoverContentProps['onInteractOutside']>
511
+ >(
512
+ (event) => {
513
+ const targetPopoverId = getTaskPropertyPopoverIdFromTarget(event.target);
514
+
515
+ if (!targetPopoverId || targetPopoverId === activePopover) {
516
+ return;
517
+ }
518
+
519
+ event.preventDefault();
520
+
521
+ const openTargetPopover = () => {
522
+ openPropertyPopover(targetPopoverId);
523
+ };
524
+
525
+ if (typeof window !== 'undefined' && window.requestAnimationFrame) {
526
+ window.requestAnimationFrame(openTargetPopover);
527
+ return;
528
+ }
529
+
530
+ openTargetPopover();
531
+ },
532
+ [activePopover, openPropertyPopover]
433
533
  );
434
534
 
535
+ const propertyPopoverContentProps = useMemo(
536
+ () => ({
537
+ onCloseAutoFocus: handlePropertyPopoverCloseAutoFocus,
538
+ onInteractOutside: handlePropertyPopoverInteractOutside,
539
+ }),
540
+ [handlePropertyPopoverCloseAutoFocus, handlePropertyPopoverInteractOutside]
541
+ );
542
+
543
+ useEffect(() => {
544
+ if (activePopover !== 'labels') {
545
+ setLabelSearchQuery('');
546
+ }
547
+ }, [activePopover]);
548
+
549
+ useEffect(() => {
550
+ if (activePopover !== 'projects') {
551
+ setProjectSearchQuery('');
552
+ }
553
+ }, [activePopover]);
554
+
555
+ useEffect(() => {
556
+ if (activePopover !== 'assignees') {
557
+ setAssigneeSearchQuery('');
558
+ }
559
+ }, [activePopover]);
560
+
435
561
  const unselectedAvailableLabels = useMemo(
436
562
  () =>
437
563
  availableLabels.filter(
@@ -903,6 +1029,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
903
1029
  ? t(`tasks.priority_${priority}`)
904
1030
  : t('common.priority')
905
1031
  }
1032
+ popoverId="priority"
906
1033
  >
907
1034
  <button
908
1035
  type="button"
@@ -932,7 +1059,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
932
1059
  </span>
933
1060
  </button>
934
1061
  </TaskPropertyPopoverTrigger>
935
- <PopoverContent align="start" className="w-56 p-0">
1062
+ <PopoverContent
1063
+ align="start"
1064
+ className="w-56 p-0"
1065
+ {...propertyPopoverContentProps}
1066
+ >
936
1067
  <div className="p-1">
937
1068
  {[
938
1069
  {
@@ -1000,7 +1131,10 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1000
1131
  compact={isCompact}
1001
1132
  open={isPopoverOpen('list')}
1002
1133
  onOpenChange={(open) => setPopoverOpen('list', open)}
1134
+ onPopoverCloseAutoFocus={handlePropertyPopoverCloseAutoFocus}
1135
+ onPopoverInteractOutside={handlePropertyPopoverInteractOutside}
1003
1136
  onListChange={onListChange}
1137
+ propertyPopoverId="list"
1004
1138
  />
1005
1139
 
1006
1140
  {/* Dates Badge */}
@@ -1015,6 +1149,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1015
1149
  ? `${startDate ? new Date(startDate).toLocaleDateString(t('common.locale', { defaultValue: 'en-US' }), { month: 'short', day: 'numeric' }) : t('ws-task-boards.dialog.no_start_date')} → ${endDate ? new Date(endDate).toLocaleDateString(t('common.locale', { defaultValue: 'en-US' }), { month: 'short', day: 'numeric' }) : t('ws-task-boards.dialog.no_due_date')}`
1016
1150
  : t('ws-task-boards.dialog.dates')
1017
1151
  }
1152
+ popoverId="dates"
1018
1153
  >
1019
1154
  <button
1020
1155
  type="button"
@@ -1036,7 +1171,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1036
1171
  </span>
1037
1172
  </button>
1038
1173
  </TaskPropertyPopoverTrigger>
1039
- <PopoverContent align="start" className="w-80 p-0">
1174
+ <PopoverContent
1175
+ align="start"
1176
+ className="w-80 p-0"
1177
+ {...propertyPopoverContentProps}
1178
+ >
1040
1179
  <div className="rounded-lg p-3.5">
1041
1180
  <div className="space-y-3">
1042
1181
  {/* Start Date */}
@@ -1165,6 +1304,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1165
1304
  )
1166
1305
  : t('ws-task-boards.dialog.estimate')
1167
1306
  }
1307
+ popoverId="estimation"
1168
1308
  >
1169
1309
  <button
1170
1310
  type="button"
@@ -1191,7 +1331,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1191
1331
  </span>
1192
1332
  </button>
1193
1333
  </TaskPropertyPopoverTrigger>
1194
- <PopoverContent align="start" className="w-64 p-0">
1334
+ <PopoverContent
1335
+ align="start"
1336
+ className="w-64 p-0"
1337
+ {...propertyPopoverContentProps}
1338
+ >
1195
1339
  {!boardConfig?.estimation_type ? (
1196
1340
  <EmptyStateCard
1197
1341
  title={t('ws-task-boards.dialog.no_estimation_configured')}
@@ -1265,6 +1409,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1265
1409
  count: selectedLabels.length,
1266
1410
  })
1267
1411
  }
1412
+ popoverId="labels"
1268
1413
  >
1269
1414
  <button
1270
1415
  type="button"
@@ -1290,7 +1435,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1290
1435
  </span>
1291
1436
  </button>
1292
1437
  </TaskPropertyPopoverTrigger>
1293
- <PopoverContent align="start" className="w-72 p-0">
1438
+ <PopoverContent
1439
+ align="start"
1440
+ className="w-72 p-0"
1441
+ {...propertyPopoverContentProps}
1442
+ >
1294
1443
  {availableLabels.length === 0 ? (
1295
1444
  <EmptyStateCard
1296
1445
  title={t('ws-task-boards.dialog.no_labels_configured')}
@@ -1415,6 +1564,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1415
1564
  count: selectedProjects.length,
1416
1565
  })
1417
1566
  }
1567
+ popoverId="projects"
1418
1568
  >
1419
1569
  <button
1420
1570
  type="button"
@@ -1440,7 +1590,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1440
1590
  </span>
1441
1591
  </button>
1442
1592
  </TaskPropertyPopoverTrigger>
1443
- <PopoverContent align="start" className="w-72 p-0">
1593
+ <PopoverContent
1594
+ align="start"
1595
+ className="w-72 p-0"
1596
+ {...propertyPopoverContentProps}
1597
+ >
1444
1598
  {taskProjects.length === 0 ? (
1445
1599
  <EmptyStateCard
1446
1600
  title={t('ws-task-boards.dialog.no_projects_configured')}
@@ -1556,6 +1710,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1556
1710
  count: selectedAssignees.length,
1557
1711
  })
1558
1712
  }
1713
+ popoverId="assignees"
1559
1714
  >
1560
1715
  <button
1561
1716
  type="button"
@@ -1582,7 +1737,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1582
1737
  </span>
1583
1738
  </button>
1584
1739
  </TaskPropertyPopoverTrigger>
1585
- <PopoverContent align="start" className="w-72 p-0">
1740
+ <PopoverContent
1741
+ align="start"
1742
+ className="w-72 p-0"
1743
+ {...propertyPopoverContentProps}
1744
+ >
1586
1745
  {workspaceMembers.length === 0 ? (
1587
1746
  <div className="p-4 text-center text-muted-foreground text-sm">
1588
1747
  {t('ws-task-boards.dialog.no_members_found')}
@@ -1679,6 +1838,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1679
1838
  ? formatDuration(totalMinutes, t)
1680
1839
  : t('ws-task-boards.dialog.schedule')
1681
1840
  }
1841
+ popoverId="scheduling"
1682
1842
  >
1683
1843
  <button
1684
1844
  type="button"
@@ -1711,7 +1871,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1711
1871
  )}
1712
1872
  </button>
1713
1873
  </TaskPropertyPopoverTrigger>
1714
- <PopoverContent align="start" className="w-72 p-0">
1874
+ <PopoverContent
1875
+ align="start"
1876
+ className="w-72 p-0"
1877
+ {...propertyPopoverContentProps}
1878
+ >
1715
1879
  <div className="rounded-lg p-3">
1716
1880
  <div className="space-y-3">
1717
1881
  {/* Duration */}
@@ -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 {