canopycms 0.0.68-int.98 → 0.1.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 +2131 -0
- package/README.md +47 -63
- package/dist/ai/generate.d.ts +5 -0
- package/dist/ai/generate.js +244 -115
- package/dist/ai/index.d.ts +1 -1
- package/dist/ai/json-to-markdown.d.ts +21 -1
- package/dist/ai/json-to-markdown.js +120 -57
- package/dist/ai/references.d.ts +72 -0
- package/dist/ai/references.js +223 -0
- package/dist/ai/resolve-branch.js +9 -2
- package/dist/ai/to-plain-text.d.ts +17 -8
- package/dist/ai/to-plain-text.js +18 -15
- package/dist/ai/types.d.ts +84 -3
- package/dist/api/admin-branch-health.d.ts +24 -1
- package/dist/api/admin-branch-health.js +52 -20
- package/dist/api/admin.d.ts +23 -5
- package/dist/api/admin.js +36 -1
- package/dist/api/assets.js +88 -78
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +16 -7
- package/dist/api/branch-status.d.ts +16 -6
- package/dist/api/branch-status.js +57 -12
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +8 -3
- package/dist/api/branch.d.ts +25 -12
- package/dist/api/branch.js +164 -103
- package/dist/api/client.d.ts +78 -58
- package/dist/api/client.js +150 -117
- package/dist/api/comments.d.ts +12 -3
- package/dist/api/comments.js +118 -9
- package/dist/api/content.d.ts +9 -9
- package/dist/api/content.js +131 -22
- package/dist/api/entries-constants.d.ts +14 -3
- package/dist/api/entries-constants.js +14 -3
- package/dist/api/entries.d.ts +40 -2
- package/dist/api/entries.js +102 -6
- package/dist/api/github-sync.d.ts +19 -0
- package/dist/api/github-sync.js +50 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/groups.js +2 -1
- package/dist/api/guards.js +23 -3
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +1 -0
- package/dist/api/permissions.d.ts +21 -2
- package/dist/api/permissions.js +50 -3
- package/dist/api/request-deadline.d.ts +59 -0
- package/dist/api/request-deadline.js +73 -0
- package/dist/api/resolve-references.js +8 -7
- package/dist/api/route-builder.d.ts +14 -0
- package/dist/api/route-builder.js +14 -1
- package/dist/api/schema.d.ts +11 -9
- package/dist/api/schema.js +2 -0
- package/dist/api/settings-helpers.d.ts +5 -1
- package/dist/api/settings-helpers.js +10 -2
- package/dist/api/types.d.ts +18 -0
- package/dist/api/users-constants.d.ts +6 -0
- package/dist/api/users-constants.js +6 -0
- package/dist/assets/asset-url.d.ts +28 -16
- package/dist/assets/asset-url.js +51 -25
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/cache.d.ts +1 -1
- package/dist/auth/cache.js +1 -1
- package/dist/auth/caching-auth-plugin.d.ts +2 -0
- package/dist/auth/caching-auth-plugin.js +12 -2
- package/dist/auth/file-based-auth-cache.d.ts +8 -0
- package/dist/auth/file-based-auth-cache.js +18 -8
- package/dist/auth/person-name.d.ts +5 -0
- package/dist/auth/person-name.js +8 -0
- package/dist/auth/plugin.d.ts +10 -1
- package/dist/auth/types.d.ts +2 -0
- package/dist/auth/user-metadata-lookup.d.ts +21 -0
- package/dist/auth/user-metadata-lookup.js +116 -0
- package/dist/authorization/branch.d.ts +1 -1
- package/dist/authorization/branch.js +12 -13
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/helpers.d.ts +8 -0
- package/dist/authorization/helpers.js +8 -0
- package/dist/authorization/index.d.ts +1 -1
- package/dist/authorization/index.js +1 -1
- package/dist/authorization/protected-branch.d.ts +5 -4
- package/dist/authorization/protected-branch.js +7 -5
- package/dist/branch-health.d.ts +35 -8
- package/dist/branch-health.js +54 -18
- package/dist/branch-metadata-error.d.ts +27 -0
- package/dist/branch-metadata-error.js +23 -0
- package/dist/branch-metadata-file.d.ts +205 -24
- package/dist/branch-metadata-file.js +126 -25
- package/dist/branch-metadata.d.ts +45 -6
- package/dist/branch-metadata.js +142 -50
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-registry.d.ts +8 -0
- package/dist/branch-registry.js +61 -9
- package/dist/branch-schema-cache.d.ts +29 -12
- package/dist/branch-schema-cache.js +64 -21
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +239 -100
- package/dist/build/asset-refs.d.ts +94 -0
- package/dist/build/asset-refs.js +267 -0
- package/dist/build/generate-ai-content.js +5 -0
- package/dist/build/inline-flight.d.ts +37 -0
- package/dist/build/inline-flight.js +142 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +8543 -3193
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +4244 -1072
- package/dist/cli/init.js +217 -126
- package/dist/cli/project-detect.d.ts +2 -0
- package/dist/cli/project-detect.js +2 -0
- package/dist/cli/template-files/Dockerfile.cms.template +13 -5
- package/dist/cli/template-files/cms-stack.ts.template +16 -13
- package/dist/client.d.ts +1 -2
- package/dist/client.js +1 -2
- package/dist/comment-store.d.ts +25 -0
- package/dist/comment-store.js +43 -8
- package/dist/config/flatten.js +2 -0
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/config.d.ts +12 -29
- package/dist/config/schemas/config.js +9 -12
- package/dist/config/schemas/field.d.ts +3 -0
- package/dist/config/schemas/field.js +58 -4
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +2 -20
- package/dist/config/schemas/url.js +2 -25
- package/dist/config/types.d.ts +116 -22
- package/dist/config/types.js +3 -9
- package/dist/config/validation.d.ts +25 -4
- package/dist/config/validation.js +116 -3
- package/dist/content-id-index.js +2 -2
- package/dist/content-index-generation.d.ts +1 -1
- package/dist/content-index-generation.js +1 -1
- package/dist/content-listing.d.ts +10 -1
- package/dist/content-listing.js +22 -5
- package/dist/content-reader.d.ts +9 -0
- package/dist/content-reader.js +16 -11
- package/dist/content-store.d.ts +75 -12
- package/dist/content-store.js +249 -86
- package/dist/content-tree.js +2 -2
- package/dist/context.js +5 -2
- package/dist/dangling-reference-log.d.ts +17 -0
- package/dist/dangling-reference-log.js +29 -0
- package/dist/dev-content-watcher.d.ts +1 -1
- package/dist/dev-content-watcher.js +1 -1
- package/dist/editor/BranchManager.d.ts +23 -7
- package/dist/editor/BranchManager.js +119 -54
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -2
- package/dist/editor/CommentsPanel.d.ts +14 -0
- package/dist/editor/CommentsPanel.js +68 -43
- package/dist/editor/Editor.d.ts +11 -5
- package/dist/editor/Editor.js +891 -218
- package/dist/editor/EditorAuthGate.d.ts +7 -1
- package/dist/editor/EditorAuthGate.js +41 -9
- package/dist/editor/EntryNavigator.d.ts +22 -0
- package/dist/editor/EntryNavigator.js +353 -105
- package/dist/editor/FormHeader.d.ts +25 -0
- package/dist/editor/FormHeader.js +82 -0
- package/dist/editor/FormRenderer.d.ts +27 -3
- package/dist/editor/FormRenderer.js +171 -76
- package/dist/editor/PreviewFrame.d.ts +40 -0
- package/dist/editor/PreviewFrame.js +215 -0
- package/dist/editor/PreviewPane.d.ts +47 -0
- package/dist/editor/PreviewPane.js +147 -0
- package/dist/editor/PreviewToolbarTools.d.ts +22 -0
- package/dist/editor/PreviewToolbarTools.js +73 -0
- package/dist/editor/admin/SystemHealthPanel.d.ts +2 -1
- package/dist/editor/admin/SystemHealthPanel.js +163 -33
- package/dist/editor/admin/useSystemHealth.d.ts +18 -0
- package/dist/editor/admin/useSystemHealth.js +70 -1
- package/dist/editor/branch-status.d.ts +17 -0
- package/dist/editor/branch-status.js +29 -0
- package/dist/editor/canopy-path.d.ts +9 -6
- package/dist/editor/canopy-path.js +8 -11
- package/dist/editor/client-reference-resolver.d.ts +43 -16
- package/dist/editor/client-reference-resolver.js +155 -64
- package/dist/editor/comments/BranchComments.d.ts +1 -1
- package/dist/editor/comments/BranchComments.js +2 -2
- package/dist/editor/comments/CloseRefusedNotice.d.ts +4 -0
- package/dist/editor/comments/CloseRefusedNotice.js +7 -0
- package/dist/editor/comments/CommentThreadsPopover.d.ts +47 -0
- package/dist/editor/comments/CommentThreadsPopover.js +274 -0
- package/dist/editor/comments/CommentWriteAlert.d.ts +11 -0
- package/dist/editor/comments/CommentWriteAlert.js +13 -0
- package/dist/editor/comments/EntryComments.d.ts +10 -4
- package/dist/editor/comments/EntryComments.js +14 -17
- package/dist/editor/comments/FieldWrapper.d.ts +18 -4
- package/dist/editor/comments/FieldWrapper.js +62 -28
- package/dist/editor/comments/InlineCommentThread.d.ts +9 -2
- package/dist/editor/comments/InlineCommentThread.js +11 -47
- package/dist/editor/comments/ThreadCarousel.d.ts +1 -1
- package/dist/editor/comments/ThreadCarousel.js +18 -21
- package/dist/editor/comments/ThreadReplyControls.d.ts +28 -0
- package/dist/editor/comments/ThreadReplyControls.js +92 -0
- package/dist/editor/comments/field-comment-context.d.ts +14 -0
- package/dist/editor/comments/field-comment-context.js +14 -0
- package/dist/editor/comments/form-comments.d.ts +48 -0
- package/dist/editor/comments/form-comments.js +178 -0
- package/dist/editor/comments/item-anchor-copy.d.ts +6 -0
- package/dist/editor/comments/item-anchor-copy.js +6 -0
- package/dist/editor/comments/move-focus-if-unmoved.d.ts +6 -0
- package/dist/editor/comments/move-focus-if-unmoved.js +11 -0
- package/dist/editor/comments/popover-placement.d.ts +18 -0
- package/dist/editor/comments/popover-placement.js +58 -0
- package/dist/editor/comments/use-tracked-writes.d.ts +16 -0
- package/dist/editor/comments/use-tracked-writes.js +53 -0
- package/dist/editor/components/BranchAccessList.d.ts +15 -0
- package/dist/editor/components/BranchAccessList.js +52 -0
- package/dist/editor/components/BranchLockBanner.d.ts +22 -0
- package/dist/editor/components/BranchLockBanner.js +39 -0
- package/dist/editor/components/BranchPill.d.ts +32 -0
- package/dist/editor/components/BranchPill.js +33 -0
- package/dist/editor/components/BranchesDrawer.d.ts +11 -0
- package/dist/editor/components/BranchesDrawer.js +5 -0
- package/dist/editor/components/ConfirmDeleteModal.d.ts +6 -1
- package/dist/editor/components/ConfirmDeleteModal.js +3 -2
- package/dist/editor/components/DestructiveMenuItem.d.ts +7 -0
- package/dist/editor/components/DestructiveMenuItem.js +8 -0
- package/dist/editor/components/EditorCrashScreen.d.ts +17 -0
- package/dist/editor/components/EditorCrashScreen.js +22 -0
- package/dist/editor/components/EditorErrorBoundary.d.ts +38 -0
- package/dist/editor/components/EditorErrorBoundary.js +40 -0
- package/dist/editor/components/EditorHeader.d.ts +56 -106
- package/dist/editor/components/EditorHeader.js +130 -131
- package/dist/editor/components/EditorRail.d.ts +49 -0
- package/dist/editor/components/EditorRail.js +65 -0
- package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
- package/dist/editor/components/EntryBreadcrumb.js +56 -0
- package/dist/editor/components/EntryCreateModal.d.ts +12 -7
- package/dist/editor/components/EntryCreateModal.js +51 -57
- package/dist/editor/components/NoEditPermissionNotice.d.ts +6 -5
- package/dist/editor/components/NoEditPermissionNotice.js +5 -4
- package/dist/editor/components/QuickOpenPalette.d.ts +22 -0
- package/dist/editor/components/QuickOpenPalette.js +66 -0
- package/dist/editor/components/ReadOnlyDraftNotice.d.ts +11 -0
- package/dist/editor/components/ReadOnlyDraftNotice.js +12 -0
- package/dist/editor/components/ReasonedMenuItem.d.ts +15 -0
- package/dist/editor/components/ReasonedMenuItem.js +15 -0
- package/dist/editor/components/ReferencedByList.d.ts +11 -0
- package/dist/editor/components/ReferencedByList.js +28 -0
- package/dist/editor/components/RelativeTime.d.ts +10 -0
- package/dist/editor/components/RelativeTime.js +10 -0
- package/dist/editor/components/RenameEntryModal.d.ts +9 -1
- package/dist/editor/components/RenameEntryModal.js +19 -31
- package/dist/editor/components/RetryToastMessage.d.ts +9 -0
- package/dist/editor/components/RetryToastMessage.js +6 -0
- package/dist/editor/components/SaveAllResult.d.ts +25 -0
- package/dist/editor/components/SaveAllResult.js +86 -0
- package/dist/editor/components/SaveSplitButton.d.ts +30 -0
- package/dist/editor/components/SaveSplitButton.js +108 -0
- package/dist/editor/components/StagedChangesDrawer.d.ts +39 -0
- package/dist/editor/components/StagedChangesDrawer.js +92 -0
- package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
- package/dist/editor/components/UnavailableEntryNotice.js +9 -0
- package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
- package/dist/editor/components/UnavailableTypeMessage.js +9 -0
- package/dist/editor/components/UndoToastMessage.d.ts +18 -0
- package/dist/editor/components/UndoToastMessage.js +10 -0
- package/dist/editor/components/UserBadge.d.ts +2 -5
- package/dist/editor/components/UserBadge.js +62 -33
- package/dist/editor/components/ValidationSummary.d.ts +14 -0
- package/dist/editor/components/ValidationSummary.js +24 -0
- package/dist/editor/components/index.d.ts +2 -2
- package/dist/editor/components/index.js +2 -2
- package/dist/editor/context/ApiClientContext.d.ts +8 -2
- package/dist/editor/context/ApiClientContext.js +19 -8
- package/dist/editor/context/AssetContext.d.ts +13 -35
- package/dist/editor/context/AssetContext.js +12 -31
- package/dist/editor/context/EditorStateContext.d.ts +4 -4
- package/dist/editor/context/EditorStateContext.js +4 -4
- package/dist/editor/context/SWRProvider.d.ts +3 -3
- package/dist/editor/context/SiteMdxAllowContext.d.ts +4 -0
- package/dist/editor/context/SiteMdxAllowContext.js +7 -0
- package/dist/editor/context/index.d.ts +2 -1
- package/dist/editor/context/index.js +2 -1
- package/dist/editor/contrast.d.ts +19 -0
- package/dist/editor/contrast.js +41 -0
- package/dist/editor/copy.d.ts +18 -0
- package/dist/editor/copy.js +18 -0
- package/dist/editor/editor-config.js +16 -0
- package/dist/editor/editor-utils.d.ts +34 -8
- package/dist/editor/editor-utils.js +42 -20
- package/dist/editor/entry-search.d.ts +20 -0
- package/dist/editor/entry-search.js +34 -0
- package/dist/editor/field-breadcrumb.d.ts +12 -0
- package/dist/editor/field-breadcrumb.js +85 -0
- package/dist/editor/field-props.d.ts +86 -0
- package/dist/editor/field-props.js +27 -0
- package/dist/editor/field-target.d.ts +30 -0
- package/dist/editor/field-target.js +77 -0
- package/dist/editor/fields/BlockField.d.ts +12 -0
- package/dist/editor/fields/BlockField.js +89 -73
- package/dist/editor/fields/CodeField.d.ts +2 -0
- package/dist/editor/fields/CodeField.js +6 -2
- package/dist/editor/fields/DateTimeField.d.ts +4 -29
- package/dist/editor/fields/DateTimeField.js +79 -41
- package/dist/editor/fields/ExpandAllButton.d.ts +6 -0
- package/dist/editor/fields/ExpandAllButton.js +4 -0
- package/dist/editor/fields/FieldCrashFallback.d.ts +17 -0
- package/dist/editor/fields/FieldCrashFallback.js +30 -0
- package/dist/editor/fields/FieldLabel.d.ts +37 -0
- package/dist/editor/fields/FieldLabel.js +19 -0
- package/dist/editor/fields/ImageField.d.ts +4 -0
- package/dist/editor/fields/ImageField.js +29 -14
- package/dist/editor/fields/InlineGroupField.js +4 -3
- package/dist/editor/fields/ItemList.d.ts +42 -0
- package/dist/editor/fields/ItemList.js +130 -0
- package/dist/editor/fields/MarkdownField.d.ts +32 -0
- package/dist/editor/fields/MarkdownField.js +223 -46
- package/dist/editor/fields/MdxImageDialog.js +1 -1
- package/dist/editor/fields/NumberField.d.ts +2 -0
- package/dist/editor/fields/NumberField.js +8 -5
- package/dist/editor/fields/NumberListField.d.ts +2 -0
- package/dist/editor/fields/NumberListField.js +19 -16
- package/dist/editor/fields/ObjectField.d.ts +2 -7
- package/dist/editor/fields/ObjectField.js +15 -5
- package/dist/editor/fields/ObjectListField.d.ts +29 -0
- package/dist/editor/fields/ObjectListField.js +57 -0
- package/dist/editor/fields/ReferenceField.d.ts +2 -0
- package/dist/editor/fields/ReferenceField.js +34 -9
- package/dist/editor/fields/SelectField.d.ts +2 -0
- package/dist/editor/fields/SelectField.js +11 -2
- package/dist/editor/fields/StringListField.d.ts +4 -1
- package/dist/editor/fields/StringListField.js +22 -8
- package/dist/editor/fields/TextField.d.ts +2 -0
- package/dist/editor/fields/TextField.js +6 -2
- package/dist/editor/fields/ToggleField.d.ts +2 -0
- package/dist/editor/fields/ToggleField.js +8 -3
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +1 -1
- package/dist/editor/fields/form-expansion.d.ts +9 -0
- package/dist/editor/fields/form-expansion.js +2 -0
- package/dist/editor/fields/item-expansion.d.ts +15 -0
- package/dist/editor/fields/item-expansion.js +60 -0
- package/dist/editor/fields/list-item-keys.d.ts +8 -0
- package/dist/editor/fields/list-item-keys.js +50 -0
- package/dist/editor/fields/list-item-title.d.ts +20 -0
- package/dist/editor/fields/list-item-title.js +85 -0
- package/dist/editor/fields/markdown-export-options.d.ts +2 -0
- package/dist/editor/fields/markdown-export-options.js +8 -0
- package/dist/editor/fields/markdown-fidelity-visitors.d.ts +11 -0
- package/dist/editor/fields/markdown-fidelity-visitors.js +160 -0
- package/dist/editor/fields/mdx-jsx-support.d.ts +5 -1
- package/dist/editor/fields/mdx-jsx-support.js +157 -69
- package/dist/editor/fields/rich-text-failures.d.ts +5 -0
- package/dist/editor/fields/rich-text-failures.js +26 -0
- package/dist/editor/fields/toolbar-wrap.d.ts +7 -0
- package/dist/editor/fields/toolbar-wrap.js +35 -0
- package/dist/editor/form-outline.d.ts +13 -0
- package/dist/editor/form-outline.js +70 -0
- package/dist/editor/group-manager/GroupCard.d.ts +4 -1
- package/dist/editor/group-manager/GroupCard.js +12 -3
- package/dist/editor/group-manager/GroupForm.js +1 -1
- package/dist/editor/group-manager/InternalGroupsTab.d.ts +2 -1
- package/dist/editor/group-manager/InternalGroupsTab.js +4 -4
- package/dist/editor/group-manager/MemberList.d.ts +2 -1
- package/dist/editor/group-manager/MemberList.js +15 -4
- package/dist/editor/group-manager/hooks/useGroupState.d.ts +5 -0
- package/dist/editor/group-manager/hooks/useGroupState.js +30 -7
- package/dist/editor/group-manager/index.js +34 -5
- package/dist/editor/group-manager/types.d.ts +5 -2
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +54 -0
- package/dist/editor/hooks/save-all.d.ts +68 -0
- package/dist/editor/hooks/save-all.js +61 -0
- package/dist/editor/hooks/useBranchActions.d.ts +11 -1
- package/dist/editor/hooks/useBranchActions.js +42 -19
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +321 -125
- package/dist/editor/hooks/useBranchesData.d.ts +8 -4
- package/dist/editor/hooks/useBranchesData.js +9 -5
- package/dist/editor/hooks/useCommentSystem.d.ts +14 -2
- package/dist/editor/hooks/useCommentSystem.js +79 -55
- package/dist/editor/hooks/useCommentsData.d.ts +11 -7
- package/dist/editor/hooks/useCommentsData.js +20 -19
- package/dist/editor/hooks/useDraftManager.d.ts +77 -3
- package/dist/editor/hooks/useDraftManager.js +887 -94
- package/dist/editor/hooks/useEditorLayout.d.ts +53 -7
- package/dist/editor/hooks/useEditorLayout.js +98 -20
- package/dist/editor/hooks/useEntriesData.d.ts +2 -1
- package/dist/editor/hooks/useEntriesData.js +18 -4
- package/dist/editor/hooks/useEntryManager.d.ts +41 -2
- package/dist/editor/hooks/useEntryManager.js +227 -41
- package/dist/editor/hooks/useGroupManager.js +13 -16
- package/dist/editor/hooks/usePermissionManager.js +14 -6
- package/dist/editor/hooks/usePreviewMarks.d.ts +21 -0
- package/dist/editor/hooks/usePreviewMarks.js +33 -0
- package/dist/editor/hooks/useReferenceResolution.d.ts +10 -3
- package/dist/editor/hooks/useReferenceResolution.js +34 -177
- package/dist/editor/hooks/useSchemaManager.d.ts +25 -6
- package/dist/editor/hooks/useSchemaManager.js +97 -39
- package/dist/editor/hooks/useUserMetadata.d.ts +14 -4
- package/dist/editor/hooks/useUserMetadata.js +76 -47
- package/dist/editor/hooks/user-metadata-batcher.d.ts +8 -0
- package/dist/editor/hooks/user-metadata-batcher.js +33 -0
- package/dist/editor/media/AssetCard.d.ts +6 -7
- package/dist/editor/media/AssetCard.js +24 -31
- package/dist/editor/media/AssetListRow.d.ts +16 -0
- package/dist/editor/media/AssetListRow.js +41 -0
- package/dist/editor/media/CropStep.js +6 -1
- package/dist/editor/media/MediaLibrary.d.ts +4 -3
- package/dist/editor/media/MediaLibrary.js +11 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +3 -3
- package/dist/editor/media/MediaLibraryBody.js +195 -80
- package/dist/editor/media/MediaLibrarySkeleton.d.ts +6 -0
- package/dist/editor/media/MediaLibrarySkeleton.js +14 -0
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +8 -0
- package/dist/editor/media/editor-image-src.js +10 -0
- package/dist/editor/media/format-bytes.d.ts +2 -0
- package/dist/editor/media/format-bytes.js +23 -0
- package/dist/editor/media/media-library-prefs.d.ts +23 -0
- package/dist/editor/media/media-library-prefs.js +49 -0
- package/dist/editor/media/media-library-view.d.ts +34 -0
- package/dist/editor/media/media-library-view.js +40 -0
- package/dist/editor/media/useAssetLibrary.d.ts +30 -0
- package/dist/editor/media/useAssetLibrary.js +126 -0
- package/dist/editor/media/useStableAssetOrder.d.ts +11 -0
- package/dist/editor/media/useStableAssetOrder.js +37 -0
- package/dist/editor/media/useThumbnailFailure.d.ts +6 -0
- package/dist/editor/media/useThumbnailFailure.js +16 -0
- package/dist/editor/permission-manager/GroupSelector.js +2 -2
- package/dist/editor/permission-manager/PermissionEditor.d.ts +1 -1
- package/dist/editor/permission-manager/PermissionEditor.js +15 -4
- package/dist/editor/permission-manager/PermissionLevelBadge.js +1 -1
- package/dist/editor/permission-manager/PermissionTree.d.ts +1 -1
- package/dist/editor/permission-manager/UserSelector.d.ts +1 -1
- package/dist/editor/permission-manager/UserSelector.js +3 -3
- package/dist/editor/permission-manager/index.js +12 -3
- package/dist/editor/permission-manager/types.d.ts +5 -2
- package/dist/editor/platform.d.ts +3 -0
- package/dist/editor/platform.js +9 -0
- package/dist/editor/preview-asset-base.d.ts +9 -0
- package/dist/editor/preview-asset-base.js +17 -0
- package/dist/editor/preview-bridge.d.ts +51 -27
- package/dist/editor/preview-bridge.js +103 -144
- package/dist/editor/preview-marks.d.ts +15 -0
- package/dist/editor/preview-marks.js +74 -0
- package/dist/editor/preview-path.d.ts +12 -0
- package/dist/editor/preview-path.js +45 -1
- package/dist/editor/preview-scale.d.ts +27 -0
- package/dist/editor/preview-scale.js +34 -0
- package/dist/editor/raw-asset-base.d.ts +5 -0
- package/dist/editor/raw-asset-base.js +14 -0
- package/dist/editor/relative-time.d.ts +4 -4
- package/dist/editor/relative-time.js +16 -8
- package/dist/editor/schema-editor/CollectionEditor.d.ts +20 -3
- package/dist/editor/schema-editor/CollectionEditor.js +361 -62
- package/dist/editor/schema-editor/EntryTypeEditor.d.ts +5 -1
- package/dist/editor/schema-editor/EntryTypeEditor.js +11 -6
- package/dist/editor/schema-editor/collection-draft.d.ts +22 -0
- package/dist/editor/schema-editor/collection-draft.js +40 -0
- package/dist/editor/schema-editor/index.d.ts +1 -1
- package/dist/editor/schema-label.d.ts +10 -0
- package/dist/editor/schema-label.js +17 -0
- package/dist/editor/settings-conflict.d.ts +5 -0
- package/dist/editor/settings-conflict.js +8 -0
- package/dist/editor/shell/ActivityRail.d.ts +37 -0
- package/dist/editor/shell/ActivityRail.js +91 -0
- package/dist/editor/shell/EditorShell.d.ts +17 -0
- package/dist/editor/shell/EditorShell.js +31 -0
- package/dist/editor/shell/PaneLayout.d.ts +28 -0
- package/dist/editor/shell/PaneLayout.js +116 -0
- package/dist/editor/shell/SidePanelHost.d.ts +35 -0
- package/dist/editor/shell/SidePanelHost.js +184 -0
- package/dist/editor/shell/TopBar.d.ts +12 -0
- package/dist/editor/shell/TopBar.js +14 -0
- package/dist/editor/shell/host-chrome.d.ts +16 -0
- package/dist/editor/shell/host-chrome.js +69 -0
- package/dist/editor/slug.d.ts +16 -0
- package/dist/editor/slug.js +43 -0
- package/dist/editor/theme.d.ts +2 -1
- package/dist/editor/theme.js +314 -13
- package/dist/editor/unanswered.d.ts +17 -0
- package/dist/editor/unanswered.js +26 -0
- package/dist/editor/unavailable-entry-type.d.ts +20 -0
- package/dist/editor/unavailable-entry-type.js +28 -0
- package/dist/editor/undo-removal.d.ts +57 -0
- package/dist/editor/undo-removal.js +163 -0
- package/dist/editor/user-display.d.ts +4 -0
- package/dist/editor/user-display.js +5 -0
- package/dist/editor/utils/confirm-modal.d.ts +8 -0
- package/dist/editor/utils/confirm-modal.js +12 -0
- package/dist/editor/utils/editor-errors.d.ts +19 -0
- package/dist/editor/utils/editor-errors.js +32 -0
- package/dist/editor/utils/focus-return.d.ts +56 -0
- package/dist/editor/utils/focus-return.js +106 -0
- package/dist/entry-link-resolver.d.ts +11 -1
- package/dist/entry-link-resolver.js +65 -5
- package/dist/entry-schema-registry.d.ts +3 -3
- package/dist/entry-schema-registry.js +7 -4
- package/dist/entry-schema.d.ts +49 -3
- package/dist/entry-schema.js +18 -0
- package/dist/git-manager.d.ts +172 -10
- package/dist/git-manager.js +449 -107
- package/dist/github-service.d.ts +39 -2
- package/dist/github-service.js +101 -7
- package/dist/http/handler.js +74 -9
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +9 -4
- package/dist/http/worker-not-ready.js +59 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/item-keys.d.ts +46 -0
- package/dist/item-keys.js +235 -0
- package/dist/operating-mode/editor-mode-check.d.ts +15 -0
- package/dist/operating-mode/editor-mode-check.js +24 -0
- package/dist/operating-mode/mode-env.d.ts +13 -14
- package/dist/operating-mode/mode-env.js +13 -14
- package/dist/operating-mode/types.d.ts +2 -2
- package/dist/paths/branch.d.ts +0 -1
- package/dist/paths/branch.js +1 -8
- package/dist/paths/index.d.ts +1 -1
- package/dist/paths/index.js +1 -1
- package/dist/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -0
- package/dist/reference-resolver.js +1 -0
- package/dist/resolve-canopy-user.d.ts +1 -1
- package/dist/resolve-canopy-user.js +1 -1
- package/dist/schema/meta-loader.d.ts +18 -8
- package/dist/schema/meta-loader.js +42 -9
- package/dist/schema/registry-fingerprint.d.ts +14 -0
- package/dist/schema/registry-fingerprint.js +63 -0
- package/dist/schema/resolver.d.ts +7 -4
- package/dist/schema/resolver.js +7 -6
- package/dist/schema/schema-store.d.ts +7 -10
- package/dist/schema/schema-store.js +13 -11
- package/dist/schema/schema-unavailable-error.d.ts +11 -0
- package/dist/schema/schema-unavailable-error.js +15 -0
- package/dist/schema/types.d.ts +22 -0
- package/dist/schema-registry-record.d.ts +35 -0
- package/dist/schema-registry-record.js +134 -0
- package/dist/server.d.ts +79 -9
- package/dist/server.js +62 -8
- package/dist/services.d.ts +33 -5
- package/dist/services.js +155 -37
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +159 -45
- package/dist/stamp-on-publish.d.ts +36 -0
- package/dist/stamp-on-publish.js +71 -0
- package/dist/static/index.d.ts +58 -5
- package/dist/static/index.js +133 -3
- package/dist/static/seo.d.ts +5 -5
- package/dist/static/seo.js +5 -5
- package/dist/submission-attribution.d.ts +8 -0
- package/dist/submission-attribution.js +22 -0
- package/dist/system-users.d.ts +12 -0
- package/dist/system-users.js +12 -0
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/task-queue/cms-task-queue.js +1 -1
- package/dist/task-queue/index.d.ts +1 -1
- package/dist/task-queue/index.js +1 -1
- package/dist/task-queue/task-queue.d.ts +6 -0
- package/dist/task-queue/task-queue.js +29 -0
- package/dist/task-queue/worker-status.d.ts +35 -9
- package/dist/task-queue/worker-status.js +84 -17
- package/dist/types.d.ts +90 -9
- package/dist/url-collision.js +3 -3
- package/dist/url-exclusivity-fixtures.d.ts +8 -1
- package/dist/url-exclusivity-fixtures.js +11 -1
- package/dist/user.js +2 -1
- package/dist/utils/atomic-write.js +2 -1
- package/dist/utils/base-branch.d.ts +13 -0
- package/dist/utils/base-branch.js +23 -0
- package/dist/utils/content-serialize.d.ts +15 -7
- package/dist/utils/content-serialize.js +353 -51
- package/dist/utils/content-write-lock.d.ts +6 -1
- package/dist/utils/content-write-lock.js +21 -6
- package/dist/utils/debug.d.ts +1 -1
- package/dist/utils/debug.js +5 -3
- package/dist/utils/error.js +13 -2
- package/dist/utils/escape-text.d.ts +7 -0
- package/dist/utils/escape-text.js +27 -0
- package/dist/utils/fs.d.ts +12 -4
- package/dist/utils/fs.js +18 -5
- package/dist/utils/git.d.ts +21 -1
- package/dist/utils/git.js +63 -8
- package/dist/utils/json-source-splice.d.ts +14 -0
- package/dist/utils/json-source-splice.js +120 -0
- package/dist/utils/markdown-body-splice.d.ts +39 -0
- package/dist/utils/markdown-body-splice.js +421 -0
- package/dist/utils/occ-json-write.js +18 -12
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +32 -1
- package/dist/utils/provisioning-lock.js +78 -32
- package/dist/utils/sanitize-href.d.ts +9 -23
- package/dist/utils/sanitize-href.js +14 -28
- package/dist/utils/title-field.d.ts +10 -0
- package/dist/utils/title-field.js +47 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/block-structural-keys.d.ts +1 -1
- package/dist/validation/block-structural-keys.js +1 -1
- package/dist/validation/deletion-checker.d.ts +21 -29
- package/dist/validation/deletion-checker.js +41 -117
- package/dist/validation/entry-validator.d.ts +6 -10
- package/dist/validation/entry-validator.js +19 -26
- package/dist/validation/field-traversal.d.ts +22 -0
- package/dist/validation/field-traversal.js +39 -8
- package/dist/validation/markdown-safety.d.ts +68 -0
- package/dist/validation/markdown-safety.js +596 -0
- package/dist/validation/mdx-allowlist.d.ts +55 -0
- package/dist/validation/mdx-allowlist.js +183 -0
- package/dist/validation/reference-validator.d.ts +2 -0
- package/dist/validation/reference-validator.js +3 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +124 -0
- package/dist/worker/branch-policy.js +243 -0
- package/dist/worker/bundle-header.d.ts +117 -0
- package/dist/worker/bundle-header.js +253 -0
- package/dist/worker/cms-worker.d.ts +236 -142
- package/dist/worker/cms-worker.js +772 -285
- package/dist/worker/gateway-egress.d.ts +43 -0
- package/dist/worker/gateway-egress.js +209 -0
- package/dist/worker/gateway-instance.d.ts +42 -0
- package/dist/worker/gateway-instance.js +129 -0
- package/dist/worker/git-process.d.ts +81 -0
- package/dist/worker/git-process.js +234 -0
- package/dist/worker/git-sync.d.ts +34 -17
- package/dist/worker/git-sync.js +394 -131
- package/dist/worker/github-auth.d.ts +9 -9
- package/dist/worker/github-auth.js +13 -15
- package/dist/worker/github-gateway-http.d.ts +62 -0
- package/dist/worker/github-gateway-http.js +977 -0
- package/dist/worker/github-gateway-wire.d.ts +590 -0
- package/dist/worker/github-gateway-wire.js +488 -0
- package/dist/worker/github-gateway.d.ts +238 -0
- package/dist/worker/github-gateway.js +726 -0
- package/dist/worker/github-mirror-upkeep.d.ts +75 -0
- package/dist/worker/github-mirror-upkeep.js +266 -0
- package/dist/worker/github-mirror.d.ts +364 -0
- package/dist/worker/github-mirror.js +1030 -0
- package/dist/worker/github-transfer.d.ts +56 -0
- package/dist/worker/github-transfer.js +444 -0
- package/dist/worker/history-rewrite.d.ts +4 -5
- package/dist/worker/history-rewrite.js +12 -17
- package/dist/worker/provisioned-workspace.d.ts +5 -5
- package/dist/worker/provisioned-workspace.js +5 -5
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +58 -54
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +12 -0
- package/dist/worker/schema-gate.d.ts +49 -0
- package/dist/worker/schema-gate.js +226 -0
- package/dist/worker/self-checks.d.ts +107 -0
- package/dist/worker/self-checks.js +358 -0
- package/dist/worker/shared-repo-git.d.ts +96 -0
- package/dist/worker/shared-repo-git.js +459 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +136 -0
- package/dist/worker/stale-mirror.d.ts +82 -0
- package/dist/worker/stale-mirror.js +130 -0
- package/dist/worker/task-runner.d.ts +5 -3
- package/dist/worker/task-runner.js +287 -186
- package/dist/worker/worker-context.d.ts +36 -37
- package/package.json +35 -7
- package/dist/editor/EditorPanes.d.ts +0 -10
- package/dist/editor/EditorPanes.js +0 -98
- package/dist/editor/components/EditorFooter.d.ts +0 -11
- package/dist/editor/components/EditorFooter.js +0 -9
- package/dist/editor/components/EditorSidebar.d.ts +0 -67
- package/dist/editor/components/EditorSidebar.js +0 -22
|
@@ -1,10 +1,38 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { notifications } from '@mantine/notifications';
|
|
3
|
-
import {
|
|
3
|
+
import { openConfirm } from '../utils/confirm-modal.js';
|
|
4
|
+
import { focusReturnProps } from '../utils/focus-return.js';
|
|
4
5
|
import equal from 'fast-deep-equal';
|
|
5
6
|
import { getNotificationDuration } from '../utils/env.js';
|
|
6
7
|
import { validateEntryFormValue } from '../../validation/entry-validator.js';
|
|
7
|
-
import { SaveApiError } from './useEntryManager.js';
|
|
8
|
+
import { EntrySchemaUnavailableError, SaveApiError } from './useEntryManager.js';
|
|
9
|
+
import { SCHEMA_UNAVAILABLE_CODE } from '../unavailable-entry-type.js';
|
|
10
|
+
import { ENTRY_CHANGED_MESSAGE } from '../../api/entries-constants.js';
|
|
11
|
+
import { isUnanswered } from '../unanswered.js';
|
|
12
|
+
import { withItemKeys } from '../../item-keys.js';
|
|
13
|
+
import { RetryToastMessage } from '../components/RetryToastMessage.js';
|
|
14
|
+
import { clearSaveAllResult, settleSaveAllEntry } from '../components/SaveAllResult.js';
|
|
15
|
+
import { classifySaveAllError, invalidReason, SAVE_ALL_REASONS, } from './save-all.js';
|
|
16
|
+
/** @internal Exported for tests. */
|
|
17
|
+
export const SAVE_TIMED_OUT_MESSAGE = "Save didn't finish, and may not have gone through. Your changes are still here, so save again.";
|
|
18
|
+
/** @internal Exported for tests. Shown when a save may already have landed, until a reload. */
|
|
19
|
+
export const SAVE_MAY_HAVE_LANDED_MESSAGE = 'Your last save may already have gone through. Before saving again, choose "Refresh from server" in the entry\'s menu, on its name; your unsaved edits will be lost.';
|
|
20
|
+
const saveMayHaveLandedToastId = (contentId) => `canopy-save-may-have-landed:${contentId}`;
|
|
21
|
+
const saveTimedOutToastId = (contentId) => `canopy-save-timed-out:${contentId}`;
|
|
22
|
+
/** @internal Exported for tests. A save refused because the entry was deleted. */
|
|
23
|
+
export const ENTRY_DELETED_MESSAGE = 'This entry was deleted since you opened it, so it was not saved. Your changes are still in the form until the content list next refreshes and closes it: choose "Save as new entry" to keep them.';
|
|
24
|
+
/** @internal Exported for tests. A save refused because the entry was renamed; the editor has opened it. */
|
|
25
|
+
export const ENTRY_RENAMED_MESSAGE = 'This entry was renamed since you opened it, so it was not saved. Your changes moved with it and are still in the form: save again to keep them.';
|
|
26
|
+
/** @internal Exported for tests. "Save as new entry" refused because an entry, possibly the user's own earlier attempt, now holds the path. */
|
|
27
|
+
export const RECREATE_TAKEN_MESSAGE = "An entry with this entry's name already exists, perhaps from your own earlier attempt, so it was not saved as a new entry. Your changes are still in the form: copy what you want to keep into that entry.";
|
|
28
|
+
/** @internal Exported for tests. The save was refused as gone, and the check for a rename failed. */
|
|
29
|
+
export const ENTRY_GONE_UNCHECKED_MESSAGE = 'This entry was deleted or renamed since you opened it, so it was not saved, and the editor could not check which. Your changes are still in the form until the content list next refreshes and closes it: copy what you want to keep.';
|
|
30
|
+
/** @internal Exported for tests. "Save as new entry" landed, but the content list does not show it yet. */
|
|
31
|
+
export const RECREATED_NOT_LISTED_MESSAGE = "Saved as a new entry, but the content list doesn't show it yet. Reload the page in a moment to open it.";
|
|
32
|
+
/** @internal Exported for tests. "Save as new entry" got no answer. */
|
|
33
|
+
export const RECREATE_TIMED_OUT_MESSAGE = 'Saving as a new entry may have gone through even though the server didn\'t answer. Choose "Save as new entry" to try again: if it went through, you\'ll be told an entry with this name already exists.';
|
|
34
|
+
const entryGoneToastId = (contentId) => `canopy-entry-gone:${contentId}`;
|
|
35
|
+
const ENTRY_GONE_CODE = 'ENTRY_GONE';
|
|
8
36
|
/** Collapse a list of per-field errors into a path → message map (first error per path wins). */
|
|
9
37
|
const toFieldErrorMap = (errors) => {
|
|
10
38
|
const map = {};
|
|
@@ -63,6 +91,9 @@ const parsePersistedDrafts = (raw) => {
|
|
|
63
91
|
baseVersions[id] = null;
|
|
64
92
|
return { drafts, baseVersions };
|
|
65
93
|
};
|
|
94
|
+
const VERIFY_CONCURRENCY = 4;
|
|
95
|
+
/** How long `whenDraftsVerified` waits before answering with whatever is known. */
|
|
96
|
+
const VERIFY_WAIT_CAP_MS = 3000;
|
|
66
97
|
/**
|
|
67
98
|
* Custom hook for managing draft state (localStorage persistence, save/discard).
|
|
68
99
|
*/
|
|
@@ -87,6 +118,33 @@ export function useDraftManager(options) {
|
|
|
87
118
|
// preceded the entry's first load); treated as safe, matching
|
|
88
119
|
// the behavior before base versions existed.
|
|
89
120
|
const draftBaseVersionsRef = useRef({});
|
|
121
|
+
const [draftStorageFailed, setDraftStorageFailed] = useState(false);
|
|
122
|
+
const [lastSaved, setLastSaved] = useState(null);
|
|
123
|
+
const [singleSaving, setSingleSaving] = useState(null);
|
|
124
|
+
const [saveInFlight, setSaveInFlight] = useState(false);
|
|
125
|
+
const [saveAllProgress, setSaveAllProgress] = useState(null);
|
|
126
|
+
const saveAllRunningRef = useRef(false);
|
|
127
|
+
const mountedRef = useRef(true);
|
|
128
|
+
// Entries a Save, refresh or discard settled while Save all ran: its result no longer applies.
|
|
129
|
+
const settledDuringRunRef = useRef(new Set());
|
|
130
|
+
const settleEntry = (id) => {
|
|
131
|
+
if (saveAllRunningRef.current)
|
|
132
|
+
settledDuringRunRef.current.add(id);
|
|
133
|
+
settleSaveAllEntry(id);
|
|
134
|
+
};
|
|
135
|
+
const singleSaveRef = useRef(false);
|
|
136
|
+
// contentId -> the token held when a save came back WRITE_OUTCOME_UNKNOWN, or when the retry of
|
|
137
|
+
// a timed-out save was refused as a version conflict. That save may have landed, so re-sending
|
|
138
|
+
// the token would bounce off the user's own write; held until re-read.
|
|
139
|
+
const reloadRequiredRef = useRef({});
|
|
140
|
+
// Entries whose timed-out save toast is up, taken down once a save, reload, discard or branch
|
|
141
|
+
// switch ends it, or a retry is refused as having landed.
|
|
142
|
+
const timedOutSavesRef = useRef(new Set());
|
|
143
|
+
// Entries whose "deleted" toast is up, taken down once a save, reload, discard or branch switch
|
|
144
|
+
// ends it.
|
|
145
|
+
const goneEntriesRef = useRef(new Set());
|
|
146
|
+
// Entries whose "Save as new entry" create is in flight, so a second activation sends none.
|
|
147
|
+
const recreatingRef = useRef(new Set());
|
|
90
148
|
// The draft ids the reconcile effect below has already accounted for. It
|
|
91
149
|
// prunes on an observed present -> absent TRANSITION rather than on "not
|
|
92
150
|
// currently in drafts": the restore effect stamps base versions for ids
|
|
@@ -99,52 +157,124 @@ export function useDraftManager(options) {
|
|
|
99
157
|
const storageKey = useMemo(() => `canopycms:drafts:${options.branchName}`, [options.branchName]);
|
|
100
158
|
// Draft keys are now content IDs, not paths
|
|
101
159
|
const currentId = options.currentEntry?.contentId;
|
|
160
|
+
const currentIdRef = useRef(currentId);
|
|
161
|
+
currentIdRef.current = currentId;
|
|
102
162
|
const selectedValue = currentId ? drafts[currentId] : undefined;
|
|
103
163
|
const loadedValue = currentId ? loadedValues[currentId] : undefined;
|
|
104
164
|
const effectiveValue = selectedValue ?? loadedValue;
|
|
105
165
|
const fieldErrors = useMemo(() => (errorState && errorState.entryId === currentId ? errorState.errors : {}), [errorState, currentId]);
|
|
106
|
-
//
|
|
166
|
+
// Server values read for restored drafts of unopened entries that differ from
|
|
167
|
+
// the draft. Not `loadedValues`: that map gates the entry load and OCC token.
|
|
168
|
+
const [verifiedBaselines, setVerifiedBaselines] = useState({});
|
|
169
|
+
// Ids whose verification read failed: still dirty, but no longer awaited.
|
|
170
|
+
const [unreadableIds, setUnreadableIds] = useState(() => new Set());
|
|
171
|
+
const requestedIdsRef = useRef(new Set());
|
|
172
|
+
const verifyQueueRef = useRef([]);
|
|
173
|
+
const verifyActiveRef = useRef(0);
|
|
174
|
+
const verifyWaitersRef = useRef([]);
|
|
175
|
+
const entryById = useMemo(() => new Map(options.entries.map((e) => [e.contentId, e])), [options.entries]);
|
|
176
|
+
// Empty while a branch switch loads, so only a non-empty list can say an entry is gone.
|
|
177
|
+
const entriesKnown = options.entries.length > 0;
|
|
178
|
+
// The draft ids that count as unsaved work:
|
|
107
179
|
//
|
|
108
|
-
//
|
|
180
|
+
// 1. A draft with no baseline (loaded or verified value) counts as dirty: it
|
|
181
|
+
// cannot be proven to match the server, and counting it keeps the
|
|
182
|
+
// branch-switch guard from silently discarding restored drafts.
|
|
183
|
+
// Verification (below) removes the ones that turn out pristine.
|
|
109
184
|
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// cannot prove such a draft matches server state, so we conservatively treat it
|
|
113
|
-
// as unsaved work — this is what keeps the branch-switch guard from silently
|
|
114
|
-
// discarding restored drafts.
|
|
185
|
+
// 2. A draft for an entry that no longer exists can never be loaded or saved,
|
|
186
|
+
// so it is not counted. It stays in storage.
|
|
115
187
|
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
188
|
+
// 3. The comparison is `fast-deep-equal`, so key insertion order of a
|
|
189
|
+
// rehydrated draft does not make it dirty.
|
|
190
|
+
const dirtyIds = useMemo(() => Object.keys(drafts).filter((id) => {
|
|
191
|
+
if (entriesKnown && !entryById.has(id))
|
|
192
|
+
return false;
|
|
193
|
+
const baseline = loadedValues[id] ?? verifiedBaselines[id];
|
|
194
|
+
return baseline === undefined || !equal(drafts[id], baseline);
|
|
195
|
+
}), [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById]);
|
|
196
|
+
const modifiedCount = dirtyIds.length;
|
|
197
|
+
const editedFiles = useMemo(() => dirtyIds
|
|
198
|
+
.map((id) => {
|
|
199
|
+
const entry = entryById.get(id);
|
|
200
|
+
return entry ? { path: entry.path, label: entry.label } : null;
|
|
201
|
+
})
|
|
202
|
+
.filter((x) => x !== null), [dirtyIds, entryById]);
|
|
203
|
+
// Draft ids verification can still settle.
|
|
204
|
+
const unresolvedIds = useMemo(() => options.readEntryValue && entriesKnown
|
|
205
|
+
? Object.keys(drafts).filter((id) => entryById.has(id) &&
|
|
206
|
+
loadedValues[id] === undefined &&
|
|
207
|
+
verifiedBaselines[id] === undefined &&
|
|
208
|
+
!unreadableIds.has(id))
|
|
209
|
+
: [], [
|
|
210
|
+
drafts,
|
|
211
|
+
loadedValues,
|
|
212
|
+
verifiedBaselines,
|
|
213
|
+
unreadableIds,
|
|
214
|
+
entriesKnown,
|
|
215
|
+
entryById,
|
|
216
|
+
options.readEntryValue,
|
|
217
|
+
]);
|
|
218
|
+
// Async callbacks outlive the render that created them; these give them current values.
|
|
219
|
+
const latestRef = useRef({
|
|
220
|
+
drafts,
|
|
221
|
+
loadedValues,
|
|
222
|
+
unresolvedCount: 0,
|
|
223
|
+
modifiedCount,
|
|
224
|
+
editedFiles,
|
|
225
|
+
dirtyIds,
|
|
226
|
+
entryById,
|
|
227
|
+
});
|
|
228
|
+
latestRef.current = {
|
|
229
|
+
drafts,
|
|
230
|
+
loadedValues,
|
|
231
|
+
unresolvedCount: unresolvedIds.length,
|
|
232
|
+
modifiedCount,
|
|
233
|
+
editedFiles,
|
|
234
|
+
dirtyIds,
|
|
235
|
+
entryById,
|
|
236
|
+
};
|
|
237
|
+
const latestOptionsRef = useRef(options);
|
|
238
|
+
latestOptionsRef.current = options;
|
|
134
239
|
// Clear drafts when branch changes (before localStorage restore)
|
|
135
240
|
const prevBranchRef = useRef(options.branchName);
|
|
136
241
|
useEffect(() => {
|
|
137
242
|
if (prevBranchRef.current && prevBranchRef.current !== options.branchName) {
|
|
138
243
|
setDrafts({});
|
|
139
244
|
setLoadedValues({});
|
|
245
|
+
setVerifiedBaselines({});
|
|
246
|
+
setUnreadableIds(new Set());
|
|
247
|
+
requestedIdsRef.current = new Set();
|
|
248
|
+
verifyQueueRef.current = [];
|
|
140
249
|
// Base versions are file mtimes, i.e. inherently per-branch -- carrying
|
|
141
250
|
// them across a switch would compare one branch's version against
|
|
142
251
|
// another's.
|
|
143
252
|
draftBaseVersionsRef.current = {};
|
|
253
|
+
for (const id of Object.keys(reloadRequiredRef.current)) {
|
|
254
|
+
notifications.hide(saveMayHaveLandedToastId(id));
|
|
255
|
+
}
|
|
256
|
+
reloadRequiredRef.current = {};
|
|
257
|
+
// A timed-out save belongs to the branch it was sent to; content ids repeat across branches.
|
|
258
|
+
for (const id of timedOutSavesRef.current)
|
|
259
|
+
notifications.hide(saveTimedOutToastId(id));
|
|
260
|
+
timedOutSavesRef.current.clear();
|
|
261
|
+
for (const id of goneEntriesRef.current)
|
|
262
|
+
notifications.hide(entryGoneToastId(id));
|
|
263
|
+
goneEntriesRef.current.clear();
|
|
264
|
+
// Its entries, and their "Open" and "Save as new entry" buttons, are the old branch's.
|
|
265
|
+
clearSaveAllResult();
|
|
144
266
|
reconciledDraftIdsRef.current = new Set();
|
|
145
267
|
}
|
|
146
268
|
prevBranchRef.current = options.branchName;
|
|
147
269
|
}, [options.branchName]);
|
|
270
|
+
// A result left up would act through this unmounted instance's callbacks.
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
mountedRef.current = true;
|
|
273
|
+
return () => {
|
|
274
|
+
mountedRef.current = false;
|
|
275
|
+
clearSaveAllResult();
|
|
276
|
+
};
|
|
277
|
+
}, []);
|
|
148
278
|
// Restore drafts from localStorage on mount or when storageKey changes
|
|
149
279
|
useEffect(() => {
|
|
150
280
|
if (typeof window === 'undefined')
|
|
@@ -256,11 +386,145 @@ export function useDraftManager(options) {
|
|
|
256
386
|
baseVersions: draftBaseVersionsRef.current,
|
|
257
387
|
};
|
|
258
388
|
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
|
389
|
+
setDraftStorageFailed(false);
|
|
259
390
|
}
|
|
260
391
|
catch (err) {
|
|
261
392
|
console.warn('Failed to persist drafts', err);
|
|
393
|
+
setDraftStorageFailed(true);
|
|
262
394
|
}
|
|
263
395
|
}, [drafts, storageKey]);
|
|
396
|
+
// Drafts survive a reload in localStorage, so leaving is guarded only when it loses work.
|
|
397
|
+
const leaveLosesWork = saveInFlight || (draftStorageFailed && modifiedCount > 0);
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (!leaveLosesWork || typeof window === 'undefined')
|
|
400
|
+
return;
|
|
401
|
+
const warn = (event) => {
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
event.returnValue = '';
|
|
404
|
+
};
|
|
405
|
+
window.addEventListener('beforeunload', warn);
|
|
406
|
+
return () => window.removeEventListener('beforeunload', warn);
|
|
407
|
+
}, [leaveLosesWork]);
|
|
408
|
+
// A pre-key draft gains the keys a read gives, so an unchanged one drops, if free of references.
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
const keyed = new Map();
|
|
411
|
+
for (const [id, draft] of Object.entries(drafts)) {
|
|
412
|
+
const schema = entryById.get(id)?.schema;
|
|
413
|
+
const next = schema ? withItemKeys(schema, draft, 'content') : draft;
|
|
414
|
+
if (next !== draft)
|
|
415
|
+
keyed.set(id, next);
|
|
416
|
+
}
|
|
417
|
+
if (keyed.size === 0)
|
|
418
|
+
return;
|
|
419
|
+
setDrafts((prev) => {
|
|
420
|
+
const next = { ...prev };
|
|
421
|
+
for (const [id, value] of keyed)
|
|
422
|
+
if (prev[id] === drafts[id])
|
|
423
|
+
next[id] = value;
|
|
424
|
+
return next;
|
|
425
|
+
});
|
|
426
|
+
}, [drafts, entryById]);
|
|
427
|
+
// A draft equal to its entry's loaded value is not an edit. Drops it so it
|
|
428
|
+
// neither counts as dirty nor lingers in storage.
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
const redundant = Object.keys(drafts).filter((id) => loadedValues[id] !== undefined && equal(drafts[id], loadedValues[id]));
|
|
431
|
+
if (redundant.length === 0)
|
|
432
|
+
return;
|
|
433
|
+
setDrafts((prev) => {
|
|
434
|
+
const next = { ...prev };
|
|
435
|
+
let changed = false;
|
|
436
|
+
for (const id of redundant) {
|
|
437
|
+
if (id in next && equal(next[id], loadedValues[id])) {
|
|
438
|
+
delete next[id];
|
|
439
|
+
changed = true;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return changed ? next : prev;
|
|
443
|
+
});
|
|
444
|
+
}, [drafts, loadedValues]);
|
|
445
|
+
// Checks one restored draft against the server. The branch is re-read at
|
|
446
|
+
// settle time: a switch mid-read makes the result another branch's value.
|
|
447
|
+
const verifyDraft = async (job) => {
|
|
448
|
+
const id = job.entry.contentId;
|
|
449
|
+
const read = latestOptionsRef.current.readEntryValue;
|
|
450
|
+
if (!read)
|
|
451
|
+
return;
|
|
452
|
+
// The server refuses to read an entry of an unavailable type, so it settles unread at once.
|
|
453
|
+
if (job.entry.unavailable) {
|
|
454
|
+
setUnreadableIds((prev) => new Set(prev).add(id));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
const server = await read(job.entry);
|
|
459
|
+
if (latestOptionsRef.current.branchName !== job.branch)
|
|
460
|
+
return;
|
|
461
|
+
const { drafts: latestDrafts, loadedValues: latestLoaded } = latestRef.current;
|
|
462
|
+
// Gone, or the entry's own load has taken over as the baseline.
|
|
463
|
+
if (latestDrafts[id] === undefined || latestLoaded[id] !== undefined)
|
|
464
|
+
return;
|
|
465
|
+
if (equal(latestDrafts[id], server)) {
|
|
466
|
+
setDrafts((prev) => {
|
|
467
|
+
if (!(id in prev) || !equal(prev[id], server))
|
|
468
|
+
return prev;
|
|
469
|
+
const next = { ...prev };
|
|
470
|
+
delete next[id];
|
|
471
|
+
return next;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
setVerifiedBaselines((prev) => ({ ...prev, [id]: server }));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// Stays unverified, so the draft keeps counting as dirty.
|
|
480
|
+
if (latestOptionsRef.current.branchName !== job.branch)
|
|
481
|
+
return;
|
|
482
|
+
setUnreadableIds((prev) => new Set(prev).add(id));
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
const pumpVerification = () => {
|
|
486
|
+
while (verifyActiveRef.current < VERIFY_CONCURRENCY && verifyQueueRef.current.length > 0) {
|
|
487
|
+
const job = verifyQueueRef.current.shift();
|
|
488
|
+
if (!job)
|
|
489
|
+
break;
|
|
490
|
+
verifyActiveRef.current++;
|
|
491
|
+
void verifyDraft(job).finally(() => {
|
|
492
|
+
verifyActiveRef.current--;
|
|
493
|
+
pumpVerification();
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
// Once per branch session, reads the server value of every restored draft
|
|
498
|
+
// whose entry has not loaded. The selected entry is left to its own load,
|
|
499
|
+
// which fills `loadedValues` and lets the effect above drop a pristine draft.
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
if (!options.readEntryValue || !entriesKnown)
|
|
502
|
+
return;
|
|
503
|
+
// `drafts` lags a branch switch by a render (see draftsStorageKeyRef); reading
|
|
504
|
+
// the old branch's drafts under the new branch's name would be wrong.
|
|
505
|
+
if (draftsStorageKeyRef.current !== storageKey)
|
|
506
|
+
return;
|
|
507
|
+
const todo = unresolvedIds.filter((id) => id !== currentId && !requestedIdsRef.current.has(id));
|
|
508
|
+
if (todo.length === 0)
|
|
509
|
+
return;
|
|
510
|
+
for (const id of todo) {
|
|
511
|
+
requestedIdsRef.current.add(id);
|
|
512
|
+
const entry = entryById.get(id);
|
|
513
|
+
if (entry)
|
|
514
|
+
verifyQueueRef.current.push({ entry, branch: options.branchName });
|
|
515
|
+
}
|
|
516
|
+
pumpVerification();
|
|
517
|
+
// pumpVerification/verifyDraft only touch refs and stable setters.
|
|
518
|
+
}, [unresolvedIds, currentId, storageKey]);
|
|
519
|
+
// Wakes `whenDraftsVerified` callers once nothing is left to settle.
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
if (unresolvedIds.length > 0)
|
|
522
|
+
return;
|
|
523
|
+
const waiters = verifyWaitersRef.current;
|
|
524
|
+
verifyWaitersRef.current = [];
|
|
525
|
+
for (const wake of waiters)
|
|
526
|
+
wake();
|
|
527
|
+
}, [unresolvedIds]);
|
|
264
528
|
// Recomputes field errors as the user edits, or as the selected entry's
|
|
265
529
|
// schema/format changes while it stays open, so each error clears when its
|
|
266
530
|
// field is fixed or no longer required. Errors for a different entry are
|
|
@@ -297,20 +561,123 @@ export function useDraftManager(options) {
|
|
|
297
561
|
});
|
|
298
562
|
}, [effectiveValue, currentId, options.currentEntry?.schema, options.currentEntry?.format]);
|
|
299
563
|
/**
|
|
300
|
-
* The 409 presentation
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* recovery for both.
|
|
564
|
+
* The 409 presentation. Server 409s differ in cause and recovery (version
|
|
565
|
+
* moved on, branch syncing, write may have landed), so their own wording is
|
|
566
|
+
* shown; the default is the version-mismatch wording the stale-base check
|
|
567
|
+
* below shares.
|
|
305
568
|
*/
|
|
306
|
-
const showConflictNotification = () => {
|
|
569
|
+
const showConflictNotification = (message = ENTRY_CHANGED_MESSAGE) => {
|
|
307
570
|
notifications.show({
|
|
308
|
-
message
|
|
571
|
+
message,
|
|
309
572
|
color: 'yellow',
|
|
310
573
|
autoClose: getNotificationDuration(8000),
|
|
311
574
|
withCloseButton: true,
|
|
312
575
|
});
|
|
313
576
|
};
|
|
577
|
+
/**
|
|
578
|
+
* Stays until dismissed or the entry is reloaded: saving is held until then. Named, since it
|
|
579
|
+
* outlasts opening other entries.
|
|
580
|
+
*/
|
|
581
|
+
const showSaveMayHaveLanded = (entry, message = SAVE_MAY_HAVE_LANDED_MESSAGE) => {
|
|
582
|
+
notifications.hide(saveMayHaveLandedToastId(entry.contentId));
|
|
583
|
+
notifications.show({
|
|
584
|
+
id: saveMayHaveLandedToastId(entry.contentId),
|
|
585
|
+
title: `No answer saving "${entry.label}"`,
|
|
586
|
+
message: message || SAVE_MAY_HAVE_LANDED_MESSAGE,
|
|
587
|
+
color: 'yellow',
|
|
588
|
+
autoClose: false,
|
|
589
|
+
withCloseButton: true,
|
|
590
|
+
});
|
|
591
|
+
};
|
|
592
|
+
const hideEntryGone = (contentId) => {
|
|
593
|
+
if (goneEntriesRef.current.delete(contentId))
|
|
594
|
+
notifications.hide(entryGoneToastId(contentId));
|
|
595
|
+
};
|
|
596
|
+
/**
|
|
597
|
+
* Sticky: the form shows the changes only until the list next refreshes and closes it. Offers the
|
|
598
|
+
* deliberate create on the save's branch. Hidden first so a repeat is announced again.
|
|
599
|
+
*/
|
|
600
|
+
const showEntryGone = (entry, branch, message = ENTRY_DELETED_MESSAGE, offerCreate = true) => {
|
|
601
|
+
goneEntriesRef.current.add(entry.contentId);
|
|
602
|
+
notifications.hide(entryGoneToastId(entry.contentId));
|
|
603
|
+
notifications.show({
|
|
604
|
+
id: entryGoneToastId(entry.contentId),
|
|
605
|
+
title: `"${entry.label}" not saved`,
|
|
606
|
+
message: offerCreate
|
|
607
|
+
? createElement(RetryToastMessage, {
|
|
608
|
+
message,
|
|
609
|
+
actionLabel: 'Save as new entry',
|
|
610
|
+
onRetry: () => void recreateGoneEntry(entry, branch),
|
|
611
|
+
})
|
|
612
|
+
: message,
|
|
613
|
+
color: 'yellow',
|
|
614
|
+
autoClose: false,
|
|
615
|
+
withCloseButton: true,
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
/** "Save as new entry", once at a time, and only on its branch: after a switch the drafts are another branch's. */
|
|
619
|
+
const recreateGoneEntry = async (entry, branch) => {
|
|
620
|
+
const id = entry.contentId;
|
|
621
|
+
const onBranch = () => latestOptionsRef.current.branchName === branch;
|
|
622
|
+
const inFlightKey = `${branch}:${id}`;
|
|
623
|
+
if (recreatingRef.current.has(inFlightKey))
|
|
624
|
+
return;
|
|
625
|
+
if (!onBranch()) {
|
|
626
|
+
notifications.hide(entryGoneToastId(id));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const { drafts: latestDrafts, loadedValues: latestLoaded } = latestRef.current;
|
|
630
|
+
const value = latestDrafts[id] ?? latestLoaded[id];
|
|
631
|
+
const recreate = latestOptionsRef.current.recreateEntry;
|
|
632
|
+
if (!value || !recreate)
|
|
633
|
+
return;
|
|
634
|
+
recreatingRef.current.add(inFlightKey);
|
|
635
|
+
hideEntryGone(id);
|
|
636
|
+
options.setBusy(true);
|
|
637
|
+
try {
|
|
638
|
+
const created = await recreate(entry, value);
|
|
639
|
+
if (!onBranch())
|
|
640
|
+
return;
|
|
641
|
+
if (created) {
|
|
642
|
+
// The changes now live in the new entry, under a new content ID; edits made while the
|
|
643
|
+
// create was in flight follow it there.
|
|
644
|
+
setDrafts((prev) => {
|
|
645
|
+
if (!(id in prev))
|
|
646
|
+
return prev;
|
|
647
|
+
const { [id]: latest, ...rest } = prev;
|
|
648
|
+
return equal(latest, value) ? rest : { ...rest, [created.contentId]: latest };
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
if (created)
|
|
652
|
+
settleEntry(id);
|
|
653
|
+
notifications.show({
|
|
654
|
+
message: created ? 'Saved as a new entry' : RECREATED_NOT_LISTED_MESSAGE,
|
|
655
|
+
color: 'green',
|
|
656
|
+
autoClose: getNotificationDuration(created ? 4000 : 8000),
|
|
657
|
+
withCloseButton: true,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
console.error(err);
|
|
662
|
+
if (!onBranch())
|
|
663
|
+
return;
|
|
664
|
+
if (err instanceof SaveApiError && err.status === 409) {
|
|
665
|
+
showEntryGone(entry, branch, RECREATE_TAKEN_MESSAGE);
|
|
666
|
+
}
|
|
667
|
+
else if (isUnanswered(err)) {
|
|
668
|
+
showEntryGone(entry, branch, RECREATE_TIMED_OUT_MESSAGE);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
showEntryGone(entry, branch, err instanceof SaveApiError && err.serverMessage
|
|
672
|
+
? `It was not saved as a new entry: ${err.serverMessage}`
|
|
673
|
+
: 'It was not saved as a new entry. Your changes are still in the form.');
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
finally {
|
|
677
|
+
recreatingRef.current.delete(inFlightKey);
|
|
678
|
+
options.setBusy(false);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
314
681
|
const draftBaseState = () => {
|
|
315
682
|
if (!currentId || drafts[currentId] === undefined)
|
|
316
683
|
return 'ok';
|
|
@@ -345,12 +712,13 @@ export function useDraftManager(options) {
|
|
|
345
712
|
decided = true;
|
|
346
713
|
resolve(value);
|
|
347
714
|
};
|
|
348
|
-
|
|
715
|
+
openConfirm({
|
|
349
716
|
title: 'Unsaved draft from an older editor version',
|
|
350
717
|
children: 'This draft was saved by an earlier version of the editor, which did not record ' +
|
|
351
|
-
'which version of the
|
|
718
|
+
'which version of the entry it was based on. Saving it will overwrite the copy ' +
|
|
352
719
|
'currently on the server, including any changes made by someone else since. ' +
|
|
353
|
-
'
|
|
720
|
+
'Choose "Refresh from server" or "Discard changes" in the entry\'s menu, on its name, ' +
|
|
721
|
+
'instead if you would rather not risk that.',
|
|
354
722
|
labels: { confirm: 'Save anyway', cancel: 'Cancel' },
|
|
355
723
|
confirmProps: { color: 'red' },
|
|
356
724
|
onConfirm: () => settle(true),
|
|
@@ -362,10 +730,23 @@ export function useDraftManager(options) {
|
|
|
362
730
|
});
|
|
363
731
|
});
|
|
364
732
|
const handleSave = async () => {
|
|
365
|
-
|
|
733
|
+
// Save all may be sending this entry with the same token; the loser's 409 would be a false conflict.
|
|
734
|
+
if (saveAllRunningRef.current)
|
|
366
735
|
return;
|
|
736
|
+
const entry = options.currentEntry;
|
|
737
|
+
const branch = options.branchName;
|
|
738
|
+
if (!entry || !effectiveValue || !currentId)
|
|
739
|
+
return;
|
|
740
|
+
if (currentId in reloadRequiredRef.current) {
|
|
741
|
+
if (reloadRequiredRef.current[currentId] === options.getEntryVersion?.(currentId)) {
|
|
742
|
+
showSaveMayHaveLanded(entry);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
delete reloadRequiredRef.current[currentId];
|
|
746
|
+
notifications.hide(saveMayHaveLandedToastId(currentId));
|
|
747
|
+
}
|
|
367
748
|
// Conflict check first: a stale-based draft must not be schema-validated
|
|
368
|
-
// into a
|
|
749
|
+
// into a "Saved" -- and nagging about field errors is noise when the
|
|
369
750
|
// save is not going out at all.
|
|
370
751
|
const baseState = draftBaseState();
|
|
371
752
|
if (baseState === 'stale') {
|
|
@@ -384,10 +765,11 @@ export function useDraftManager(options) {
|
|
|
384
765
|
}
|
|
385
766
|
// Client-side pre-save validation (ED-H1): the same pure schema rules the
|
|
386
767
|
// server enforces authoritatively at the write boundary. Blocks the save
|
|
387
|
-
// and surfaces per-field errors in the form instead of a
|
|
388
|
-
const validationErrors = validateEntryFormValue(
|
|
768
|
+
// and surfaces per-field errors in the form instead of a "Saved".
|
|
769
|
+
const validationErrors = validateEntryFormValue(entry.schema, entry.format, effectiveValue);
|
|
389
770
|
if (validationErrors.length > 0) {
|
|
390
771
|
setErrorState({ entryId: currentId, errors: toFieldErrorMap(validationErrors) });
|
|
772
|
+
options.onSaveBlocked?.();
|
|
391
773
|
notifications.show({
|
|
392
774
|
title: 'Cannot save yet',
|
|
393
775
|
message: `Fix ${validationErrors.length} validation ${validationErrors.length === 1 ? 'issue' : 'issues'} before saving`,
|
|
@@ -399,17 +781,21 @@ export function useDraftManager(options) {
|
|
|
399
781
|
}
|
|
400
782
|
setErrorState(null);
|
|
401
783
|
options.setBusy(true);
|
|
784
|
+
setSaveInFlight(true);
|
|
785
|
+
setSingleSaving({ branch, contentId: currentId });
|
|
786
|
+
singleSaveRef.current = true;
|
|
402
787
|
try {
|
|
403
|
-
const saved = await options.saveEntry(
|
|
788
|
+
const saved = await options.saveEntry(entry, effectiveValue);
|
|
789
|
+
if (latestOptionsRef.current.branchName !== branch)
|
|
790
|
+
return;
|
|
404
791
|
// Drop the draft now that it has been persisted, rather than
|
|
405
792
|
// overwriting it with `saved`. `effectiveValue` is `drafts[currentId]
|
|
406
793
|
// ?? loadedValues[currentId]`, and `loadedValues[currentId]` is about
|
|
407
794
|
// to become `saved` below, so removing the draft key is a no-op for
|
|
408
795
|
// the rendered value while fixing the "phantom dirty" bug: a draft
|
|
409
796
|
// that lingers forever is what made every fresh page load show Save
|
|
410
|
-
// enabled with zero real edits (see
|
|
411
|
-
//
|
|
412
|
-
// treated as dirty).
|
|
797
|
+
// enabled with zero real edits (see `dirtyIds` above: a draft with no
|
|
798
|
+
// baseline is conservatively treated as dirty).
|
|
413
799
|
setDrafts((prev) => {
|
|
414
800
|
if (!(currentId in prev))
|
|
415
801
|
return prev;
|
|
@@ -418,16 +804,20 @@ export function useDraftManager(options) {
|
|
|
418
804
|
return next;
|
|
419
805
|
});
|
|
420
806
|
setLoadedValues((prev) => ({ ...prev, [currentId]: saved }));
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
807
|
+
if (timedOutSavesRef.current.delete(currentId)) {
|
|
808
|
+
notifications.hide(saveTimedOutToastId(currentId));
|
|
809
|
+
}
|
|
810
|
+
hideEntryGone(currentId);
|
|
811
|
+
settleEntry(currentId);
|
|
812
|
+
// Said on the Save button, not in a toast.
|
|
813
|
+
setLastSaved((prev) => ({ branch, contentId: currentId, seq: (prev?.seq ?? 0) + 1 }));
|
|
427
814
|
options.onSaved?.();
|
|
428
815
|
}
|
|
429
816
|
catch (err) {
|
|
430
817
|
console.error(err);
|
|
818
|
+
// Content ids repeat across branches: none of what follows belongs on the next one.
|
|
819
|
+
if (latestOptionsRef.current.branchName !== branch)
|
|
820
|
+
return;
|
|
431
821
|
const isConflict = err instanceof SaveApiError && err.status === 409;
|
|
432
822
|
// 422 = rejected by server-side schema validation or the adopter's
|
|
433
823
|
// validateEntry hook; show its message and map per-field errors (e.g.
|
|
@@ -445,31 +835,330 @@ export function useDraftManager(options) {
|
|
|
445
835
|
// the moment the user is most confused, so surface the server's own
|
|
446
836
|
// message instead, the same way the 422 branch already does.
|
|
447
837
|
const isForbidden = err instanceof SaveApiError && err.status === 403;
|
|
838
|
+
// The entry's type has no schema in the running code; the server's message says why.
|
|
839
|
+
const isUnavailable = err instanceof SaveApiError && err.code === SCHEMA_UNAVAILABLE_CODE;
|
|
448
840
|
if (err instanceof SaveApiError &&
|
|
449
841
|
err.status === 422 &&
|
|
450
842
|
err.fieldErrors &&
|
|
451
843
|
err.fieldErrors.length > 0) {
|
|
452
844
|
setErrorState({ entryId: currentId, errors: toFieldErrorMap(err.fieldErrors) });
|
|
845
|
+
options.onSaveBlocked?.();
|
|
453
846
|
}
|
|
454
|
-
if (isConflict) {
|
|
455
|
-
|
|
847
|
+
if (isConflict && err.code === ENTRY_GONE_CODE) {
|
|
848
|
+
// Deleted or renamed since it was read, by someone else or by a delete or rename of this
|
|
849
|
+
// user's that got no answer. A retry cannot save it, so the timed-out toast's "save
|
|
850
|
+
// again" no longer applies. A rename keeps the content ID, so the entry is looked for
|
|
851
|
+
// under its new name, where the draft follows it.
|
|
852
|
+
if (timedOutSavesRef.current.delete(currentId)) {
|
|
853
|
+
notifications.hide(saveTimedOutToastId(currentId));
|
|
854
|
+
}
|
|
855
|
+
let moved;
|
|
856
|
+
let checked = true;
|
|
857
|
+
try {
|
|
858
|
+
moved = await options.followMovedEntry?.(entry);
|
|
859
|
+
}
|
|
860
|
+
catch (followErr) {
|
|
861
|
+
console.error(followErr);
|
|
862
|
+
checked = false;
|
|
863
|
+
}
|
|
864
|
+
// A branch switch meanwhile cleared this branch's toasts; none of these belong on the next.
|
|
865
|
+
if (latestOptionsRef.current.branchName !== branch)
|
|
866
|
+
return;
|
|
867
|
+
if (moved) {
|
|
868
|
+
hideEntryGone(currentId);
|
|
869
|
+
notifications.show({
|
|
870
|
+
title: `"${entry.label}" not saved`,
|
|
871
|
+
message: ENTRY_RENAMED_MESSAGE,
|
|
872
|
+
color: 'yellow',
|
|
873
|
+
autoClose: getNotificationDuration(8000),
|
|
874
|
+
withCloseButton: true,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
else if (checked) {
|
|
878
|
+
showEntryGone(entry, branch);
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
// Deleted or renamed, but which is unknown, so a create could duplicate a renamed entry.
|
|
882
|
+
showEntryGone(entry, branch, ENTRY_GONE_UNCHECKED_MESSAGE, false);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
else if (isConflict) {
|
|
886
|
+
// A version conflict on the retry of a save that went unanswered is most likely that
|
|
887
|
+
// save having landed, which a reload settles just as for WRITE_OUTCOME_UNKNOWN.
|
|
888
|
+
const ownSaveLanded = err.serverMessage === ENTRY_CHANGED_MESSAGE && timedOutSavesRef.current.has(currentId);
|
|
889
|
+
if (err.code === 'WRITE_OUTCOME_UNKNOWN' || ownSaveLanded) {
|
|
890
|
+
reloadRequiredRef.current[currentId] = options.getEntryVersion?.(currentId);
|
|
891
|
+
}
|
|
892
|
+
if (ownSaveLanded) {
|
|
893
|
+
timedOutSavesRef.current.delete(currentId);
|
|
894
|
+
notifications.hide(saveTimedOutToastId(currentId));
|
|
895
|
+
}
|
|
896
|
+
if (err.code === 'WRITE_OUTCOME_UNKNOWN' || ownSaveLanded) {
|
|
897
|
+
showSaveMayHaveLanded(entry, ownSaveLanded ? undefined : err.serverMessage);
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
showConflictNotification(err.serverMessage || undefined);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
else if (isUnanswered(err)) {
|
|
904
|
+
// A save that did land moved the file's version, so the server refuses the retry as a
|
|
905
|
+
// conflict; a file since deleted or moved refuses it as gone (`ContentStore.write`).
|
|
906
|
+
timedOutSavesRef.current.add(currentId);
|
|
907
|
+
notifications.show({
|
|
908
|
+
id: saveTimedOutToastId(currentId),
|
|
909
|
+
title: `No answer saving "${entry.label}"`,
|
|
910
|
+
message: SAVE_TIMED_OUT_MESSAGE,
|
|
911
|
+
color: 'yellow',
|
|
912
|
+
autoClose: false,
|
|
913
|
+
withCloseButton: true,
|
|
914
|
+
});
|
|
456
915
|
}
|
|
457
916
|
else {
|
|
917
|
+
const explained = isValidation || isForbidden || isUnavailable;
|
|
458
918
|
notifications.show({
|
|
459
919
|
...(isValidation ? { title: 'Save rejected' } : {}),
|
|
460
920
|
...(isForbidden ? { title: 'Save not allowed' } : {}),
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
921
|
+
...(isUnavailable ? { title: 'Not saved yet' } : {}),
|
|
922
|
+
message: explained ? err.message : 'Save failed',
|
|
923
|
+
color: isUnavailable ? 'yellow' : 'red',
|
|
924
|
+
autoClose: getNotificationDuration(explained ? 8000 : 6000),
|
|
464
925
|
withCloseButton: true,
|
|
465
926
|
});
|
|
466
927
|
}
|
|
467
928
|
}
|
|
468
929
|
finally {
|
|
930
|
+
singleSaveRef.current = false;
|
|
931
|
+
setSingleSaving(null);
|
|
932
|
+
setSaveInFlight(false);
|
|
933
|
+
options.setBusy(false);
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
/**
|
|
937
|
+
* A save that got no answer landed if the server now holds exactly what was sent. Only that
|
|
938
|
+
* answer settles it: a differing value may be the server's normalisation of a save that did
|
|
939
|
+
* land, so it leaves the token alone and the retry's 409 decides, as for `handleSave`.
|
|
940
|
+
*/
|
|
941
|
+
const settleUnansweredSave = async (entry, sent) => {
|
|
942
|
+
const { readEntryValue, loadEntry } = latestOptionsRef.current;
|
|
943
|
+
try {
|
|
944
|
+
if (!readEntryValue || !equal(await readEntryValue(entry), sent))
|
|
945
|
+
return undefined;
|
|
946
|
+
const loaded = await loadEntry(entry);
|
|
947
|
+
return equal(loaded, sent) ? loaded : undefined;
|
|
948
|
+
}
|
|
949
|
+
catch (err) {
|
|
950
|
+
console.error(err);
|
|
951
|
+
return undefined;
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
/**
|
|
955
|
+
* One entry of Save all, through the same checks as `handleSave`: held saves, the draft's base
|
|
956
|
+
* version, schema validation, then the OCC-checked write. An entry not opened this session has
|
|
957
|
+
* no token yet, so it is loaded first, which also lets the base check see whether it moved on.
|
|
958
|
+
* Returns undefined when the draft went away or the branch changed meanwhile.
|
|
959
|
+
*/
|
|
960
|
+
const saveOneOfAll = async (entry, branch) => {
|
|
961
|
+
const id = entry.contentId;
|
|
962
|
+
const sent = latestRef.current.drafts[id];
|
|
963
|
+
if (sent === undefined)
|
|
964
|
+
return undefined;
|
|
965
|
+
const onBranch = () => latestOptionsRef.current.branchName === branch;
|
|
966
|
+
const fail = (kind, reason, extra) => ({
|
|
967
|
+
saved: false,
|
|
968
|
+
failure: { contentId: id, label: entry.label, path: entry.path, kind, reason, ...extra },
|
|
969
|
+
});
|
|
970
|
+
const { getEntryVersion, loadEntry, saveEntry } = latestOptionsRef.current;
|
|
971
|
+
if (entry.unavailable)
|
|
972
|
+
return fail('blocked', SAVE_ALL_REASONS.unavailable);
|
|
973
|
+
if (entry.canEdit === false)
|
|
974
|
+
return fail('blocked', SAVE_ALL_REASONS.noEditAccess);
|
|
975
|
+
if (id in reloadRequiredRef.current) {
|
|
976
|
+
if (reloadRequiredRef.current[id] === getEntryVersion?.(id)) {
|
|
977
|
+
return fail('unanswered', SAVE_ALL_REASONS.earlierUnanswered);
|
|
978
|
+
}
|
|
979
|
+
delete reloadRequiredRef.current[id];
|
|
980
|
+
notifications.hide(saveMayHaveLandedToastId(id));
|
|
981
|
+
}
|
|
982
|
+
if (getEntryVersion?.(id) === undefined) {
|
|
983
|
+
try {
|
|
984
|
+
await loadEntry(entry);
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
console.error(err);
|
|
988
|
+
return fail('error', SAVE_ALL_REASONS.loadFailed);
|
|
989
|
+
}
|
|
990
|
+
if (!onBranch())
|
|
991
|
+
return undefined;
|
|
992
|
+
}
|
|
993
|
+
const base = draftBaseVersionsRef.current[id];
|
|
994
|
+
if (base === null)
|
|
995
|
+
return fail('conflict', SAVE_ALL_REASONS.unknownBase);
|
|
996
|
+
if (base !== undefined && base !== getEntryVersion?.(id)) {
|
|
997
|
+
return fail('conflict', SAVE_ALL_REASONS.staleBase);
|
|
998
|
+
}
|
|
999
|
+
const validationErrors = validateEntryFormValue(entry.schema, entry.format, sent);
|
|
1000
|
+
if (validationErrors.length > 0) {
|
|
1001
|
+
if (id === currentIdRef.current) {
|
|
1002
|
+
setErrorState({ entryId: id, errors: toFieldErrorMap(validationErrors) });
|
|
1003
|
+
latestOptionsRef.current.onSaveBlocked?.();
|
|
1004
|
+
}
|
|
1005
|
+
return fail('invalid', invalidReason(validationErrors.length));
|
|
1006
|
+
}
|
|
1007
|
+
const commitSaved = (saved) => {
|
|
1008
|
+
// Decided from `prev`, which holds an edit queued but not yet rendered.
|
|
1009
|
+
setDrafts((prev) => {
|
|
1010
|
+
if (!(id in prev))
|
|
1011
|
+
return prev;
|
|
1012
|
+
if (!equal(prev[id], sent)) {
|
|
1013
|
+
// Edits typed while the save was out stay a draft, based on the version just written; a
|
|
1014
|
+
// new object, so that base is persisted. Idempotent, as a strict-mode rerun requires.
|
|
1015
|
+
const version = latestOptionsRef.current.getEntryVersion?.(id);
|
|
1016
|
+
if (version !== undefined)
|
|
1017
|
+
draftBaseVersionsRef.current[id] = version;
|
|
1018
|
+
return { ...prev };
|
|
1019
|
+
}
|
|
1020
|
+
const next = { ...prev };
|
|
1021
|
+
delete next[id];
|
|
1022
|
+
return next;
|
|
1023
|
+
});
|
|
1024
|
+
setLoadedValues((prev) => ({ ...prev, [id]: saved }));
|
|
1025
|
+
if (timedOutSavesRef.current.delete(id))
|
|
1026
|
+
notifications.hide(saveTimedOutToastId(id));
|
|
1027
|
+
hideEntryGone(id);
|
|
1028
|
+
};
|
|
1029
|
+
try {
|
|
1030
|
+
const saved = await saveEntry(entry, sent);
|
|
1031
|
+
// Another branch's state now: its loaded values and draft bases are not this write's.
|
|
1032
|
+
if (!onBranch())
|
|
1033
|
+
return undefined;
|
|
1034
|
+
commitSaved(saved);
|
|
1035
|
+
return { saved: true };
|
|
1036
|
+
}
|
|
1037
|
+
catch (err) {
|
|
1038
|
+
console.error(err);
|
|
1039
|
+
if (!onBranch())
|
|
1040
|
+
return undefined;
|
|
1041
|
+
const outcome = classifySaveAllError(err, timedOutSavesRef.current.has(id));
|
|
1042
|
+
switch (outcome.kind) {
|
|
1043
|
+
case 'unanswered': {
|
|
1044
|
+
const landed = await settleUnansweredSave(entry, sent);
|
|
1045
|
+
if (!onBranch())
|
|
1046
|
+
return undefined;
|
|
1047
|
+
if (landed) {
|
|
1048
|
+
commitSaved(landed);
|
|
1049
|
+
return { saved: true };
|
|
1050
|
+
}
|
|
1051
|
+
timedOutSavesRef.current.add(id);
|
|
1052
|
+
return fail('unanswered', SAVE_ALL_REASONS.unanswered);
|
|
1053
|
+
}
|
|
1054
|
+
case 'landed?':
|
|
1055
|
+
reloadRequiredRef.current[id] = getEntryVersion?.(id);
|
|
1056
|
+
timedOutSavesRef.current.delete(id);
|
|
1057
|
+
notifications.hide(saveTimedOutToastId(id));
|
|
1058
|
+
return fail('unanswered', SAVE_ALL_REASONS.earlierUnanswered);
|
|
1059
|
+
case 'gone': {
|
|
1060
|
+
if (timedOutSavesRef.current.delete(id))
|
|
1061
|
+
notifications.hide(saveTimedOutToastId(id));
|
|
1062
|
+
let moved;
|
|
1063
|
+
try {
|
|
1064
|
+
moved = await latestOptionsRef.current.followMovedEntry?.(entry);
|
|
1065
|
+
}
|
|
1066
|
+
catch (followErr) {
|
|
1067
|
+
console.error(followErr);
|
|
1068
|
+
return fail('conflict', SAVE_ALL_REASONS.goneUnchecked);
|
|
1069
|
+
}
|
|
1070
|
+
// "Open" in the result goes to where the changes moved.
|
|
1071
|
+
if (moved)
|
|
1072
|
+
return fail('conflict', SAVE_ALL_REASONS.renamed, { path: moved.path });
|
|
1073
|
+
return fail('conflict', SAVE_ALL_REASONS.deleted, {
|
|
1074
|
+
recreate: () => void recreateGoneEntry(entry, branch),
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
default:
|
|
1078
|
+
if (outcome.kind === 'invalid' && id === currentIdRef.current) {
|
|
1079
|
+
const fieldErrors = err instanceof SaveApiError ? err.fieldErrors : undefined;
|
|
1080
|
+
if (fieldErrors?.length) {
|
|
1081
|
+
setErrorState({ entryId: id, errors: toFieldErrorMap(fieldErrors) });
|
|
1082
|
+
latestOptionsRef.current.onSaveBlocked?.();
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return fail(outcome.kind, outcome.reason, outcome.retryable ? { retryable: true } : undefined);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
const handleSaveAll = async () => {
|
|
1090
|
+
// A Save in flight would race this run's write of the same entry, with the same token.
|
|
1091
|
+
if (saveAllRunningRef.current || singleSaveRef.current)
|
|
1092
|
+
return undefined;
|
|
1093
|
+
saveAllRunningRef.current = true;
|
|
1094
|
+
settledDuringRunRef.current = new Set();
|
|
1095
|
+
const branch = options.branchName;
|
|
1096
|
+
// Busy from the first moment, so Save waits through the draft check too.
|
|
1097
|
+
options.setBusy(true);
|
|
1098
|
+
setSaveInFlight(true);
|
|
1099
|
+
setSaveAllProgress({ done: 0, total: latestRef.current.modifiedCount });
|
|
1100
|
+
try {
|
|
1101
|
+
// A restored draft identical to the server is not a change, so it isn't saved.
|
|
1102
|
+
await whenDraftsVerified();
|
|
1103
|
+
if (latestOptionsRef.current.branchName !== branch)
|
|
1104
|
+
return undefined;
|
|
1105
|
+
const { dirtyIds: ids, entryById: byId } = latestRef.current;
|
|
1106
|
+
const current = currentIdRef.current;
|
|
1107
|
+
const queue = [...ids]
|
|
1108
|
+
.sort((a, b) => Number(b === current) - Number(a === current))
|
|
1109
|
+
.flatMap((id) => {
|
|
1110
|
+
const entry = byId.get(id);
|
|
1111
|
+
return entry ? [entry] : [];
|
|
1112
|
+
});
|
|
1113
|
+
const result = { branch, total: queue.length, saved: [], failed: [] };
|
|
1114
|
+
if (queue.length === 0)
|
|
1115
|
+
return result;
|
|
1116
|
+
clearSaveAllResult();
|
|
1117
|
+
setSaveAllProgress({ done: 0, total: queue.length });
|
|
1118
|
+
for (const [index, entry] of queue.entries()) {
|
|
1119
|
+
if (latestOptionsRef.current.branchName !== branch || !mountedRef.current)
|
|
1120
|
+
return undefined;
|
|
1121
|
+
const outcome = await saveOneOfAll(entry, branch);
|
|
1122
|
+
if (outcome?.saved) {
|
|
1123
|
+
result.saved.push({ contentId: entry.contentId, label: entry.label, path: entry.path });
|
|
1124
|
+
}
|
|
1125
|
+
else if (outcome) {
|
|
1126
|
+
result.failed.push(outcome.failure);
|
|
1127
|
+
}
|
|
1128
|
+
setSaveAllProgress({ done: index + 1, total: queue.length });
|
|
1129
|
+
}
|
|
1130
|
+
// Unmounted: a result would act through this gone instance's callbacks.
|
|
1131
|
+
if (latestOptionsRef.current.branchName !== branch || !mountedRef.current)
|
|
1132
|
+
return undefined;
|
|
1133
|
+
// Discarded, refreshed or saved mid-run, so neither saved nor failed by this run.
|
|
1134
|
+
const { drafts: remaining } = latestRef.current;
|
|
1135
|
+
const settled = settledDuringRunRef.current;
|
|
1136
|
+
result.failed = result.failed.filter((failure) => failure.contentId in remaining && !settled.has(failure.contentId));
|
|
1137
|
+
result.total = result.saved.length + result.failed.length;
|
|
1138
|
+
if (result.saved.length > 0)
|
|
1139
|
+
latestOptionsRef.current.onSaved?.();
|
|
1140
|
+
return result;
|
|
1141
|
+
}
|
|
1142
|
+
finally {
|
|
1143
|
+
saveAllRunningRef.current = false;
|
|
1144
|
+
setSaveAllProgress(null);
|
|
1145
|
+
setSaveInFlight(false);
|
|
469
1146
|
options.setBusy(false);
|
|
470
1147
|
}
|
|
471
1148
|
};
|
|
472
1149
|
const performDiscardDrafts = () => {
|
|
1150
|
+
for (const id of timedOutSavesRef.current)
|
|
1151
|
+
notifications.hide(saveTimedOutToastId(id));
|
|
1152
|
+
timedOutSavesRef.current.clear();
|
|
1153
|
+
for (const id of goneEntriesRef.current)
|
|
1154
|
+
notifications.hide(entryGoneToastId(id));
|
|
1155
|
+
goneEntriesRef.current.clear();
|
|
1156
|
+
clearSaveAllResult();
|
|
1157
|
+
if (saveAllRunningRef.current) {
|
|
1158
|
+
for (const id of Object.keys(latestRef.current.drafts))
|
|
1159
|
+
settledDuringRunRef.current.add(id);
|
|
1160
|
+
}
|
|
1161
|
+
options.onDraftsReset?.();
|
|
473
1162
|
setDrafts({});
|
|
474
1163
|
setErrorState(null);
|
|
475
1164
|
try {
|
|
@@ -491,22 +1180,33 @@ export function useDraftManager(options) {
|
|
|
491
1180
|
// is actually something to lose (modifiedCount > 0, same definition used
|
|
492
1181
|
// everywhere else in this hook). An all-clean discard (e.g. drafts that
|
|
493
1182
|
// exactly mirror loaded values) clears silently.
|
|
494
|
-
const handleDiscardDrafts = () => {
|
|
1183
|
+
const handleDiscardDrafts = (returnFocusTo) => {
|
|
495
1184
|
if (modifiedCount === 0) {
|
|
496
1185
|
performDiscardDrafts();
|
|
497
1186
|
return;
|
|
498
1187
|
}
|
|
499
|
-
|
|
500
|
-
title: 'Discard
|
|
501
|
-
children: `Discard
|
|
502
|
-
labels: { confirm: 'Discard', cancel: 'Cancel' },
|
|
503
|
-
confirmProps: { color: 'red' },
|
|
1188
|
+
openConfirm({
|
|
1189
|
+
title: 'Discard all changes',
|
|
1190
|
+
children: `Discard unsaved changes for ${modifiedCount} ${modifiedCount === 1 ? 'entry' : 'entries'}? They will be lost.`,
|
|
1191
|
+
labels: { confirm: 'Discard all changes', cancel: 'Cancel' },
|
|
1192
|
+
confirmProps: { color: 'red', 'data-testid': 'confirm-discard-all' },
|
|
504
1193
|
onConfirm: performDiscardDrafts,
|
|
1194
|
+
...focusReturnProps(returnFocusTo),
|
|
505
1195
|
});
|
|
506
1196
|
};
|
|
507
|
-
const performDiscardFileDraft = () => {
|
|
508
|
-
|
|
1197
|
+
const performDiscardFileDraft = (announce = false) => {
|
|
1198
|
+
const entry = options.currentEntry;
|
|
1199
|
+
if (!entry || !currentId)
|
|
509
1200
|
return;
|
|
1201
|
+
if (announce) {
|
|
1202
|
+
notifications.show({
|
|
1203
|
+
message: `Unsaved changes to "${entry.label}" discarded`,
|
|
1204
|
+
color: 'green',
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
options.onDraftsReset?.(currentId);
|
|
1208
|
+
hideEntryGone(currentId);
|
|
1209
|
+
settleEntry(currentId);
|
|
510
1210
|
setErrorState(null);
|
|
511
1211
|
setDrafts((prev) => {
|
|
512
1212
|
const next = { ...prev };
|
|
@@ -530,41 +1230,49 @@ export function useDraftManager(options) {
|
|
|
530
1230
|
}
|
|
531
1231
|
}
|
|
532
1232
|
catch (err) {
|
|
533
|
-
console.warn('Failed to clear draft for
|
|
1233
|
+
console.warn('Failed to clear draft for entry', err);
|
|
534
1234
|
}
|
|
535
|
-
notifications.show({
|
|
536
|
-
message: 'Draft cleared for file',
|
|
537
|
-
color: 'blue',
|
|
538
|
-
autoClose: getNotificationDuration(3000),
|
|
539
|
-
withCloseButton: true,
|
|
540
|
-
});
|
|
541
1235
|
};
|
|
542
1236
|
// Only prompt when there is a real draft that actually differs from the
|
|
543
1237
|
// loaded value (`isSelectedDirty()` below uses the exact same deep-equal
|
|
544
1238
|
// comparison as `modifiedCount`). Discarding a draft that's identical to
|
|
545
1239
|
// the loaded value, or discarding when there's no draft at all, has
|
|
546
1240
|
// nothing to lose, so it clears silently.
|
|
547
|
-
const handleDiscardFileDraft = () => {
|
|
1241
|
+
const handleDiscardFileDraft = (returnFocusTo, { announce = false } = {}) => {
|
|
548
1242
|
if (!currentId)
|
|
549
1243
|
return;
|
|
550
1244
|
if (!isSelectedDirty()) {
|
|
551
|
-
performDiscardFileDraft();
|
|
1245
|
+
performDiscardFileDraft(announce);
|
|
552
1246
|
return;
|
|
553
1247
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1248
|
+
const label = options.currentEntry?.label;
|
|
1249
|
+
openConfirm({
|
|
1250
|
+
title: label ? `Discard changes to "${label}"?` : 'Discard changes?',
|
|
1251
|
+
children: 'Your unsaved changes to this entry will be lost.',
|
|
1252
|
+
labels: { confirm: 'Discard changes', cancel: 'Keep editing' },
|
|
1253
|
+
confirmProps: { color: 'red', 'data-testid': 'confirm-discard-entry' },
|
|
1254
|
+
onConfirm: () => performDiscardFileDraft(announce),
|
|
1255
|
+
...focusReturnProps(returnFocusTo),
|
|
560
1256
|
});
|
|
561
1257
|
};
|
|
562
1258
|
const performReload = async () => {
|
|
563
1259
|
if (!options.currentEntry || !currentId)
|
|
564
1260
|
return;
|
|
1261
|
+
const branch = options.branchName;
|
|
565
1262
|
options.setBusy(true);
|
|
566
1263
|
try {
|
|
567
1264
|
const loaded = await options.loadEntry(options.currentEntry);
|
|
1265
|
+
// Content ids repeat across branches; the next branch's state is not this read's.
|
|
1266
|
+
if (latestOptionsRef.current.branchName !== branch)
|
|
1267
|
+
return;
|
|
1268
|
+
options.onDraftsReset?.(currentId);
|
|
1269
|
+
delete reloadRequiredRef.current[currentId];
|
|
1270
|
+
notifications.hide(saveMayHaveLandedToastId(currentId));
|
|
1271
|
+
if (timedOutSavesRef.current.delete(currentId)) {
|
|
1272
|
+
notifications.hide(saveTimedOutToastId(currentId));
|
|
1273
|
+
}
|
|
1274
|
+
hideEntryGone(currentId);
|
|
1275
|
+
settleEntry(currentId);
|
|
568
1276
|
setLoadedValues((prev) => ({ ...prev, [currentId]: loaded }));
|
|
569
1277
|
// Drop the draft rather than seeding it with `loaded`. `effectiveValue`
|
|
570
1278
|
// falls back to `loadedValues`, so the rendered value is identical --
|
|
@@ -579,16 +1287,28 @@ export function useDraftManager(options) {
|
|
|
579
1287
|
return next;
|
|
580
1288
|
});
|
|
581
1289
|
notifications.show({
|
|
582
|
-
message: '
|
|
1290
|
+
message: 'Refreshed from server',
|
|
583
1291
|
color: 'blue',
|
|
584
1292
|
autoClose: getNotificationDuration(3000),
|
|
585
1293
|
withCloseButton: true,
|
|
586
1294
|
});
|
|
587
1295
|
}
|
|
588
1296
|
catch (err) {
|
|
1297
|
+
if (latestOptionsRef.current.branchName !== branch)
|
|
1298
|
+
return;
|
|
1299
|
+
if (err instanceof EntrySchemaUnavailableError) {
|
|
1300
|
+
notifications.show({
|
|
1301
|
+
title: 'Not available yet',
|
|
1302
|
+
message: err.message,
|
|
1303
|
+
color: 'yellow',
|
|
1304
|
+
autoClose: getNotificationDuration(8000),
|
|
1305
|
+
withCloseButton: true,
|
|
1306
|
+
});
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
589
1309
|
console.error(err);
|
|
590
1310
|
notifications.show({
|
|
591
|
-
message: '
|
|
1311
|
+
message: "Couldn't refresh from the server. Your changes are still here; try again.",
|
|
592
1312
|
color: 'red',
|
|
593
1313
|
autoClose: getNotificationDuration(6000),
|
|
594
1314
|
withCloseButton: true,
|
|
@@ -604,21 +1324,22 @@ export function useDraftManager(options) {
|
|
|
604
1324
|
// moment their unsaved work matters most. Guarded exactly like
|
|
605
1325
|
// handleDiscardFileDraft: the same `isSelectedDirty()` test (nothing to lose
|
|
606
1326
|
// -> no prompt) and the same confirm-modal shape.
|
|
607
|
-
const handleReload = async () => {
|
|
1327
|
+
const handleReload = async (returnFocusTo) => {
|
|
608
1328
|
if (!options.currentEntry || !currentId)
|
|
609
1329
|
return;
|
|
610
1330
|
if (!isSelectedDirty()) {
|
|
611
1331
|
await performReload();
|
|
612
1332
|
return;
|
|
613
1333
|
}
|
|
614
|
-
|
|
615
|
-
title: '
|
|
616
|
-
children:
|
|
617
|
-
labels: { confirm: '
|
|
1334
|
+
openConfirm({
|
|
1335
|
+
title: 'Refresh from server',
|
|
1336
|
+
children: "Replace this entry with the server's copy? Unsaved changes to this entry will be lost.",
|
|
1337
|
+
labels: { confirm: 'Refresh', cancel: 'Cancel' },
|
|
618
1338
|
confirmProps: { color: 'red' },
|
|
619
1339
|
onConfirm: () => {
|
|
620
1340
|
void performReload();
|
|
621
1341
|
},
|
|
1342
|
+
...focusReturnProps(returnFocusTo),
|
|
622
1343
|
});
|
|
623
1344
|
};
|
|
624
1345
|
const isDirtyForEntry = (entryPath) => {
|
|
@@ -628,7 +1349,8 @@ export function useDraftManager(options) {
|
|
|
628
1349
|
const id = entry.contentId;
|
|
629
1350
|
if (!drafts[id])
|
|
630
1351
|
return false;
|
|
631
|
-
|
|
1352
|
+
const baseline = loadedValues[id] ?? verifiedBaselines[id];
|
|
1353
|
+
return baseline === undefined || !equal(drafts[id], baseline);
|
|
632
1354
|
};
|
|
633
1355
|
const isSelectedDirty = () => {
|
|
634
1356
|
if (!currentId)
|
|
@@ -637,14 +1359,77 @@ export function useDraftManager(options) {
|
|
|
637
1359
|
return false;
|
|
638
1360
|
return !loadedValues[currentId] || !equal(drafts[currentId], loadedValues[currentId]);
|
|
639
1361
|
};
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
// Used for branch-switch guards so unsaved work in non-selected entries is not
|
|
643
|
-
// silently discarded. Derived from `modifiedCount`, so its semantics note
|
|
644
|
-
// above also applies: localStorage-restored drafts without a loaded value
|
|
645
|
-
// count as dirty.
|
|
1362
|
+
// True if ANY counted draft is dirty (see `dirtyIds`). Used for branch-level
|
|
1363
|
+
// guards so unsaved work in non-selected entries is not silently discarded.
|
|
646
1364
|
const isAnyDirty = () => modifiedCount > 0;
|
|
1365
|
+
const whenDraftsVerified = () => {
|
|
1366
|
+
if (latestRef.current.unresolvedCount === 0)
|
|
1367
|
+
return Promise.resolve();
|
|
1368
|
+
return new Promise((resolve) => {
|
|
1369
|
+
const wake = () => {
|
|
1370
|
+
clearTimeout(timer);
|
|
1371
|
+
resolve();
|
|
1372
|
+
};
|
|
1373
|
+
const timer = setTimeout(() => {
|
|
1374
|
+
verifyWaitersRef.current = verifyWaitersRef.current.filter((w) => w !== wake);
|
|
1375
|
+
resolve();
|
|
1376
|
+
}, VERIFY_WAIT_CAP_MS);
|
|
1377
|
+
verifyWaitersRef.current.push(wake);
|
|
1378
|
+
});
|
|
1379
|
+
};
|
|
1380
|
+
const resolveUnsaved = async () => {
|
|
1381
|
+
await whenDraftsVerified();
|
|
1382
|
+
const { modifiedCount: count, editedFiles: files } = latestRef.current;
|
|
1383
|
+
return { count, labels: files.map((f) => f.label) };
|
|
1384
|
+
};
|
|
1385
|
+
// An entry's token and draft base move to the stamped version only from the version the stamp
|
|
1386
|
+
// replaced: one already behind it stays behind, so the usual conflict check still catches it.
|
|
1387
|
+
const applySubmitStamps = (stamped) => {
|
|
1388
|
+
const { getEntryVersion, adoptEntryVersion } = latestOptionsRef.current;
|
|
1389
|
+
const loadedMoved = [];
|
|
1390
|
+
const draftsMoved = [];
|
|
1391
|
+
for (const entry of stamped) {
|
|
1392
|
+
if (getEntryVersion?.(entry.contentId) === entry.previousVersion) {
|
|
1393
|
+
adoptEntryVersion?.(entry.contentId, entry.version);
|
|
1394
|
+
loadedMoved.push(entry);
|
|
1395
|
+
}
|
|
1396
|
+
if (draftBaseVersionsRef.current[entry.contentId] === entry.previousVersion) {
|
|
1397
|
+
draftBaseVersionsRef.current[entry.contentId] = entry.version;
|
|
1398
|
+
draftsMoved.push(entry);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (loadedMoved.length > 0) {
|
|
1402
|
+
setLoadedValues((prev) => {
|
|
1403
|
+
const next = { ...prev };
|
|
1404
|
+
for (const { contentId, fields } of loadedMoved) {
|
|
1405
|
+
if (prev[contentId])
|
|
1406
|
+
next[contentId] = { ...prev[contentId], ...fields };
|
|
1407
|
+
}
|
|
1408
|
+
return next;
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
// A new drafts object even when no value changes, so the moved base versions are persisted.
|
|
1412
|
+
if (draftsMoved.length > 0) {
|
|
1413
|
+
setDrafts((prev) => {
|
|
1414
|
+
const next = { ...prev };
|
|
1415
|
+
for (const { contentId, fields } of draftsMoved) {
|
|
1416
|
+
const draft = prev[contentId];
|
|
1417
|
+
if (!draft)
|
|
1418
|
+
continue;
|
|
1419
|
+
const filled = { ...draft };
|
|
1420
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
1421
|
+
if (draft[name] === undefined || draft[name] === null || draft[name] === '') {
|
|
1422
|
+
filled[name] = value;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
next[contentId] = filled;
|
|
1426
|
+
}
|
|
1427
|
+
return next;
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
647
1431
|
return {
|
|
1432
|
+
applySubmitStamps,
|
|
648
1433
|
drafts,
|
|
649
1434
|
setDrafts,
|
|
650
1435
|
loadedValues,
|
|
@@ -655,12 +1440,20 @@ export function useDraftManager(options) {
|
|
|
655
1440
|
modifiedCount,
|
|
656
1441
|
editedFiles,
|
|
657
1442
|
handleSave,
|
|
1443
|
+
singleSaving,
|
|
1444
|
+
lastSaved,
|
|
1445
|
+
handleSaveAll,
|
|
1446
|
+
saveAllProgress,
|
|
1447
|
+
isSaveAllRunning: () => saveAllRunningRef.current,
|
|
658
1448
|
handleDiscardDrafts,
|
|
659
1449
|
handleDiscardFileDraft,
|
|
660
1450
|
handleReload,
|
|
661
1451
|
isDirtyForEntry,
|
|
662
1452
|
isSelectedDirty,
|
|
663
1453
|
isAnyDirty,
|
|
1454
|
+
whenDraftsVerified,
|
|
1455
|
+
resolveUnsaved,
|
|
664
1456
|
fieldErrors,
|
|
1457
|
+
draftStorageFailed,
|
|
665
1458
|
};
|
|
666
1459
|
}
|