@elytracms/studio 0.0.43 → 0.0.45
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/convex/cms.ts +59 -21
- package/convex/content.ts +6 -3
- package/convex/delivery.ts +8 -5
- package/convex/triggers.ts +8 -1
- package/dist/components/app-views/studio-config.d.ts +12 -0
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +13 -1
- package/dist/components/assets/assets-collection-view-source.js +42 -51
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/cms/cms-store.js +9 -11
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/detail-editor.js +110 -338
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-staging.d.ts +8 -3
- package/dist/components/cms/document-staging.js +81 -21
- package/dist/components/cms/document-staging.js.map +1 -1
- package/dist/components/cms/use-compare-selection.d.ts +27 -0
- package/dist/components/cms/use-compare-selection.js +29 -0
- package/dist/components/cms/use-compare-selection.js.map +1 -0
- package/dist/components/cms/use-discard-controller.d.ts +44 -0
- package/dist/components/cms/use-discard-controller.js +51 -0
- package/dist/components/cms/use-discard-controller.js.map +1 -0
- package/dist/components/cms/use-embed-inspector-host.d.ts +12 -0
- package/dist/components/cms/use-embed-inspector-host.js +17 -0
- package/dist/components/cms/use-embed-inspector-host.js.map +1 -0
- package/dist/components/cms/use-field-focus.d.ts +38 -0
- package/dist/components/cms/use-field-focus.js +70 -0
- package/dist/components/cms/use-field-focus.js.map +1 -0
- package/dist/components/cms/use-live-url.d.ts +41 -0
- package/dist/components/cms/use-live-url.js +55 -0
- package/dist/components/cms/use-live-url.js.map +1 -0
- package/dist/components/cms/use-publish-controller.d.ts +49 -0
- package/dist/components/cms/use-publish-controller.js +102 -0
- package/dist/components/cms/use-publish-controller.js.map +1 -0
- package/dist/components/cms/use-published-perspective.d.ts +67 -0
- package/dist/components/cms/use-published-perspective.js +67 -0
- package/dist/components/cms/use-published-perspective.js.map +1 -0
- package/dist/components/cms/use-restore-version.d.ts +57 -0
- package/dist/components/cms/use-restore-version.js +66 -0
- package/dist/components/cms/use-restore-version.js.map +1 -0
- package/dist/components/cms/use-save-mode.d.ts +35 -0
- package/dist/components/cms/use-save-mode.js +54 -0
- package/dist/components/cms/use-save-mode.js.map +1 -0
- package/dist/components/cms/version-compare.js +13 -16
- package/dist/components/cms/version-compare.js.map +1 -1
- package/dist/components/cms/versions-pane.js +21 -19
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/components/transfer-dialog.d.ts +11 -6
- package/dist/components/components/transfer-dialog.js +98 -25
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/components/transfer.d.ts +74 -8
- package/dist/components/components/transfer.js +87 -2
- package/dist/components/components/transfer.js.map +1 -1
- package/dist/components/rail-store-status.d.ts +7 -5
- package/dist/components/rail-store-status.js +12 -9
- package/dist/components/rail-store-status.js.map +1 -1
- package/dist/components/rail-view-source.d.ts +111 -0
- package/dist/components/rail-view-source.js +139 -0
- package/dist/components/rail-view-source.js.map +1 -0
- package/dist/components/redirects/redirects-collection-view-source.d.ts +13 -1
- package/dist/components/redirects/redirects-collection-view-source.js +42 -70
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
- package/dist/components/section-state.d.ts +12 -3
- package/dist/components/section-state.js +8 -4
- package/dist/components/section-state.js.map +1 -1
- package/dist/components/users/users-collection-view-source.d.ts +13 -1
- package/dist/components/users/users-collection-view-source.js +60 -79
- package/dist/components/users/users-collection-view-source.js.map +1 -1
- package/dist/lib/ai/anthropic-provider.d.ts +29 -0
- package/dist/lib/ai/anthropic-provider.js +59 -0
- package/dist/lib/ai/anthropic-provider.js.map +1 -0
- package/dist/lib/ai/apply-content.d.ts +9 -0
- package/dist/lib/ai/apply-content.js +170 -0
- package/dist/lib/ai/apply-content.js.map +1 -0
- package/dist/lib/ai/config.d.ts +58 -0
- package/dist/lib/ai/config.js +134 -0
- package/dist/lib/ai/config.js.map +1 -0
- package/dist/lib/ai/fake-provider.d.ts +34 -0
- package/dist/lib/ai/fake-provider.js +31 -0
- package/dist/lib/ai/fake-provider.js.map +1 -0
- package/dist/lib/ai/index.d.ts +22 -0
- package/dist/lib/ai/index.js +23 -0
- package/dist/lib/ai/index.js.map +1 -0
- package/dist/lib/ai/knowledge.d.ts +39 -0
- package/dist/lib/ai/knowledge.js +60 -0
- package/dist/lib/ai/knowledge.js.map +1 -0
- package/dist/lib/ai/provenance.d.ts +20 -0
- package/dist/lib/ai/provenance.js +15 -0
- package/dist/lib/ai/provenance.js.map +1 -0
- package/dist/lib/ai/route.d.ts +24 -0
- package/dist/lib/ai/route.js +90 -0
- package/dist/lib/ai/route.js.map +1 -0
- package/dist/lib/ai/shape.d.ts +35 -0
- package/dist/lib/ai/shape.js +113 -0
- package/dist/lib/ai/shape.js.map +1 -0
- package/dist/lib/ai/types.d.ts +227 -0
- package/dist/lib/ai/types.js +2 -0
- package/dist/lib/ai/types.js.map +1 -0
- package/dist/lib/i18n/messages.en.js +4 -2
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +9 -0
- package/dist/lib/workspace/index.js +11 -0
- package/dist/lib/workspace/index.js.map +1 -1
- package/package.json +8 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CmsDocument, CollectionDef, DocumentRef } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source';
|
|
3
|
+
export type PublishState = 'idle' | 'pending' | 'success';
|
|
4
|
+
/**
|
|
5
|
+
* EC-268 / 0.0.39 — the split Publish button's shared gate, computed once so the
|
|
6
|
+
* primary "Publish" and the "Publish & close" item can never diverge. A pure
|
|
7
|
+
* decision (directly testable): the Published perspective is read-only (switch to
|
|
8
|
+
* Draft to publish), schema errors block (with a count-aware hint), and an
|
|
9
|
+
* in-flight publish disables re-entry.
|
|
10
|
+
*/
|
|
11
|
+
export declare function publishGate(input: {
|
|
12
|
+
hasErrors: boolean;
|
|
13
|
+
viewReadOnly: boolean;
|
|
14
|
+
publishState: PublishState;
|
|
15
|
+
errorCount: number;
|
|
16
|
+
}): {
|
|
17
|
+
disabled: boolean;
|
|
18
|
+
title: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
export interface PublishController {
|
|
21
|
+
publishState: PublishState;
|
|
22
|
+
/** Is the confirm-through dialog (unpublished hard refs) open? */
|
|
23
|
+
publishConfirm: boolean;
|
|
24
|
+
/** Publish (opening the confirm-through dialog first if there are unpublished refs). */
|
|
25
|
+
handlePublish: () => void;
|
|
26
|
+
/** Publish and, on success, return to the rail's list (0.0.39 "Publish & close"). */
|
|
27
|
+
handlePublishAndClose: () => void;
|
|
28
|
+
/** Confirm-through: publish despite the unpublished hard references. */
|
|
29
|
+
confirmPublish: () => void;
|
|
30
|
+
/** Dismiss the confirm-through dialog (also clears a pending "& close" intent). */
|
|
31
|
+
cancelPublishConfirm: () => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* EC-268 / EC-300 / 0.0.39 — the publish controller. Owns the publish indicator
|
|
35
|
+
* state, the EC-300 confirm-through guard (unpublished hard references would render
|
|
36
|
+
* empty on the live site, so publishing confirms rather than blocks), and the
|
|
37
|
+
* 0.0.39 "Publish & close" navigation (remembered across the confirm dialog via a
|
|
38
|
+
* ref, so a successful publish returns to the rail-owned list).
|
|
39
|
+
*/
|
|
40
|
+
export declare function usePublishController(input: {
|
|
41
|
+
source: CollectionViewSource;
|
|
42
|
+
docRef: DocumentRef;
|
|
43
|
+
collection: CollectionDef;
|
|
44
|
+
projectId: string;
|
|
45
|
+
draft: CmsDocument;
|
|
46
|
+
setDraft: (doc: CmsDocument) => void;
|
|
47
|
+
dirty: boolean;
|
|
48
|
+
hasUnpublishedRefs: boolean;
|
|
49
|
+
}): PublishController;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
|
|
3
|
+
import { clearHiddenValues } from '@elytracms/core/cms-core';
|
|
4
|
+
import { toast } from '@elytracms/studio/ui';
|
|
5
|
+
import { translate } from '@elytracms/studio/lib/i18n';
|
|
6
|
+
/**
|
|
7
|
+
* EC-268 / 0.0.39 — the split Publish button's shared gate, computed once so the
|
|
8
|
+
* primary "Publish" and the "Publish & close" item can never diverge. A pure
|
|
9
|
+
* decision (directly testable): the Published perspective is read-only (switch to
|
|
10
|
+
* Draft to publish), schema errors block (with a count-aware hint), and an
|
|
11
|
+
* in-flight publish disables re-entry.
|
|
12
|
+
*/
|
|
13
|
+
export function publishGate(input) {
|
|
14
|
+
const disabled = input.hasErrors || input.viewReadOnly || input.publishState === 'pending';
|
|
15
|
+
const title = input.viewReadOnly
|
|
16
|
+
? translate('cms.editor.switchToDraftToPublish')
|
|
17
|
+
: input.hasErrors
|
|
18
|
+
? translate(input.errorCount === 1
|
|
19
|
+
? 'cms.editor.fixIssuesToPublish.one'
|
|
20
|
+
: 'cms.editor.fixIssuesToPublish.other', { count: input.errorCount })
|
|
21
|
+
: undefined;
|
|
22
|
+
return { disabled, title };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* EC-268 / EC-300 / 0.0.39 — the publish controller. Owns the publish indicator
|
|
26
|
+
* state, the EC-300 confirm-through guard (unpublished hard references would render
|
|
27
|
+
* empty on the live site, so publishing confirms rather than blocks), and the
|
|
28
|
+
* 0.0.39 "Publish & close" navigation (remembered across the confirm dialog via a
|
|
29
|
+
* ref, so a successful publish returns to the rail-owned list).
|
|
30
|
+
*/
|
|
31
|
+
export function usePublishController(input) {
|
|
32
|
+
const { source, docRef, collection, projectId, draft, setDraft, dirty, hasUnpublishedRefs } = input;
|
|
33
|
+
const navigate = useNavigate();
|
|
34
|
+
const [publishState, setPublishState] = useState('idle');
|
|
35
|
+
const [publishConfirm, setPublishConfirm] = useState(false);
|
|
36
|
+
// The split-button "Publish & close" intent — remembered across the confirm-through
|
|
37
|
+
// dialog so a successful publish returns to the list. Cleared on cancel or failure.
|
|
38
|
+
const closeAfterPublishRef = useRef(false);
|
|
39
|
+
const doPublish = useCallback(async () => {
|
|
40
|
+
// EC-273: clean conditionally-hidden values from the draft AT PUBLISH TIME —
|
|
41
|
+
// into the draft AND the snapshot, so both go clean together. Also captures the
|
|
42
|
+
// latest edit if Publish fires inside the autosave debounce window; the serial
|
|
43
|
+
// persister runs the upsert before the publish so the snapshot is this draft.
|
|
44
|
+
const cleaned = clearHiddenValues(collection, draft);
|
|
45
|
+
if (cleaned !== draft)
|
|
46
|
+
setDraft(cleaned);
|
|
47
|
+
if (cleaned !== draft || dirty)
|
|
48
|
+
source.save(cleaned);
|
|
49
|
+
setPublishState('pending');
|
|
50
|
+
try {
|
|
51
|
+
await source.publishing?.publish(docRef);
|
|
52
|
+
setPublishState('success');
|
|
53
|
+
setTimeout(() => setPublishState('idle'), 1800);
|
|
54
|
+
// "Publish & close": return to the list once the publish landed (never on a
|
|
55
|
+
// failure). Rail-owned `listPath`, falling back to the project index (AD-19).
|
|
56
|
+
if (closeAfterPublishRef.current) {
|
|
57
|
+
closeAfterPublishRef.current = false;
|
|
58
|
+
void navigate({
|
|
59
|
+
to: STUDIO_PROJECT_ROUTE,
|
|
60
|
+
params: { projectId, _splat: source.listPath ?? studioPath.index() },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
closeAfterPublishRef.current = false;
|
|
66
|
+
setPublishState('idle');
|
|
67
|
+
toast.error(error instanceof Error
|
|
68
|
+
? translate('cms.editor.publishFailedReason', { error: error.message })
|
|
69
|
+
: translate('cms.editor.publishFailed'));
|
|
70
|
+
}
|
|
71
|
+
}, [collection, draft, setDraft, dirty, source, docRef, navigate, projectId]);
|
|
72
|
+
// Gate publish on the confirm-through dialog when there are unpublished hard
|
|
73
|
+
// references; schema errors already disable the button, so they never reach here.
|
|
74
|
+
const handlePublish = useCallback(() => {
|
|
75
|
+
if (hasUnpublishedRefs) {
|
|
76
|
+
setPublishConfirm(true);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
void doPublish();
|
|
80
|
+
}, [hasUnpublishedRefs, doPublish]);
|
|
81
|
+
const handlePublishAndClose = useCallback(() => {
|
|
82
|
+
closeAfterPublishRef.current = true;
|
|
83
|
+
handlePublish();
|
|
84
|
+
}, [handlePublish]);
|
|
85
|
+
const confirmPublish = useCallback(() => {
|
|
86
|
+
setPublishConfirm(false);
|
|
87
|
+
void doPublish();
|
|
88
|
+
}, [doPublish]);
|
|
89
|
+
const cancelPublishConfirm = useCallback(() => {
|
|
90
|
+
setPublishConfirm(false);
|
|
91
|
+
closeAfterPublishRef.current = false;
|
|
92
|
+
}, []);
|
|
93
|
+
return {
|
|
94
|
+
publishState,
|
|
95
|
+
publishConfirm,
|
|
96
|
+
handlePublish,
|
|
97
|
+
handlePublishAndClose,
|
|
98
|
+
confirmPublish,
|
|
99
|
+
cancelPublishConfirm,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=use-publish-controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-publish-controller.js","sourceRoot":"","sources":["../../../src/components/cms/use-publish-controller.ts"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useRef, useState } from 'react'\nimport { STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router'\nimport { clearHiddenValues } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, DocumentRef } from '@elytracms/core/cms-core'\nimport type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source'\nimport { toast } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\nexport type PublishState = 'idle' | 'pending' | 'success'\n\n/**\n * EC-268 / 0.0.39 — the split Publish button's shared gate, computed once so the\n * primary \"Publish\" and the \"Publish & close\" item can never diverge. A pure\n * decision (directly testable): the Published perspective is read-only (switch to\n * Draft to publish), schema errors block (with a count-aware hint), and an\n * in-flight publish disables re-entry.\n */\nexport function publishGate(input: {\n hasErrors: boolean\n viewReadOnly: boolean\n publishState: PublishState\n errorCount: number\n}): { disabled: boolean; title: string | undefined } {\n const disabled = input.hasErrors || input.viewReadOnly || input.publishState === 'pending'\n const title = input.viewReadOnly\n ? translate('cms.editor.switchToDraftToPublish')\n : input.hasErrors\n ? translate(\n input.errorCount === 1\n ? 'cms.editor.fixIssuesToPublish.one'\n : 'cms.editor.fixIssuesToPublish.other',\n { count: input.errorCount },\n )\n : undefined\n return { disabled, title }\n}\n\nexport interface PublishController {\n publishState: PublishState\n /** Is the confirm-through dialog (unpublished hard refs) open? */\n publishConfirm: boolean\n /** Publish (opening the confirm-through dialog first if there are unpublished refs). */\n handlePublish: () => void\n /** Publish and, on success, return to the rail's list (0.0.39 \"Publish & close\"). */\n handlePublishAndClose: () => void\n /** Confirm-through: publish despite the unpublished hard references. */\n confirmPublish: () => void\n /** Dismiss the confirm-through dialog (also clears a pending \"& close\" intent). */\n cancelPublishConfirm: () => void\n}\n\n/**\n * EC-268 / EC-300 / 0.0.39 — the publish controller. Owns the publish indicator\n * state, the EC-300 confirm-through guard (unpublished hard references would render\n * empty on the live site, so publishing confirms rather than blocks), and the\n * 0.0.39 \"Publish & close\" navigation (remembered across the confirm dialog via a\n * ref, so a successful publish returns to the rail-owned list).\n */\nexport function usePublishController(input: {\n source: CollectionViewSource\n docRef: DocumentRef\n collection: CollectionDef\n projectId: string\n draft: CmsDocument\n setDraft: (doc: CmsDocument) => void\n dirty: boolean\n hasUnpublishedRefs: boolean\n}): PublishController {\n const { source, docRef, collection, projectId, draft, setDraft, dirty, hasUnpublishedRefs } = input\n const navigate = useNavigate()\n const [publishState, setPublishState] = useState<PublishState>('idle')\n const [publishConfirm, setPublishConfirm] = useState(false)\n // The split-button \"Publish & close\" intent — remembered across the confirm-through\n // dialog so a successful publish returns to the list. Cleared on cancel or failure.\n const closeAfterPublishRef = useRef(false)\n\n const doPublish = useCallback(async () => {\n // EC-273: clean conditionally-hidden values from the draft AT PUBLISH TIME —\n // into the draft AND the snapshot, so both go clean together. Also captures the\n // latest edit if Publish fires inside the autosave debounce window; the serial\n // persister runs the upsert before the publish so the snapshot is this draft.\n const cleaned = clearHiddenValues(collection, draft)\n if (cleaned !== draft) setDraft(cleaned)\n if (cleaned !== draft || dirty) source.save(cleaned)\n setPublishState('pending')\n try {\n await source.publishing?.publish(docRef)\n setPublishState('success')\n setTimeout(() => setPublishState('idle'), 1800)\n // \"Publish & close\": return to the list once the publish landed (never on a\n // failure). Rail-owned `listPath`, falling back to the project index (AD-19).\n if (closeAfterPublishRef.current) {\n closeAfterPublishRef.current = false\n void navigate({\n to: STUDIO_PROJECT_ROUTE,\n params: { projectId, _splat: source.listPath ?? studioPath.index() },\n })\n }\n } catch (error) {\n closeAfterPublishRef.current = false\n setPublishState('idle')\n toast.error(\n error instanceof Error\n ? translate('cms.editor.publishFailedReason', { error: error.message })\n : translate('cms.editor.publishFailed'),\n )\n }\n }, [collection, draft, setDraft, dirty, source, docRef, navigate, projectId])\n\n // Gate publish on the confirm-through dialog when there are unpublished hard\n // references; schema errors already disable the button, so they never reach here.\n const handlePublish = useCallback(() => {\n if (hasUnpublishedRefs) {\n setPublishConfirm(true)\n return\n }\n void doPublish()\n }, [hasUnpublishedRefs, doPublish])\n\n const handlePublishAndClose = useCallback(() => {\n closeAfterPublishRef.current = true\n handlePublish()\n }, [handlePublish])\n\n const confirmPublish = useCallback(() => {\n setPublishConfirm(false)\n void doPublish()\n }, [doPublish])\n\n const cancelPublishConfirm = useCallback(() => {\n setPublishConfirm(false)\n closeAfterPublishRef.current = false\n }, [])\n\n return {\n publishState,\n publishConfirm,\n handlePublish,\n handlePublishAndClose,\n confirmPublish,\n cancelPublishConfirm,\n }\n}\n"]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { QueryKey } from '@tanstack/react-query';
|
|
2
|
+
import type { CmsDocument, DocumentRef } from '@elytracms/core/cms-core';
|
|
3
|
+
import type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source';
|
|
4
|
+
import type { DocumentVersionsView } from './cms-store';
|
|
5
|
+
import type { PublishedSnapshotState } from './versions-model';
|
|
6
|
+
export type Perspective = 'draft' | 'published';
|
|
7
|
+
/**
|
|
8
|
+
* EC-226 — which document the fieldset renders, given the active perspective and
|
|
9
|
+
* the pinned published snapshot's load state. A pure decision so "never present
|
|
10
|
+
* the draft AS published" is single-sourced and directly testable:
|
|
11
|
+
*
|
|
12
|
+
* - Draft perspective → the live draft, editable.
|
|
13
|
+
* - Published perspective, snapshot READY → the pinned snapshot, read-only.
|
|
14
|
+
* - Published perspective, snapshot NOT ready → still the draft as the fallback
|
|
15
|
+
* render target, but flagged `publishedPending` so the shell shows a placeholder
|
|
16
|
+
* instead (the draft must never be shown labelled as the published content).
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveViewDoc(input: {
|
|
19
|
+
perspective: Perspective;
|
|
20
|
+
publishedSnapshot: PublishedSnapshotState;
|
|
21
|
+
draft: CmsDocument;
|
|
22
|
+
}): {
|
|
23
|
+
viewReadOnly: boolean;
|
|
24
|
+
viewDoc: CmsDocument;
|
|
25
|
+
publishedPending: boolean;
|
|
26
|
+
};
|
|
27
|
+
export interface PublishedPerspective {
|
|
28
|
+
perspective: Perspective;
|
|
29
|
+
setPerspective: (perspective: Perspective) => void;
|
|
30
|
+
/** Is the document published (a version pointer is set)? */
|
|
31
|
+
published: boolean;
|
|
32
|
+
/** A publishing AND versioned rail — gates the version-history query + Discard. */
|
|
33
|
+
canDiscard: boolean;
|
|
34
|
+
/** The recorded version history (null while loading / disabled). */
|
|
35
|
+
versionsView: DocumentVersionsView | null;
|
|
36
|
+
/** The shared version-history cache key ({@link documentVersionsKey}). */
|
|
37
|
+
versionKey: QueryKey;
|
|
38
|
+
/** The pinned published version number, or undefined while loading / none pinned. */
|
|
39
|
+
publishedVersionNumber: number | undefined;
|
|
40
|
+
/** The pinned published version's snapshot, or undefined while loading / none. */
|
|
41
|
+
publishedSnapshotDoc: CmsDocument | undefined;
|
|
42
|
+
/** The loaded state of the pinned published snapshot. */
|
|
43
|
+
publishedSnapshot: PublishedSnapshotState;
|
|
44
|
+
/** Is the editor viewing the read-only Published snapshot? */
|
|
45
|
+
viewReadOnly: boolean;
|
|
46
|
+
/** The document the fieldset renders (draft or the pinned snapshot). */
|
|
47
|
+
viewDoc: CmsDocument;
|
|
48
|
+
/** Is the Published snapshot still loading (show a placeholder, not the draft)? */
|
|
49
|
+
publishedPending: boolean;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* EC-226 / EC-316 / EC-325 — the Draft/Published VIEW perspective plus the pinned
|
|
53
|
+
* published snapshot, both derived from the ONE shared version-history query
|
|
54
|
+
* (AD-17: the single operations-backed cache, `use-document-versions.ts` — no raw
|
|
55
|
+
* `source.versioning.load` call). The Discard-draft gating and the Published
|
|
56
|
+
* perspective read exactly the same `versions`/`publishedVersion` shape, so a
|
|
57
|
+
* second bespoke fetch on perspective switch would just re-read this. Gated the
|
|
58
|
+
* same way Discard always was — a publishing + versioned rail AND a published
|
|
59
|
+
* document — which is also exactly when the Published perspective is reachable.
|
|
60
|
+
*/
|
|
61
|
+
export declare function usePublishedPerspective(input: {
|
|
62
|
+
ref: DocumentRef;
|
|
63
|
+
source: CollectionViewSource;
|
|
64
|
+
canPublish: boolean;
|
|
65
|
+
canVersions: boolean;
|
|
66
|
+
draft: CmsDocument;
|
|
67
|
+
}): PublishedPerspective;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useDocumentVersionsQuery } from './use-document-versions';
|
|
3
|
+
import { derivePublishedSnapshot, publishedVersionSnapshot } from './versions-model';
|
|
4
|
+
/**
|
|
5
|
+
* EC-226 — which document the fieldset renders, given the active perspective and
|
|
6
|
+
* the pinned published snapshot's load state. A pure decision so "never present
|
|
7
|
+
* the draft AS published" is single-sourced and directly testable:
|
|
8
|
+
*
|
|
9
|
+
* - Draft perspective → the live draft, editable.
|
|
10
|
+
* - Published perspective, snapshot READY → the pinned snapshot, read-only.
|
|
11
|
+
* - Published perspective, snapshot NOT ready → still the draft as the fallback
|
|
12
|
+
* render target, but flagged `publishedPending` so the shell shows a placeholder
|
|
13
|
+
* instead (the draft must never be shown labelled as the published content).
|
|
14
|
+
*/
|
|
15
|
+
export function resolveViewDoc(input) {
|
|
16
|
+
const viewReadOnly = input.perspective === 'published';
|
|
17
|
+
const ready = input.publishedSnapshot.status === 'ready';
|
|
18
|
+
return {
|
|
19
|
+
viewReadOnly,
|
|
20
|
+
viewDoc: viewReadOnly && input.publishedSnapshot.status === 'ready' ? input.publishedSnapshot.doc : input.draft,
|
|
21
|
+
publishedPending: viewReadOnly && !ready,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* EC-226 / EC-316 / EC-325 — the Draft/Published VIEW perspective plus the pinned
|
|
26
|
+
* published snapshot, both derived from the ONE shared version-history query
|
|
27
|
+
* (AD-17: the single operations-backed cache, `use-document-versions.ts` — no raw
|
|
28
|
+
* `source.versioning.load` call). The Discard-draft gating and the Published
|
|
29
|
+
* perspective read exactly the same `versions`/`publishedVersion` shape, so a
|
|
30
|
+
* second bespoke fetch on perspective switch would just re-read this. Gated the
|
|
31
|
+
* same way Discard always was — a publishing + versioned rail AND a published
|
|
32
|
+
* document — which is also exactly when the Published perspective is reachable.
|
|
33
|
+
*/
|
|
34
|
+
export function usePublishedPerspective(input) {
|
|
35
|
+
const { ref, source, canPublish, canVersions, draft } = input;
|
|
36
|
+
const published = source.publishing?.isPublished(ref) ?? false;
|
|
37
|
+
const [perspective, setPerspective] = useState('draft');
|
|
38
|
+
// Snap back to Draft if the document is unpublished while viewing Published.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!published)
|
|
41
|
+
setPerspective('draft');
|
|
42
|
+
}, [published]);
|
|
43
|
+
const canDiscard = canPublish && canVersions;
|
|
44
|
+
const { versionsQuery, versionKey } = useDocumentVersionsQuery(ref, {
|
|
45
|
+
enabled: canDiscard && published,
|
|
46
|
+
});
|
|
47
|
+
const versionsView = versionsQuery.data ?? null;
|
|
48
|
+
const publishedVersionNumber = versionsView?.publishedVersion;
|
|
49
|
+
const publishedSnapshotDoc = publishedVersionSnapshot(versionsView);
|
|
50
|
+
const publishedSnapshot = derivePublishedSnapshot(versionsView);
|
|
51
|
+
const { viewReadOnly, viewDoc, publishedPending } = resolveViewDoc({ perspective, publishedSnapshot, draft });
|
|
52
|
+
return {
|
|
53
|
+
perspective,
|
|
54
|
+
setPerspective,
|
|
55
|
+
published,
|
|
56
|
+
canDiscard,
|
|
57
|
+
versionsView,
|
|
58
|
+
versionKey,
|
|
59
|
+
publishedVersionNumber,
|
|
60
|
+
publishedSnapshotDoc,
|
|
61
|
+
publishedSnapshot,
|
|
62
|
+
viewReadOnly,
|
|
63
|
+
viewDoc,
|
|
64
|
+
publishedPending,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=use-published-perspective.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-published-perspective.js","sourceRoot":"","sources":["../../../src/components/cms/use-published-perspective.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAK3C,OAAO,EAAE,wBAAwB,EAAE,MAAM,yBAAyB,CAAA;AAClE,OAAO,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAA;AAKpF;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAAC,KAI9B;IACC,MAAM,YAAY,GAAG,KAAK,CAAC,WAAW,KAAK,WAAW,CAAA;IACtD,MAAM,KAAK,GAAG,KAAK,CAAC,iBAAiB,CAAC,MAAM,KAAK,OAAO,CAAA;IACxD,OAAO;QACL,YAAY;QACZ,OAAO,EAAE,YAAY,IAAI,KAAK,CAAC,iBAAiB,CAAC,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK;QAC/G,gBAAgB,EAAE,YAAY,IAAI,CAAC,KAAK;KACzC,CAAA;AACH,CAAC;AA2BD;;;;;;;;;GASG;AACH,MAAM,UAAU,uBAAuB,CAAC,KAMvC;IACC,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,KAAK,CAAA;IAC7D,MAAM,SAAS,GAAG,MAAM,CAAC,UAAU,EAAE,WAAW,CAAC,GAAG,CAAC,IAAI,KAAK,CAAA;IAC9D,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAc,OAAO,CAAC,CAAA;IACpE,6EAA6E;IAC7E,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,SAAS;YAAE,cAAc,CAAC,OAAO,CAAC,CAAA;IACzC,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC,CAAA;IAEf,MAAM,UAAU,GAAG,UAAU,IAAI,WAAW,CAAA;IAC5C,MAAM,EAAE,aAAa,EAAE,UAAU,EAAE,GAAG,wBAAwB,CAAC,GAAG,EAAE;QAClE,OAAO,EAAE,UAAU,IAAI,SAAS;KACjC,CAAC,CAAA;IACF,MAAM,YAAY,GAAG,aAAa,CAAC,IAAI,IAAI,IAAI,CAAA;IAC/C,MAAM,sBAAsB,GAAG,YAAY,EAAE,gBAAgB,CAAA;IAC7D,MAAM,oBAAoB,GAAG,wBAAwB,CAAC,YAAY,CAAC,CAAA;IACnE,MAAM,iBAAiB,GAAG,uBAAuB,CAAC,YAAY,CAAC,CAAA;IAC/D,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,gBAAgB,EAAE,GAAG,cAAc,CAAC,EAAE,WAAW,EAAE,iBAAiB,EAAE,KAAK,EAAE,CAAC,CAAA;IAE7G,OAAO;QACL,WAAW;QACX,cAAc;QACd,SAAS;QACT,UAAU;QACV,YAAY;QACZ,UAAU;QACV,sBAAsB;QACtB,oBAAoB;QACpB,iBAAiB;QACjB,YAAY;QACZ,OAAO;QACP,gBAAgB;KACjB,CAAA;AACH,CAAC","sourcesContent":["import { useEffect, useState } from 'react'\nimport type { QueryKey } from '@tanstack/react-query'\nimport type { CmsDocument, DocumentRef } from '@elytracms/core/cms-core'\nimport type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source'\nimport type { DocumentVersionsView } from './cms-store'\nimport { useDocumentVersionsQuery } from './use-document-versions'\nimport { derivePublishedSnapshot, publishedVersionSnapshot } from './versions-model'\nimport type { PublishedSnapshotState } from './versions-model'\n\nexport type Perspective = 'draft' | 'published'\n\n/**\n * EC-226 — which document the fieldset renders, given the active perspective and\n * the pinned published snapshot's load state. A pure decision so \"never present\n * the draft AS published\" is single-sourced and directly testable:\n *\n * - Draft perspective → the live draft, editable.\n * - Published perspective, snapshot READY → the pinned snapshot, read-only.\n * - Published perspective, snapshot NOT ready → still the draft as the fallback\n * render target, but flagged `publishedPending` so the shell shows a placeholder\n * instead (the draft must never be shown labelled as the published content).\n */\nexport function resolveViewDoc(input: {\n perspective: Perspective\n publishedSnapshot: PublishedSnapshotState\n draft: CmsDocument\n}): { viewReadOnly: boolean; viewDoc: CmsDocument; publishedPending: boolean } {\n const viewReadOnly = input.perspective === 'published'\n const ready = input.publishedSnapshot.status === 'ready'\n return {\n viewReadOnly,\n viewDoc: viewReadOnly && input.publishedSnapshot.status === 'ready' ? input.publishedSnapshot.doc : input.draft,\n publishedPending: viewReadOnly && !ready,\n }\n}\n\nexport interface PublishedPerspective {\n perspective: Perspective\n setPerspective: (perspective: Perspective) => void\n /** Is the document published (a version pointer is set)? */\n published: boolean\n /** A publishing AND versioned rail — gates the version-history query + Discard. */\n canDiscard: boolean\n /** The recorded version history (null while loading / disabled). */\n versionsView: DocumentVersionsView | null\n /** The shared version-history cache key ({@link documentVersionsKey}). */\n versionKey: QueryKey\n /** The pinned published version number, or undefined while loading / none pinned. */\n publishedVersionNumber: number | undefined\n /** The pinned published version's snapshot, or undefined while loading / none. */\n publishedSnapshotDoc: CmsDocument | undefined\n /** The loaded state of the pinned published snapshot. */\n publishedSnapshot: PublishedSnapshotState\n /** Is the editor viewing the read-only Published snapshot? */\n viewReadOnly: boolean\n /** The document the fieldset renders (draft or the pinned snapshot). */\n viewDoc: CmsDocument\n /** Is the Published snapshot still loading (show a placeholder, not the draft)? */\n publishedPending: boolean\n}\n\n/**\n * EC-226 / EC-316 / EC-325 — the Draft/Published VIEW perspective plus the pinned\n * published snapshot, both derived from the ONE shared version-history query\n * (AD-17: the single operations-backed cache, `use-document-versions.ts` — no raw\n * `source.versioning.load` call). The Discard-draft gating and the Published\n * perspective read exactly the same `versions`/`publishedVersion` shape, so a\n * second bespoke fetch on perspective switch would just re-read this. Gated the\n * same way Discard always was — a publishing + versioned rail AND a published\n * document — which is also exactly when the Published perspective is reachable.\n */\nexport function usePublishedPerspective(input: {\n ref: DocumentRef\n source: CollectionViewSource\n canPublish: boolean\n canVersions: boolean\n draft: CmsDocument\n}): PublishedPerspective {\n const { ref, source, canPublish, canVersions, draft } = input\n const published = source.publishing?.isPublished(ref) ?? false\n const [perspective, setPerspective] = useState<Perspective>('draft')\n // Snap back to Draft if the document is unpublished while viewing Published.\n useEffect(() => {\n if (!published) setPerspective('draft')\n }, [published])\n\n const canDiscard = canPublish && canVersions\n const { versionsQuery, versionKey } = useDocumentVersionsQuery(ref, {\n enabled: canDiscard && published,\n })\n const versionsView = versionsQuery.data ?? null\n const publishedVersionNumber = versionsView?.publishedVersion\n const publishedSnapshotDoc = publishedVersionSnapshot(versionsView)\n const publishedSnapshot = derivePublishedSnapshot(versionsView)\n const { viewReadOnly, viewDoc, publishedPending } = resolveViewDoc({ perspective, publishedSnapshot, draft })\n\n return {\n perspective,\n setPerspective,\n published,\n canDiscard,\n versionsView,\n versionKey,\n publishedVersionNumber,\n publishedSnapshotDoc,\n publishedSnapshot,\n viewReadOnly,\n viewDoc,\n publishedPending,\n }\n}\n"]}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { QueryKey } from '@tanstack/react-query';
|
|
2
|
+
import type { CmsDocument, DocumentRef, RestoreScope } from '@elytracms/core/cms-core';
|
|
3
|
+
import type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source';
|
|
4
|
+
/**
|
|
5
|
+
* EC-325 — the version-restore sequence, single-sourced. The Versions pane's
|
|
6
|
+
* per-version restore, the Compare view's scoped/whole restore, and the header's
|
|
7
|
+
* "Discard changes" (a whole-document restore of the pinned published version)
|
|
8
|
+
* all ran their OWN copy of the identical four steps:
|
|
9
|
+
*
|
|
10
|
+
* 1. flush a dirty in-memory buffer FIRST (`flushDirtyDraft`) — `documents.restore`
|
|
11
|
+
* snapshots whatever is currently PERSISTED as its pre-restore version, so an
|
|
12
|
+
* edit still in the autosave debounce window must reach the server before the
|
|
13
|
+
* restore reads it, or it is silently unrecoverable (the undo-guarantee gap
|
|
14
|
+
* the Discard path used to have);
|
|
15
|
+
* 2. `versioning.restore(ref, version, scope?)` — scope absent ⇒ whole document
|
|
16
|
+
* (`documents.restore` reads `scope ?? { kind: 'all' }`, so an omitted scope
|
|
17
|
+
* and `{ kind: 'all' }` are the same restore);
|
|
18
|
+
* 3. adopt the restored draft into the open editor (`replaceDraft`);
|
|
19
|
+
* 4. invalidate the shared version-history cache key so the history re-reads.
|
|
20
|
+
*
|
|
21
|
+
* The pure {@link performRestore} pins that ordering (unit-tested with spies); the
|
|
22
|
+
* {@link useRestoreVersion} hook wraps it with the per-call-site busy flag and the
|
|
23
|
+
* operations cache. The differing signatures reconcile to `(version, scope?)`:
|
|
24
|
+
* whole-document callers pass no scope, the Compare view passes its `RestoreScope`,
|
|
25
|
+
* and Discard passes the pinned published version with `{ kind: 'all' }`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function performRestore(input: {
|
|
28
|
+
dirty: boolean;
|
|
29
|
+
save: () => void;
|
|
30
|
+
restore: (version: number, scope?: RestoreScope) => Promise<CmsDocument | null>;
|
|
31
|
+
replaceDraft: (doc: CmsDocument) => void;
|
|
32
|
+
invalidate: () => Promise<void>;
|
|
33
|
+
version: number;
|
|
34
|
+
scope?: RestoreScope;
|
|
35
|
+
}): Promise<void>;
|
|
36
|
+
/** Dependencies the restore sequence reads from its call site's editor state. */
|
|
37
|
+
export interface RestoreVersionDeps {
|
|
38
|
+
source: CollectionViewSource;
|
|
39
|
+
docRef: DocumentRef;
|
|
40
|
+
/** The shared version-history cache key ({@link documentVersionsKey}). */
|
|
41
|
+
versionKey: QueryKey;
|
|
42
|
+
/** Is the in-memory draft dirty (an unsaved buffer to flush first)? */
|
|
43
|
+
dirty: boolean;
|
|
44
|
+
/** Persist the current draft buffer. */
|
|
45
|
+
save: () => void;
|
|
46
|
+
/** Adopt the restored snapshot as the open editor's draft. */
|
|
47
|
+
replaceDraft: (doc: CmsDocument) => void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The shared restore action + its in-flight flag. `restore(version, scope?)`
|
|
51
|
+
* runs the {@link performRestore} sequence; `busy` gates the calling surface's
|
|
52
|
+
* restore/discard controls while it round-trips.
|
|
53
|
+
*/
|
|
54
|
+
export declare function useRestoreVersion(deps: RestoreVersionDeps): {
|
|
55
|
+
restore: (version: number, scope?: RestoreScope) => Promise<void>;
|
|
56
|
+
busy: boolean;
|
|
57
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import { useOperationCache } from '@elytracms/studio/lib/query';
|
|
3
|
+
import { flushDirtyDraft } from './discard-draft';
|
|
4
|
+
/**
|
|
5
|
+
* EC-325 — the version-restore sequence, single-sourced. The Versions pane's
|
|
6
|
+
* per-version restore, the Compare view's scoped/whole restore, and the header's
|
|
7
|
+
* "Discard changes" (a whole-document restore of the pinned published version)
|
|
8
|
+
* all ran their OWN copy of the identical four steps:
|
|
9
|
+
*
|
|
10
|
+
* 1. flush a dirty in-memory buffer FIRST (`flushDirtyDraft`) — `documents.restore`
|
|
11
|
+
* snapshots whatever is currently PERSISTED as its pre-restore version, so an
|
|
12
|
+
* edit still in the autosave debounce window must reach the server before the
|
|
13
|
+
* restore reads it, or it is silently unrecoverable (the undo-guarantee gap
|
|
14
|
+
* the Discard path used to have);
|
|
15
|
+
* 2. `versioning.restore(ref, version, scope?)` — scope absent ⇒ whole document
|
|
16
|
+
* (`documents.restore` reads `scope ?? { kind: 'all' }`, so an omitted scope
|
|
17
|
+
* and `{ kind: 'all' }` are the same restore);
|
|
18
|
+
* 3. adopt the restored draft into the open editor (`replaceDraft`);
|
|
19
|
+
* 4. invalidate the shared version-history cache key so the history re-reads.
|
|
20
|
+
*
|
|
21
|
+
* The pure {@link performRestore} pins that ordering (unit-tested with spies); the
|
|
22
|
+
* {@link useRestoreVersion} hook wraps it with the per-call-site busy flag and the
|
|
23
|
+
* operations cache. The differing signatures reconcile to `(version, scope?)`:
|
|
24
|
+
* whole-document callers pass no scope, the Compare view passes its `RestoreScope`,
|
|
25
|
+
* and Discard passes the pinned published version with `{ kind: 'all' }`.
|
|
26
|
+
*/
|
|
27
|
+
export async function performRestore(input) {
|
|
28
|
+
// Step 1 — flush before the restore reads the persisted document.
|
|
29
|
+
flushDirtyDraft({ dirty: input.dirty, save: input.save });
|
|
30
|
+
// Step 2 — the restore itself (scope absent ⇒ whole document).
|
|
31
|
+
const restored = await input.restore(input.version, input.scope);
|
|
32
|
+
// Step 3 — the open editor adopts the restored snapshot as its new draft.
|
|
33
|
+
if (restored)
|
|
34
|
+
input.replaceDraft(restored);
|
|
35
|
+
// Step 4 — re-read the recorded history (the new pre-restore version appears).
|
|
36
|
+
await input.invalidate();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The shared restore action + its in-flight flag. `restore(version, scope?)`
|
|
40
|
+
* runs the {@link performRestore} sequence; `busy` gates the calling surface's
|
|
41
|
+
* restore/discard controls while it round-trips.
|
|
42
|
+
*/
|
|
43
|
+
export function useRestoreVersion(deps) {
|
|
44
|
+
const { source, docRef, versionKey, dirty, save, replaceDraft } = deps;
|
|
45
|
+
const cache = useOperationCache();
|
|
46
|
+
const [busy, setBusy] = useState(false);
|
|
47
|
+
const restore = useCallback(async (version, scope) => {
|
|
48
|
+
setBusy(true);
|
|
49
|
+
try {
|
|
50
|
+
await performRestore({
|
|
51
|
+
dirty,
|
|
52
|
+
save,
|
|
53
|
+
restore: async (v, s) => (await source.versioning?.restore(docRef, v, s)) ?? null,
|
|
54
|
+
replaceDraft,
|
|
55
|
+
invalidate: () => cache.invalidate(versionKey),
|
|
56
|
+
version,
|
|
57
|
+
scope,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
setBusy(false);
|
|
62
|
+
}
|
|
63
|
+
}, [source, docRef, versionKey, dirty, save, replaceDraft, cache]);
|
|
64
|
+
return { restore, busy };
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=use-restore-version.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-restore-version.js","sourceRoot":"","sources":["../../../src/components/cms/use-restore-version.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAG7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;AAE/D,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAEjD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,KAQpC;IACC,kEAAkE;IAClE,eAAe,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAA;IACzD,+DAA+D;IAC/D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,CAAA;IAChE,0EAA0E;IAC1E,IAAI,QAAQ;QAAE,KAAK,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAA;IAC1C,+EAA+E;IAC/E,MAAM,KAAK,CAAC,UAAU,EAAE,CAAA;AAC1B,CAAC;AAgBD;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAwB;IAIxD,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,GAAG,IAAI,CAAA;IACtE,MAAM,KAAK,GAAG,iBAAiB,EAAE,CAAA;IACjC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IACvC,MAAM,OAAO,GAAG,WAAW,CACzB,KAAK,EAAE,OAAe,EAAE,KAAoB,EAAE,EAAE;QAC9C,OAAO,CAAC,IAAI,CAAC,CAAA;QACb,IAAI,CAAC;YACH,MAAM,cAAc,CAAC;gBACnB,KAAK;gBACL,IAAI;gBACJ,OAAO,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI;gBACjF,YAAY;gBACZ,UAAU,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,UAAU,CAAC;gBAC9C,OAAO;gBACP,KAAK;aACN,CAAC,CAAA;QACJ,CAAC;gBAAS,CAAC;YACT,OAAO,CAAC,KAAK,CAAC,CAAA;QAChB,CAAC;IACH,CAAC,EACD,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,CAAC,CAC/D,CAAA;IACD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAC1B,CAAC","sourcesContent":["import { useCallback, useState } from 'react'\nimport type { QueryKey } from '@tanstack/react-query'\nimport type { CmsDocument, DocumentRef, RestoreScope } from '@elytracms/core/cms-core'\nimport { useOperationCache } from '@elytracms/studio/lib/query'\nimport type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source'\nimport { flushDirtyDraft } from './discard-draft'\n\n/**\n * EC-325 — the version-restore sequence, single-sourced. The Versions pane's\n * per-version restore, the Compare view's scoped/whole restore, and the header's\n * \"Discard changes\" (a whole-document restore of the pinned published version)\n * all ran their OWN copy of the identical four steps:\n *\n * 1. flush a dirty in-memory buffer FIRST (`flushDirtyDraft`) — `documents.restore`\n * snapshots whatever is currently PERSISTED as its pre-restore version, so an\n * edit still in the autosave debounce window must reach the server before the\n * restore reads it, or it is silently unrecoverable (the undo-guarantee gap\n * the Discard path used to have);\n * 2. `versioning.restore(ref, version, scope?)` — scope absent ⇒ whole document\n * (`documents.restore` reads `scope ?? { kind: 'all' }`, so an omitted scope\n * and `{ kind: 'all' }` are the same restore);\n * 3. adopt the restored draft into the open editor (`replaceDraft`);\n * 4. invalidate the shared version-history cache key so the history re-reads.\n *\n * The pure {@link performRestore} pins that ordering (unit-tested with spies); the\n * {@link useRestoreVersion} hook wraps it with the per-call-site busy flag and the\n * operations cache. The differing signatures reconcile to `(version, scope?)`:\n * whole-document callers pass no scope, the Compare view passes its `RestoreScope`,\n * and Discard passes the pinned published version with `{ kind: 'all' }`.\n */\nexport async function performRestore(input: {\n dirty: boolean\n save: () => void\n restore: (version: number, scope?: RestoreScope) => Promise<CmsDocument | null>\n replaceDraft: (doc: CmsDocument) => void\n invalidate: () => Promise<void>\n version: number\n scope?: RestoreScope\n}): Promise<void> {\n // Step 1 — flush before the restore reads the persisted document.\n flushDirtyDraft({ dirty: input.dirty, save: input.save })\n // Step 2 — the restore itself (scope absent ⇒ whole document).\n const restored = await input.restore(input.version, input.scope)\n // Step 3 — the open editor adopts the restored snapshot as its new draft.\n if (restored) input.replaceDraft(restored)\n // Step 4 — re-read the recorded history (the new pre-restore version appears).\n await input.invalidate()\n}\n\n/** Dependencies the restore sequence reads from its call site's editor state. */\nexport interface RestoreVersionDeps {\n source: CollectionViewSource\n docRef: DocumentRef\n /** The shared version-history cache key ({@link documentVersionsKey}). */\n versionKey: QueryKey\n /** Is the in-memory draft dirty (an unsaved buffer to flush first)? */\n dirty: boolean\n /** Persist the current draft buffer. */\n save: () => void\n /** Adopt the restored snapshot as the open editor's draft. */\n replaceDraft: (doc: CmsDocument) => void\n}\n\n/**\n * The shared restore action + its in-flight flag. `restore(version, scope?)`\n * runs the {@link performRestore} sequence; `busy` gates the calling surface's\n * restore/discard controls while it round-trips.\n */\nexport function useRestoreVersion(deps: RestoreVersionDeps): {\n restore: (version: number, scope?: RestoreScope) => Promise<void>\n busy: boolean\n} {\n const { source, docRef, versionKey, dirty, save, replaceDraft } = deps\n const cache = useOperationCache()\n const [busy, setBusy] = useState(false)\n const restore = useCallback(\n async (version: number, scope?: RestoreScope) => {\n setBusy(true)\n try {\n await performRestore({\n dirty,\n save,\n restore: async (v, s) => (await source.versioning?.restore(docRef, v, s)) ?? null,\n replaceDraft,\n invalidate: () => cache.invalidate(versionKey),\n version,\n scope,\n })\n } finally {\n setBusy(false)\n }\n },\n [source, docRef, versionKey, dirty, save, replaceDraft, cache],\n )\n return { restore, busy }\n}\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CmsDocument } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source';
|
|
3
|
+
/**
|
|
4
|
+
* EC-225 / EC-240 — the two persistence disciplines the detail editor supports,
|
|
5
|
+
* as one pure decision so the autosave scheduler and the unmount flush can never
|
|
6
|
+
* diverge on WHEN a dirty draft is persisted silently:
|
|
7
|
+
*
|
|
8
|
+
* - A **publishing** rail AUTOSAVES: the draft persists 1s after the last edit and
|
|
9
|
+
* publish is the deliberate commit — so a dirty draft is flushed both on the
|
|
10
|
+
* debounce timer and on unmount (navigating away inside the debounce window).
|
|
11
|
+
* - A **non-publishing** rail (users, EC-240) SAVES EXPLICITLY: edits stay in the
|
|
12
|
+
* draft until the Save button, and an `UnsavedChangesGuard` protects navigation —
|
|
13
|
+
* so it NEVER persists silently (returns false here).
|
|
14
|
+
*/
|
|
15
|
+
export declare function shouldPersistDraft(input: {
|
|
16
|
+
explicitSave: boolean;
|
|
17
|
+
dirty: boolean;
|
|
18
|
+
}): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* EC-225 autosave · EC-240 explicit save. Owns the debounced autosave timer and
|
|
21
|
+
* the unmount flush for a publishing rail; a non-publishing rail persists only via
|
|
22
|
+
* its Save button (the caller's `UnsavedChangesGuard` covers navigation). Refs let
|
|
23
|
+
* the unmount flush read the latest values without re-subscribing.
|
|
24
|
+
*
|
|
25
|
+
* Returns `explicitSave` — the caller renders the Save button + guard (explicit)
|
|
26
|
+
* or the autosave status text (autosave) from it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function useSaveMode(input: {
|
|
29
|
+
canPublish: boolean;
|
|
30
|
+
dirty: boolean;
|
|
31
|
+
draft: CmsDocument;
|
|
32
|
+
source: CollectionViewSource;
|
|
33
|
+
}): {
|
|
34
|
+
explicitSave: boolean;
|
|
35
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* EC-225 / EC-240 — the two persistence disciplines the detail editor supports,
|
|
4
|
+
* as one pure decision so the autosave scheduler and the unmount flush can never
|
|
5
|
+
* diverge on WHEN a dirty draft is persisted silently:
|
|
6
|
+
*
|
|
7
|
+
* - A **publishing** rail AUTOSAVES: the draft persists 1s after the last edit and
|
|
8
|
+
* publish is the deliberate commit — so a dirty draft is flushed both on the
|
|
9
|
+
* debounce timer and on unmount (navigating away inside the debounce window).
|
|
10
|
+
* - A **non-publishing** rail (users, EC-240) SAVES EXPLICITLY: edits stay in the
|
|
11
|
+
* draft until the Save button, and an `UnsavedChangesGuard` protects navigation —
|
|
12
|
+
* so it NEVER persists silently (returns false here).
|
|
13
|
+
*/
|
|
14
|
+
export function shouldPersistDraft(input) {
|
|
15
|
+
return !input.explicitSave && input.dirty;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* EC-225 autosave · EC-240 explicit save. Owns the debounced autosave timer and
|
|
19
|
+
* the unmount flush for a publishing rail; a non-publishing rail persists only via
|
|
20
|
+
* its Save button (the caller's `UnsavedChangesGuard` covers navigation). Refs let
|
|
21
|
+
* the unmount flush read the latest values without re-subscribing.
|
|
22
|
+
*
|
|
23
|
+
* Returns `explicitSave` — the caller renders the Save button + guard (explicit)
|
|
24
|
+
* or the autosave status text (autosave) from it.
|
|
25
|
+
*/
|
|
26
|
+
export function useSaveMode(input) {
|
|
27
|
+
const { canPublish, dirty, draft, source } = input;
|
|
28
|
+
const explicitSave = !canPublish;
|
|
29
|
+
const draftRef = useRef(draft);
|
|
30
|
+
draftRef.current = draft;
|
|
31
|
+
const dirtyRef = useRef(dirty);
|
|
32
|
+
dirtyRef.current = dirty;
|
|
33
|
+
const sourceRef = useRef(source);
|
|
34
|
+
sourceRef.current = source;
|
|
35
|
+
const explicitSaveRef = useRef(explicitSave);
|
|
36
|
+
explicitSaveRef.current = explicitSave;
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (explicitSave)
|
|
39
|
+
return; // explicit rails persist only via the Save button
|
|
40
|
+
if (!dirty)
|
|
41
|
+
return;
|
|
42
|
+
const timer = window.setTimeout(() => sourceRef.current.save(draftRef.current), 1000);
|
|
43
|
+
return () => window.clearTimeout(timer);
|
|
44
|
+
}, [dirty, draft, explicitSave]);
|
|
45
|
+
// Flush a still-pending AUTOSAVE edit on unmount (navigating away inside the
|
|
46
|
+
// debounce window) so nothing is lost. Explicit rails never flush silently —
|
|
47
|
+
// their `UnsavedChangesGuard` catches the navigation instead. Unmount-only.
|
|
48
|
+
useEffect(() => () => {
|
|
49
|
+
if (shouldPersistDraft({ explicitSave: explicitSaveRef.current, dirty: dirtyRef.current }))
|
|
50
|
+
sourceRef.current.save(draftRef.current);
|
|
51
|
+
}, []);
|
|
52
|
+
return { explicitSave };
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=use-save-mode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-save-mode.js","sourceRoot":"","sources":["../../../src/components/cms/use-save-mode.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAIzC;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAgD;IACjF,OAAO,CAAC,KAAK,CAAC,YAAY,IAAI,KAAK,CAAC,KAAK,CAAA;AAC3C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CAAC,KAK3B;IACC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,KAAK,CAAA;IAClD,MAAM,YAAY,GAAG,CAAC,UAAU,CAAA;IAChC,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC9B,QAAQ,CAAC,OAAO,GAAG,KAAK,CAAA;IACxB,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC9B,QAAQ,CAAC,OAAO,GAAG,KAAK,CAAA;IACxB,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAA;IAChC,SAAS,CAAC,OAAO,GAAG,MAAM,CAAA;IAC1B,MAAM,eAAe,GAAG,MAAM,CAAC,YAAY,CAAC,CAAA;IAC5C,eAAe,CAAC,OAAO,GAAG,YAAY,CAAA;IAEtC,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,YAAY;YAAE,OAAM,CAAC,kDAAkD;QAC3E,IAAI,CAAC,KAAK;YAAE,OAAM;QAClB,MAAM,KAAK,GAAG,MAAM,CAAC,UAAU,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,CAAA;QACrF,OAAO,GAAG,EAAE,CAAC,MAAM,CAAC,YAAY,CAAC,KAAK,CAAC,CAAA;IACzC,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC,CAAA;IAEhC,6EAA6E;IAC7E,6EAA6E;IAC7E,4EAA4E;IAC5E,SAAS,CACP,GAAG,EAAE,CAAC,GAAG,EAAE;QACT,IAAI,kBAAkB,CAAC,EAAE,YAAY,EAAE,eAAe,CAAC,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,OAAO,EAAE,CAAC;YACxF,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAA;IAC5C,CAAC,EACD,EAAE,CACH,CAAA;IAED,OAAO,EAAE,YAAY,EAAE,CAAA;AACzB,CAAC","sourcesContent":["import { useEffect, useRef } from 'react'\nimport type { CmsDocument } from '@elytracms/core/cms-core'\nimport type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source'\n\n/**\n * EC-225 / EC-240 — the two persistence disciplines the detail editor supports,\n * as one pure decision so the autosave scheduler and the unmount flush can never\n * diverge on WHEN a dirty draft is persisted silently:\n *\n * - A **publishing** rail AUTOSAVES: the draft persists 1s after the last edit and\n * publish is the deliberate commit — so a dirty draft is flushed both on the\n * debounce timer and on unmount (navigating away inside the debounce window).\n * - A **non-publishing** rail (users, EC-240) SAVES EXPLICITLY: edits stay in the\n * draft until the Save button, and an `UnsavedChangesGuard` protects navigation —\n * so it NEVER persists silently (returns false here).\n */\nexport function shouldPersistDraft(input: { explicitSave: boolean; dirty: boolean }): boolean {\n return !input.explicitSave && input.dirty\n}\n\n/**\n * EC-225 autosave · EC-240 explicit save. Owns the debounced autosave timer and\n * the unmount flush for a publishing rail; a non-publishing rail persists only via\n * its Save button (the caller's `UnsavedChangesGuard` covers navigation). Refs let\n * the unmount flush read the latest values without re-subscribing.\n *\n * Returns `explicitSave` — the caller renders the Save button + guard (explicit)\n * or the autosave status text (autosave) from it.\n */\nexport function useSaveMode(input: {\n canPublish: boolean\n dirty: boolean\n draft: CmsDocument\n source: CollectionViewSource\n}): { explicitSave: boolean } {\n const { canPublish, dirty, draft, source } = input\n const explicitSave = !canPublish\n const draftRef = useRef(draft)\n draftRef.current = draft\n const dirtyRef = useRef(dirty)\n dirtyRef.current = dirty\n const sourceRef = useRef(source)\n sourceRef.current = source\n const explicitSaveRef = useRef(explicitSave)\n explicitSaveRef.current = explicitSave\n\n useEffect(() => {\n if (explicitSave) return // explicit rails persist only via the Save button\n if (!dirty) return\n const timer = window.setTimeout(() => sourceRef.current.save(draftRef.current), 1000)\n return () => window.clearTimeout(timer)\n }, [dirty, draft, explicitSave])\n\n // Flush a still-pending AUTOSAVE edit on unmount (navigating away inside the\n // debounce window) so nothing is lost. Explicit rails never flush silently —\n // their `UnsavedChangesGuard` catches the navigation instead. Unmount-only.\n useEffect(\n () => () => {\n if (shouldPersistDraft({ explicitSave: explicitSaveRef.current, dirty: dirtyRef.current }))\n sourceRef.current.save(draftRef.current)\n },\n [],\n )\n\n return { explicitSave }\n}\n"]}
|
|
@@ -4,7 +4,6 @@ import { ArrowRight, ChevronDown, ChevronLeft, ChevronRight, Image as ImageIcon,
|
|
|
4
4
|
import { diffDocuments } from '@elytracms/core/content';
|
|
5
5
|
import { assetSubfieldsOf, isAssetNode, readAssetId, readDocumentRef } from '@elytracms/core/cms-core';
|
|
6
6
|
import { Badge, Button, Code, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Row, Spacer, Stack, Text, } from '@elytracms/studio/ui';
|
|
7
|
-
import { useOperationCache } from '@elytracms/studio/lib/query';
|
|
8
7
|
import { translate } from '@elytracms/studio/lib/i18n';
|
|
9
8
|
import { localeChipLabel } from '@elytracms/studio/lib/locale';
|
|
10
9
|
import { cn } from '@elytracms/studio/lib/utils';
|
|
@@ -15,6 +14,7 @@ import { toRefArray } from './cell';
|
|
|
15
14
|
import { DIFF_TRUNCATE_THRESHOLD, mergeWordDiff, richTextToText, truncateText, truncateTokens, wordDiff, } from './version-compare-text';
|
|
16
15
|
import { useDocumentEditor } from './document-editor-context';
|
|
17
16
|
import { useDocumentVersionsQuery } from './use-document-versions';
|
|
17
|
+
import { useRestoreVersion } from './use-restore-version';
|
|
18
18
|
import { formatVersionTimestamp } from './versions-model';
|
|
19
19
|
/**
|
|
20
20
|
* EC-298 — the version compare view. Renders as the sticky **Compare** content tab
|
|
@@ -35,8 +35,16 @@ export function VersionCompare() {
|
|
|
35
35
|
const source = useCollectionView();
|
|
36
36
|
const { collection, docRef, draft, dirty, viewReadOnly, save, replaceDraft, compare, setCompareBase, setCompareTarget, } = useDocumentEditor();
|
|
37
37
|
const { versionsQuery, versionKey } = useDocumentVersionsQuery(docRef);
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// EC-325 — the shared restore sequence (flush → restore → replaceDraft →
|
|
39
|
+
// invalidate). `busy` gates the restore controls below while it round-trips.
|
|
40
|
+
const { restore: restoreVersion, busy } = useRestoreVersion({
|
|
41
|
+
source,
|
|
42
|
+
docRef,
|
|
43
|
+
versionKey,
|
|
44
|
+
dirty,
|
|
45
|
+
save,
|
|
46
|
+
replaceDraft,
|
|
47
|
+
});
|
|
40
48
|
// EC-317 — the unified/side-by-side toggle is a transient, personal-local view
|
|
41
49
|
// preference (same class as the theme/rail prefs): read once at mount, written
|
|
42
50
|
// back on every change, never round-tripped through the project graph.
|
|
@@ -97,19 +105,8 @@ export function VersionCompare() {
|
|
|
97
105
|
const restore = useCallback(async (scope) => {
|
|
98
106
|
if (!compare)
|
|
99
107
|
return;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (dirty)
|
|
103
|
-
save();
|
|
104
|
-
const restored = await source.versioning?.restore(docRef, compare.base, scope);
|
|
105
|
-
if (restored)
|
|
106
|
-
replaceDraft(restored);
|
|
107
|
-
await cache.invalidate(versionKey);
|
|
108
|
-
}
|
|
109
|
-
finally {
|
|
110
|
-
setBusy(false);
|
|
111
|
-
}
|
|
112
|
-
}, [compare, dirty, save, source, docRef, replaceDraft, cache, versionKey]);
|
|
108
|
+
await restoreVersion(compare.base, scope);
|
|
109
|
+
}, [compare, restoreVersion]);
|
|
113
110
|
if (!compare)
|
|
114
111
|
return null;
|
|
115
112
|
if (view === null)
|