@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
@@ -11,10 +11,12 @@ import {
11
11
  uploadWorkspaceTaskFile,
12
12
  } from '@tuturuuu/internal-api';
13
13
  import type { WorkspaceTaskSuggestionTask } from '@tuturuuu/internal-api/tasks';
14
+ import { getWorkspaceTaskHistory } from '@tuturuuu/internal-api/tasks';
14
15
  import type { Task } from '@tuturuuu/types/primitives/Task';
15
16
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
16
17
  import { Button } from '@tuturuuu/ui/button';
17
18
  import { Dialog, DialogContent, DialogTitle } from '@tuturuuu/ui/dialog';
19
+ import { SUPABASE_PROVIDER_SYNC_ORIGIN } from '@tuturuuu/ui/hooks/supabase-provider';
18
20
  import { useYjsCollaboration } from '@tuturuuu/ui/hooks/use-yjs-collaboration';
19
21
  import { toast } from '@tuturuuu/ui/sonner';
20
22
  import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
@@ -30,8 +32,9 @@ import {
30
32
  } from '@tuturuuu/utils/yjs-helper';
31
33
  import dayjs from 'dayjs';
32
34
  import { usePathname } from 'next/navigation';
33
- import { useTranslations } from 'next-intl';
35
+ import { useLocale, useTranslations } from 'next-intl';
34
36
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
37
+ import * as Y from 'yjs';
35
38
  import { BoardEstimationConfigDialog } from '../boards/boardId/task-dialogs/BoardEstimationConfigDialog';
36
39
  import { TaskNewLabelDialog } from '../boards/boardId/task-dialogs/TaskNewLabelDialog';
37
40
  import { TaskNewProjectDialog } from '../boards/boardId/task-dialogs/TaskNewProjectDialog';
@@ -64,7 +67,14 @@ import {
64
67
  import { TaskNameInput } from './task-edit-dialog/components/task-name-input';
65
68
  import { TaskSuggestionMenus } from './task-edit-dialog/components/task-suggestion-menus';
66
69
  import { NAME_UPDATE_DEBOUNCE_MS } from './task-edit-dialog/constants';
67
- import { fetchWorkspaceTaskDescription } from './task-edit-dialog/hooks/task-api';
70
+ import {
71
+ buildRecoverableTaskDescriptionVersions,
72
+ type RecoverableTaskDescriptionVersion,
73
+ } from './task-edit-dialog/description-versions';
74
+ import {
75
+ fetchWorkspaceTaskDescription,
76
+ updateWorkspaceTaskDescription,
77
+ } from './task-edit-dialog/hooks/task-api';
68
78
  import { useEditorCommands } from './task-edit-dialog/hooks/use-editor-commands';
69
79
  import { useSuggestionMenus } from './task-edit-dialog/hooks/use-suggestion-menus';
70
80
  import { useTaskChangeDetection } from './task-edit-dialog/hooks/use-task-change-detection';
@@ -85,6 +95,8 @@ import { useTaskYjsSync } from './task-edit-dialog/hooks/use-task-yjs-sync';
85
95
  import { PersonalOverridesSection } from './task-edit-dialog/personal-overrides-section';
86
96
  import { TaskActivitySection } from './task-edit-dialog/task-activity-section';
87
97
  import { TaskDeleteDialog } from './task-edit-dialog/task-delete-dialog';
98
+ import { TaskDescriptionRestoreBanner } from './task-edit-dialog/task-description-restore-banner';
99
+ import { TaskDescriptionVersionRestoreDialog } from './task-edit-dialog/task-description-version-restore-dialog';
88
100
  import { TaskInstancesSection } from './task-edit-dialog/task-instances-section';
89
101
  import { TaskPropertiesSection } from './task-edit-dialog/task-properties-section';
90
102
  import { TaskRelationshipsProperties } from './task-edit-dialog/task-relationships-properties';
@@ -104,16 +116,22 @@ import {
104
116
  } from './task-edit-dialog/user-display';
105
117
  import {
106
118
  broadcastTaskDescriptionUpsert,
119
+ canPersistTaskDescriptionSnapshot,
107
120
  clearDraft,
121
+ createTaskDescriptionPersistenceGuardState,
108
122
  getDescriptionContent,
109
123
  getDraftStorageKey,
110
124
  getTaskDescriptionPercentLeft,
111
125
  getTaskDescriptionPreviewText,
112
126
  getTaskDescriptionStorageLength,
127
+ normalizeTaskDescriptionSnapshot,
128
+ recordTaskDescriptionEditorSnapshot,
113
129
  saveAndVerifyYjsDescriptionToDatabase,
114
130
  saveYjsDescriptionToDatabase,
115
- serializeTaskDescriptionContent,
131
+ serializeTaskDescriptionPersistenceSnapshot,
116
132
  shouldPreserveNativeContextMenu,
133
+ type TaskDescriptionPersistenceGuardState,
134
+ updateTaskDescriptionCaches,
117
135
  } from './task-edit-dialog/utils';
118
136
  import {
119
137
  resolveInlineTaskTargetList,
@@ -125,6 +143,11 @@ import { UnsavedChangesWarningDialog } from './unsaved-changes-warning-dialog';
125
143
 
126
144
  type AssigneeMemberSource = 'workspace' | 'board' | 'workspace-and-board';
127
145
 
146
+ const EMPTY_TASK_DESCRIPTION_DOC: JSONContent = {
147
+ type: 'doc',
148
+ content: [{ type: 'paragraph' }],
149
+ };
150
+
128
151
  export {
129
152
  type DialogHeaderInfo,
130
153
  getTaskDialogHeaderInfo,
@@ -216,10 +239,12 @@ export function TaskEditDialog({
216
239
  const isCreateMode = mode === 'create';
217
240
  const effectiveTaskWsId = !isCreateMode ? (taskWsId ?? wsId) : wsId;
218
241
  const pathname = usePathname();
242
+ const locale = useLocale();
219
243
  const queryClient = useQueryClient();
220
244
  const t = useTranslations('common');
221
245
  const rootT = useTranslations();
222
246
  const dialogT = useTranslations('ws-task-boards.dialog');
247
+ const historyT = useTranslations('tasks.history');
223
248
  const { registerCloseRequestHandler } = useTaskDialogContext();
224
249
 
225
250
  // Access workspace tier for tier-aware collaboration settings (null outside the provider)
@@ -290,7 +315,18 @@ export function TaskEditDialog({
290
315
  const handleConvertToTaskRef = useRef<(() => Promise<void>) | null>(null);
291
316
  const descriptionRef = useRef<JSONContent | null>(formState.description);
292
317
  const persistedDescriptionRef = useRef<string | null>(null);
318
+ const descriptionPersistenceGuardRef =
319
+ useRef<TaskDescriptionPersistenceGuardState>(
320
+ createTaskDescriptionPersistenceGuardState({
321
+ persistedDescription: null,
322
+ })
323
+ );
293
324
  const taskRealtimeBroadcastRef = useRef<BoardBroadcastFn | null>(null);
325
+ const [hasHydratedYjsState, setHasHydratedYjsState] = useState(false);
326
+ const [showDescriptionVersionsDialog, setShowDescriptionVersionsDialog] =
327
+ useState(false);
328
+ const [restoringDescriptionVersionId, setRestoringDescriptionVersionId] =
329
+ useState<string | null>(null);
294
330
 
295
331
  useEffect(() => {
296
332
  descriptionRef.current = formState.description;
@@ -367,6 +403,54 @@ export function TaskEditDialog({
367
403
  refetchOnReconnect: false,
368
404
  });
369
405
 
406
+ const persistedTaskDescriptionSnapshot = useMemo(
407
+ () =>
408
+ serializeTaskDescriptionPersistenceSnapshot(
409
+ getDescriptionContent(task?.description)
410
+ ) ?? null,
411
+ [task?.description]
412
+ );
413
+
414
+ useEffect(() => {
415
+ if (!isOpen || isCreateMode) {
416
+ persistedDescriptionRef.current = null;
417
+ descriptionPersistenceGuardRef.current =
418
+ createTaskDescriptionPersistenceGuardState({
419
+ persistedDescription: null,
420
+ });
421
+ return;
422
+ }
423
+
424
+ persistedDescriptionRef.current = persistedTaskDescriptionSnapshot;
425
+ descriptionPersistenceGuardRef.current =
426
+ createTaskDescriptionPersistenceGuardState({
427
+ persistedDescription: persistedTaskDescriptionSnapshot,
428
+ trustPersistedDescription: !effectiveRealtimeEnabled,
429
+ });
430
+ }, [
431
+ effectiveRealtimeEnabled,
432
+ isOpen,
433
+ isCreateMode,
434
+ persistedTaskDescriptionSnapshot,
435
+ ]);
436
+
437
+ const canConfirmEmptyDescriptionSnapshot =
438
+ !effectiveRealtimeEnabled || hasHydratedYjsState;
439
+
440
+ const recordDescriptionSnapshot = useCallback(
441
+ (nextDescription: JSONContent | null) => {
442
+ descriptionPersistenceGuardRef.current =
443
+ recordTaskDescriptionEditorSnapshot(
444
+ descriptionPersistenceGuardRef.current,
445
+ nextDescription,
446
+ {
447
+ canConfirmEmptySnapshot: canConfirmEmptyDescriptionSnapshot,
448
+ }
449
+ );
450
+ },
451
+ [canConfirmEmptyDescriptionSnapshot]
452
+ );
453
+
370
454
  const loadTaskDescriptionState = useCallback(async () => {
371
455
  if (!task?.id) return null;
372
456
  const response = await fetchWorkspaceTaskDescription(
@@ -393,7 +477,9 @@ export function TaskEditDialog({
393
477
  return false;
394
478
  }
395
479
 
396
- const serializedDescription = serializeTaskDescriptionContent(content);
480
+ content = normalizeTaskDescriptionSnapshot(content);
481
+ const serializedDescription =
482
+ serializeTaskDescriptionPersistenceSnapshot(content);
397
483
 
398
484
  if (
399
485
  serializedDescription &&
@@ -402,6 +488,15 @@ export function TaskEditDialog({
402
488
  return false;
403
489
  }
404
490
 
491
+ if (
492
+ !canPersistTaskDescriptionSnapshot({
493
+ currentSerializedDescription: serializedDescription,
494
+ guardState: descriptionPersistenceGuardRef.current,
495
+ })
496
+ ) {
497
+ return false;
498
+ }
499
+
405
500
  const didPersist = await saveYjsDescriptionToDatabase({
406
501
  wsId: effectiveTaskWsId,
407
502
  taskId: task.id,
@@ -415,6 +510,11 @@ export function TaskEditDialog({
415
510
  if (!didPersist) return false;
416
511
 
417
512
  persistedDescriptionRef.current = serializedDescription;
513
+ descriptionPersistenceGuardRef.current =
514
+ createTaskDescriptionPersistenceGuardState({
515
+ persistedDescription: serializedDescription,
516
+ trustPersistedDescription: true,
517
+ });
418
518
 
419
519
  const broadcast =
420
520
  getActiveBroadcast() ?? taskRealtimeBroadcastRef.current;
@@ -444,8 +544,6 @@ export function TaskEditDialog({
444
544
  saveDocumentState: saveTaskDescriptionState,
445
545
  });
446
546
 
447
- const [hasHydratedYjsState, setHasHydratedYjsState] = useState(false);
448
-
449
547
  useEffect(() => {
450
548
  if (!isOpen || isCreateMode || !effectiveRealtimeEnabled || !task?.id) {
451
549
  setHasHydratedYjsState(false);
@@ -475,6 +573,49 @@ export function TaskEditDialog({
475
573
  synced,
476
574
  ]);
477
575
 
576
+ const { data: descriptionHistoryData } = useQuery({
577
+ queryKey: ['task-history', effectiveTaskWsId, task?.id, 'description'],
578
+ queryFn: async () => {
579
+ if (!task?.id) return null;
580
+
581
+ return getWorkspaceTaskHistory(effectiveTaskWsId, task.id, {
582
+ limit: 100,
583
+ changeType: 'field_updated',
584
+ fieldName: 'description',
585
+ });
586
+ },
587
+ enabled:
588
+ isOpen &&
589
+ !isCreateMode &&
590
+ !taskControlsDisabled &&
591
+ Boolean(effectiveTaskWsId) &&
592
+ Boolean(task?.id),
593
+ staleTime: 30 * 1000,
594
+ });
595
+
596
+ const recoverableDescriptionVersions = useMemo(
597
+ () =>
598
+ buildRecoverableTaskDescriptionVersions(
599
+ descriptionHistoryData?.history ?? []
600
+ ),
601
+ [descriptionHistoryData?.history]
602
+ );
603
+
604
+ const currentSerializedDescription = useMemo(
605
+ () =>
606
+ serializeTaskDescriptionPersistenceSnapshot(formState.description) ??
607
+ null,
608
+ [formState.description]
609
+ );
610
+
611
+ const latestRestorableDescriptionVersion = useMemo(() => {
612
+ const latestVersion = recoverableDescriptionVersions[0] ?? null;
613
+ if (!latestVersion) return null;
614
+ return latestVersion.description === currentSerializedDescription
615
+ ? null
616
+ : latestVersion;
617
+ }, [currentSerializedDescription, recoverableDescriptionVersions]);
618
+
478
619
  // Update user when props change
479
620
  useEffect(() => {
480
621
  if (propsCurrentUser) {
@@ -784,34 +925,45 @@ export function TaskEditDialog({
784
925
  });
785
926
 
786
927
  // Change detection
787
- const { hasUnsavedChanges, canSave, parseDescription } =
788
- useTaskChangeDetection({
789
- task,
790
- name: formState.name,
791
- description: formState.description,
792
- priority: formState.priority,
793
- startDate: formState.startDate,
794
- endDate: formState.endDate,
795
- selectedListId: formState.selectedListId,
796
- estimationPoints: formState.estimationPoints,
797
- selectedLabels: formState.selectedLabels,
798
- selectedAssignees: formState.selectedAssignees,
799
- isCreateMode,
800
- isLoading: isLoading || taskControlsDisabled,
801
- collaborationMode: effectiveCollaborationMode,
802
- draftId,
803
- });
928
+ const { hasUnsavedChanges, canSave } = useTaskChangeDetection({
929
+ task,
930
+ name: formState.name,
931
+ description: formState.description,
932
+ priority: formState.priority,
933
+ startDate: formState.startDate,
934
+ endDate: formState.endDate,
935
+ selectedListId: formState.selectedListId,
936
+ estimationPoints: formState.estimationPoints,
937
+ selectedLabels: formState.selectedLabels,
938
+ selectedAssignees: formState.selectedAssignees,
939
+ isCreateMode,
940
+ isLoading: isLoading || taskControlsDisabled,
941
+ collaborationMode: effectiveCollaborationMode,
942
+ draftId,
943
+ });
804
944
 
805
945
  useEffect(() => {
806
- if (!isOpen || isCreateMode) {
807
- persistedDescriptionRef.current = null;
946
+ if (!isOpen || isCreateMode) return;
947
+ if (effectiveRealtimeEnabled && !hasHydratedYjsState) return;
948
+ if (
949
+ serializeTaskDescriptionPersistenceSnapshot(formState.description) ===
950
+ null
951
+ ) {
808
952
  return;
809
953
  }
810
954
 
811
- persistedDescriptionRef.current =
812
- serializeTaskDescriptionContent(parseDescription(task?.description)) ??
813
- null;
814
- }, [isOpen, isCreateMode, parseDescription, task?.description]);
955
+ descriptionPersistenceGuardRef.current =
956
+ recordTaskDescriptionEditorSnapshot(
957
+ descriptionPersistenceGuardRef.current,
958
+ formState.description
959
+ );
960
+ }, [
961
+ effectiveRealtimeEnabled,
962
+ formState.description,
963
+ hasHydratedYjsState,
964
+ isOpen,
965
+ isCreateMode,
966
+ ]);
815
967
 
816
968
  // Realtime sync for task fields and relations. Description sync remains Yjs-only.
817
969
  const { broadcast: taskRealtimeBroadcast } = useTaskRealtimeSync({
@@ -1028,6 +1180,135 @@ export function TaskEditDialog({
1028
1180
  );
1029
1181
  }, []);
1030
1182
 
1183
+ const applyRestoredDescriptionToOpenEditor = useCallback(
1184
+ ({
1185
+ content,
1186
+ yjsState,
1187
+ }: {
1188
+ content: JSONContent | null;
1189
+ yjsState: number[] | null;
1190
+ }) => {
1191
+ const nextContent = content ?? EMPTY_TASK_DESCRIPTION_DOC;
1192
+
1193
+ const nextYjsState =
1194
+ yjsState && yjsState.length > 0
1195
+ ? Uint8Array.from(yjsState)
1196
+ : doc && editorInstance?.schema
1197
+ ? convertJsonContentToYjsState(nextContent, editorInstance.schema)
1198
+ : null;
1199
+
1200
+ if (doc && nextYjsState) {
1201
+ const syncOrigin = provider ?? SUPABASE_PROVIDER_SYNC_ORIGIN;
1202
+ doc.transact(() => {
1203
+ const fragment = doc.getXmlFragment('prosemirror');
1204
+ if (fragment.length > 0) {
1205
+ fragment.delete(0, fragment.length);
1206
+ }
1207
+ }, syncOrigin);
1208
+ Y.applyUpdate(doc, nextYjsState, syncOrigin);
1209
+ return;
1210
+ }
1211
+
1212
+ editorInstance?.commands.setContent(nextContent, {
1213
+ emitUpdate: false,
1214
+ });
1215
+ },
1216
+ [doc, editorInstance, provider]
1217
+ );
1218
+
1219
+ const handleRestoreDescriptionVersion = useCallback(
1220
+ async (version: RecoverableTaskDescriptionVersion) => {
1221
+ if (!task?.id) return;
1222
+
1223
+ setRestoringDescriptionVersionId(version.id);
1224
+
1225
+ try {
1226
+ const response = await updateWorkspaceTaskDescription(
1227
+ effectiveTaskWsId,
1228
+ task.id,
1229
+ { description: version.description }
1230
+ );
1231
+ const restoredDescription = response.description ?? version.description;
1232
+ const restoredContent = normalizeTaskDescriptionSnapshot(
1233
+ getDescriptionContent(restoredDescription)
1234
+ );
1235
+ const serializedRestoredDescription =
1236
+ serializeTaskDescriptionPersistenceSnapshot(restoredContent) ?? null;
1237
+
1238
+ applyRestoredDescriptionToOpenEditor({
1239
+ content: restoredContent,
1240
+ yjsState: response.description_yjs_state,
1241
+ });
1242
+ formState.setDescription(restoredContent);
1243
+ setDescriptionStorageLength(
1244
+ getTaskDescriptionStorageLength(restoredContent)
1245
+ );
1246
+ persistedDescriptionRef.current = serializedRestoredDescription;
1247
+ descriptionPersistenceGuardRef.current =
1248
+ createTaskDescriptionPersistenceGuardState({
1249
+ persistedDescription: serializedRestoredDescription,
1250
+ trustPersistedDescription: true,
1251
+ });
1252
+ setHasHydratedYjsState(true);
1253
+
1254
+ updateTaskDescriptionCaches({
1255
+ taskId: task.id,
1256
+ descriptionString: serializedRestoredDescription,
1257
+ boardId,
1258
+ queryClient,
1259
+ });
1260
+
1261
+ await Promise.all([
1262
+ queryClient.invalidateQueries({
1263
+ queryKey: ['task-history', effectiveTaskWsId, task.id],
1264
+ }),
1265
+ queryClient.invalidateQueries({
1266
+ queryKey: ['task', task.id],
1267
+ }),
1268
+ queryClient.invalidateQueries({
1269
+ queryKey: ['task-snapshot', effectiveTaskWsId, task.id],
1270
+ }),
1271
+ ]);
1272
+
1273
+ const broadcast =
1274
+ getActiveBroadcast() ?? taskRealtimeBroadcastRef.current;
1275
+ broadcastTaskDescriptionUpsert({
1276
+ taskId: task.id,
1277
+ descriptionString: serializedRestoredDescription,
1278
+ broadcast: broadcast ?? undefined,
1279
+ });
1280
+
1281
+ onUpdate();
1282
+ setShowDescriptionVersionsDialog(false);
1283
+ toast.success(
1284
+ historyT('description_restore_success', {
1285
+ defaultValue: 'Description restored',
1286
+ })
1287
+ );
1288
+ } catch (error) {
1289
+ toast.error(
1290
+ error instanceof Error
1291
+ ? error.message
1292
+ : historyT('description_restore_failed', {
1293
+ defaultValue: 'Failed to restore description',
1294
+ })
1295
+ );
1296
+ } finally {
1297
+ setRestoringDescriptionVersionId(null);
1298
+ }
1299
+ },
1300
+ [
1301
+ applyRestoredDescriptionToOpenEditor,
1302
+ boardId,
1303
+ effectiveTaskWsId,
1304
+ formState.setDescription,
1305
+ historyT,
1306
+ onUpdate,
1307
+ queryClient,
1308
+ task?.id,
1309
+ ]
1310
+ );
1311
+
1031
1312
  const isDescriptionOverLimit =
1032
1313
  descriptionStorageLength > MAX_TASK_DESCRIPTION_LENGTH;
1033
1314
  const descriptionPercentLeft = getTaskDescriptionPercentLeft(
@@ -1393,7 +1674,8 @@ export function TaskEditDialog({
1393
1674
  return createWorkspaceTaskSuggestions(effectiveTaskWsId, {
1394
1675
  boardId,
1395
1676
  prompt,
1396
- description: serializeTaskDescriptionContent(currentDescription),
1677
+ description:
1678
+ serializeTaskDescriptionPersistenceSnapshot(currentDescription),
1397
1679
  currentListId: formState.selectedListId || undefined,
1398
1680
  clientTimezone:
1399
1681
  Intl.DateTimeFormat().resolvedOptions().timeZone || undefined,
@@ -1690,9 +1972,11 @@ export function TaskEditDialog({
1690
1972
 
1691
1973
  closeBlockedByOverflowRef.current = false;
1692
1974
 
1693
- const currentContent = flushEditorPendingRef.current();
1975
+ const currentContent = normalizeTaskDescriptionSnapshot(
1976
+ flushEditorPendingRef.current()
1977
+ );
1694
1978
  const currentSerializedDescription =
1695
- serializeTaskDescriptionContent(currentContent) ?? null;
1979
+ serializeTaskDescriptionPersistenceSnapshot(currentContent) ?? null;
1696
1980
 
1697
1981
  if (
1698
1982
  currentSerializedDescription &&
@@ -1709,6 +1993,15 @@ export function TaskEditDialog({
1709
1993
  return true;
1710
1994
  }
1711
1995
 
1996
+ if (
1997
+ !canPersistTaskDescriptionSnapshot({
1998
+ currentSerializedDescription,
1999
+ guardState: descriptionPersistenceGuardRef.current,
2000
+ })
2001
+ ) {
2002
+ return false;
2003
+ }
2004
+
1712
2005
  const yjsState =
1713
2006
  currentContent && editorInstance?.schema
1714
2007
  ? Array.from(
@@ -1728,6 +2021,11 @@ export function TaskEditDialog({
1728
2021
 
1729
2022
  if (didPersist) {
1730
2023
  persistedDescriptionRef.current = currentSerializedDescription;
2024
+ descriptionPersistenceGuardRef.current =
2025
+ createTaskDescriptionPersistenceGuardState({
2026
+ persistedDescription: currentSerializedDescription,
2027
+ trustPersistedDescription: true,
2028
+ });
1731
2029
  }
1732
2030
 
1733
2031
  return didPersist;
@@ -1745,9 +2043,11 @@ export function TaskEditDialog({
1745
2043
  return false;
1746
2044
  }
1747
2045
 
1748
- const currentContent = flushEditorPendingRef.current();
2046
+ const currentContent = normalizeTaskDescriptionSnapshot(
2047
+ flushEditorPendingRef.current()
2048
+ );
1749
2049
  const currentSerializedDescription =
1750
- serializeTaskDescriptionContent(currentContent) ?? null;
2050
+ serializeTaskDescriptionPersistenceSnapshot(currentContent) ?? null;
1751
2051
 
1752
2052
  if (
1753
2053
  currentSerializedDescription &&
@@ -2521,6 +2821,30 @@ export function TaskEditDialog({
2521
2821
  />
2522
2822
  )}
2523
2823
 
2824
+ {!taskControlsDisabled && !isCreateMode && (
2825
+ <TaskDescriptionRestoreBanner
2826
+ isRestoring={Boolean(restoringDescriptionVersionId)}
2827
+ latestVersion={latestRestorableDescriptionVersion}
2828
+ onRestoreLatest={() => {
2829
+ if (latestRestorableDescriptionVersion) {
2830
+ void handleRestoreDescriptionVersion(
2831
+ latestRestorableDescriptionVersion
2832
+ );
2833
+ }
2834
+ }}
2835
+ onViewVersions={() =>
2836
+ setShowDescriptionVersionsDialog(true)
2837
+ }
2838
+ t={
2839
+ historyT as (
2840
+ key: string,
2841
+ options?: { count?: number; defaultValue?: string }
2842
+ ) => string
2843
+ }
2844
+ versionCount={recoverableDescriptionVersions.length}
2845
+ />
2846
+ )}
2847
+
2524
2848
  <TaskDescriptionEditor
2525
2849
  description={formState.description}
2526
2850
  setDescription={formState.setDescription}
@@ -2554,6 +2878,7 @@ export function TaskEditDialog({
2554
2878
  onImageUpload={imageUploadHandler}
2555
2879
  onEditorReady={handleEditorReady}
2556
2880
  onConvertToTask={handleConvertToTask}
2881
+ onDescriptionSnapshotChange={recordDescriptionSnapshot}
2557
2882
  onDescriptionStorageLengthChange={
2558
2883
  handleDescriptionStorageLengthChange
2559
2884
  }
@@ -2633,7 +2958,13 @@ export function TaskEditDialog({
2633
2958
  id: p.id,
2634
2959
  })),
2635
2960
  }}
2961
+ onRestoreDescriptionVersion={
2962
+ handleRestoreDescriptionVersion
2963
+ }
2636
2964
  revertDisabled={true}
2965
+ restoringDescriptionVersionId={
2966
+ restoringDescriptionVersionId
2967
+ }
2637
2968
  />
2638
2969
  )}
2639
2970
  </div>
@@ -2655,6 +2986,22 @@ export function TaskEditDialog({
2655
2986
  </DialogContent>
2656
2987
  </Dialog>
2657
2988
 
2989
+ <TaskDescriptionVersionRestoreDialog
2990
+ currentDescription={currentSerializedDescription}
2991
+ isOpen={showDescriptionVersionsDialog}
2992
+ locale={locale}
2993
+ onClose={() => setShowDescriptionVersionsDialog(false)}
2994
+ onRestoreVersion={handleRestoreDescriptionVersion}
2995
+ restoringVersionId={restoringDescriptionVersionId}
2996
+ t={
2997
+ historyT as (
2998
+ key: string,
2999
+ options?: { count?: number; defaultValue?: string }
3000
+ ) => string
3001
+ }
3002
+ versions={recoverableDescriptionVersions}
3003
+ />
3004
+
2658
3005
  <TaskDeleteDialog
2659
3006
  open={showDeleteConfirm}
2660
3007
  onOpenChange={setShowDeleteConfirm}