@elytracms/studio 0.0.70 → 0.0.72
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 +8 -0
- package/convex/delivery.ts +12 -2
- package/convex/write-limits.ts +62 -0
- package/dist/components/account/self-account-view-source.js +1 -0
- package/dist/components/account/self-account-view-source.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.js +1 -0
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/cms/collection-table.js +19 -2
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.js +73 -51
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/duplicate-document.d.ts +45 -0
- package/dist/components/cms/duplicate-document.js +141 -0
- package/dist/components/cms/duplicate-document.js.map +1 -0
- package/dist/components/cms/field-anchors.d.ts +67 -0
- package/dist/components/cms/field-anchors.js +91 -0
- package/dist/components/cms/field-anchors.js.map +1 -0
- package/dist/components/cms/issue-copy.d.ts +45 -0
- package/dist/components/cms/issue-copy.js +89 -0
- package/dist/components/cms/issue-copy.js.map +1 -0
- package/dist/components/cms/issues-pane.d.ts +11 -2
- package/dist/components/cms/issues-pane.js +22 -4
- package/dist/components/cms/issues-pane.js.map +1 -1
- package/dist/components/cms/object-array-field.d.ts +2 -0
- package/dist/components/cms/object-array-field.js +75 -21
- package/dist/components/cms/object-array-field.js.map +1 -1
- package/dist/components/cms/row-actions.d.ts +10 -3
- package/dist/components/cms/row-actions.js +8 -0
- package/dist/components/cms/row-actions.js.map +1 -1
- package/dist/components/cms/use-field-focus.d.ts +25 -9
- package/dist/components/cms/use-field-focus.js +80 -15
- package/dist/components/cms/use-field-focus.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +1 -0
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/redirects/redirects-collection-view-source.js +1 -0
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
- package/dist/components/users/users-collection-view-source.js +1 -0
- package/dist/components/users/users-collection-view-source.js.map +1 -1
- package/dist/customization.d.ts +9 -0
- package/dist/customization.js +8 -0
- package/dist/customization.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +8 -0
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +38 -1
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +34 -0
- package/dist/lib/i18n/messages.en.js +41 -1
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/persona/labels.d.ts +5 -1
- package/dist/lib/persona/labels.js +43 -30
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/previews/enumerate.d.ts +68 -0
- package/dist/previews/enumerate.js +78 -0
- package/dist/previews/enumerate.js.map +1 -0
- package/dist/previews/harness.d.ts +50 -0
- package/dist/previews/harness.js +123 -0
- package/dist/previews/harness.js.map +1 -0
- package/dist/previews/manifest-format.d.ts +57 -0
- package/dist/previews/manifest-format.js +59 -0
- package/dist/previews/manifest-format.js.map +1 -0
- package/dist/ui/field.d.ts +24 -1
- package/dist/ui/field.js +23 -2
- package/dist/ui/field.js.map +1 -1
- package/package.json +10 -6
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EC-442 — the duplicate-document derivation (pure core) + the source-composed
|
|
3
|
+
* dispatch behind the list row `⋮` menu's and the detail-editor header's
|
|
4
|
+
* **Duplicate** action.
|
|
5
|
+
*
|
|
6
|
+
* Semantics (ratified 2026-07-17):
|
|
7
|
+
* - The duplicate copies the current DRAFT field values — `values` plus
|
|
8
|
+
* `localized` across ALL locales plus `defaultLocale` — into a fresh document
|
|
9
|
+
* id. It is created as a **draft, never auto-published** ("live is
|
|
10
|
+
* published-only"): published-ness is the version pointer (EC-224), and
|
|
11
|
+
* nothing here touches it.
|
|
12
|
+
* - `alias` is deliberately NOT copied: an alias is a unique-within-collection
|
|
13
|
+
* slot handle (EC-284) — code addresses THE document holding it, and a copied
|
|
14
|
+
* alias would immediately surface as uniqueness drift. The duplicate is new
|
|
15
|
+
* content, not the slot holder.
|
|
16
|
+
* - **Slug strategy: uniquify, never clear.** Every slug field (a `text` field
|
|
17
|
+
* named `slug` or opted in via `form.control: 'slug'` — the EC-315
|
|
18
|
+
* convention) gets a deterministic `-copy` / `-copy-2` / … suffix walked past
|
|
19
|
+
* every slug string the collection already holds (base values AND every
|
|
20
|
+
* locale variant of every sibling). Rationale: a cleared slug lands the new
|
|
21
|
+
* draft in an error-looking validation state, while a uniquified one reads as
|
|
22
|
+
* what it is ("about-copy") and keeps the draft one review away from
|
|
23
|
+
* publishable. A missing/duplicate slug stays publish-gating only — this
|
|
24
|
+
* derivation never blocks a create.
|
|
25
|
+
* - Composition (`blocks`) field values copy VERBATIM (deep clone): node ids
|
|
26
|
+
* only need uniqueness within one composition value (`validateCompositionValue`
|
|
27
|
+
* checks duplicates per value; the builder's fresh-id counter re-derives from
|
|
28
|
+
* the open value via `maxNodeIdSeq`), so identical node ids across two
|
|
29
|
+
* documents collide nowhere.
|
|
30
|
+
* - References/relations copy as-is (the duplicate points at the same targets);
|
|
31
|
+
* the EC-277 in-transaction reference derivation indexes the new document on
|
|
32
|
+
* its first persisted write because it catches every writer.
|
|
33
|
+
*/
|
|
34
|
+
/** The conventional slug field name (mirrors `field-control`'s slug auto-upgrade). */
|
|
35
|
+
const SLUG_FIELD_NAME = 'slug';
|
|
36
|
+
/** The built-in control name that opts any `text` field into slug behaviour (EC-315). */
|
|
37
|
+
const SLUG_CONTROL = 'slug';
|
|
38
|
+
/** The EC-315 slug-field convention: a `text` field named `slug`, or any `text`
|
|
39
|
+
* field with `form.control: 'slug'` (same predicate as the studio slug control). */
|
|
40
|
+
function isSlugField(field) {
|
|
41
|
+
return (field.type === 'text' && (field.form?.control === SLUG_CONTROL || field.name === SLUG_FIELD_NAME));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Every slug string the collection already holds for `fieldName` — the base
|
|
45
|
+
* `values` slot AND every per-locale slot of every sibling (the original
|
|
46
|
+
* included). A union pool across locales is deliberately conservative: a
|
|
47
|
+
* candidate free in the pool can never collide with any sibling in any locale,
|
|
48
|
+
* including through the EC-018 base-value fallback.
|
|
49
|
+
*/
|
|
50
|
+
function slugPool(fieldName, siblings) {
|
|
51
|
+
const pool = new Set();
|
|
52
|
+
const add = (value) => {
|
|
53
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
54
|
+
pool.add(value.trim());
|
|
55
|
+
};
|
|
56
|
+
for (const doc of siblings) {
|
|
57
|
+
add(doc.values?.[fieldName]);
|
|
58
|
+
for (const localeValues of Object.values(doc.localized ?? {}))
|
|
59
|
+
add(localeValues[fieldName]);
|
|
60
|
+
}
|
|
61
|
+
return pool;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The deterministic duplicate slug: `<slug>-copy`, then `<slug>-copy-2`,
|
|
65
|
+
* `<slug>-copy-3`, … — the first candidate not in `taken`. Pure function of
|
|
66
|
+
* `(slug, taken)`, so the SAME original string in the base slot and a locale
|
|
67
|
+
* slot maps to the SAME uniquified string (the duplicate keeps the original's
|
|
68
|
+
* internal base/locale relationship).
|
|
69
|
+
*/
|
|
70
|
+
export function uniquifySlug(slug, taken) {
|
|
71
|
+
const base = `${slug}-copy`;
|
|
72
|
+
if (!taken.has(base))
|
|
73
|
+
return base;
|
|
74
|
+
let n = 2;
|
|
75
|
+
while (taken.has(`${base}-${n}`))
|
|
76
|
+
n += 1;
|
|
77
|
+
return `${base}-${n}`;
|
|
78
|
+
}
|
|
79
|
+
/** Uniquify one stored slug slot in place; empty/non-string slots stay untouched
|
|
80
|
+
* (a missing slug remains a publish-gating issue, never a create blocker). */
|
|
81
|
+
function uniquifySlot(values, fieldName, pool) {
|
|
82
|
+
if (!values)
|
|
83
|
+
return;
|
|
84
|
+
const raw = values[fieldName];
|
|
85
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
86
|
+
return;
|
|
87
|
+
values[fieldName] = uniquifySlug(raw.trim(), pool);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Derive the duplicate document (pure): deep-copied `values` + `localized` (all
|
|
91
|
+
* locales) + `defaultLocale` under the fresh id, `alias` dropped, every slug
|
|
92
|
+
* field's stored slots uniquified against the collection's slug pool. See the
|
|
93
|
+
* module doc for the ratified semantics each rule pins.
|
|
94
|
+
*/
|
|
95
|
+
export function deriveDuplicateDocument({ original, newId, schema, siblings, }) {
|
|
96
|
+
const duplicate = {
|
|
97
|
+
collection: original.collection,
|
|
98
|
+
id: newId,
|
|
99
|
+
// Deep clones: composition trees, rich text, nested objects/arrays must be
|
|
100
|
+
// independent of the original (editing one must never mutate the other).
|
|
101
|
+
values: structuredClone(original.values ?? {}),
|
|
102
|
+
...(original.localized !== undefined ? { localized: structuredClone(original.localized) } : {}),
|
|
103
|
+
...(original.defaultLocale !== undefined ? { defaultLocale: original.defaultLocale } : {}),
|
|
104
|
+
// `alias` deliberately dropped (unique slot handle — see module doc).
|
|
105
|
+
};
|
|
106
|
+
for (const field of schema.fields) {
|
|
107
|
+
if (!isSlugField(field))
|
|
108
|
+
continue;
|
|
109
|
+
const pool = slugPool(field.name, siblings);
|
|
110
|
+
uniquifySlot(duplicate.values, field.name, pool);
|
|
111
|
+
for (const localeValues of Object.values(duplicate.localized ?? {})) {
|
|
112
|
+
uniquifySlot(localeValues, field.name, pool);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return duplicate;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Duplicate `original` through a {@link CollectionViewSource} — the ONE dispatch
|
|
119
|
+
* both surfaces (list row menu, detail-editor header) share. Composes the seam's
|
|
120
|
+
* EXISTING write paths, never a parallel one (AD-17):
|
|
121
|
+
*
|
|
122
|
+
* 1. `source.create()` mints the fresh id + inserts the rail's blank record
|
|
123
|
+
* (the same collision-free mint the New button uses);
|
|
124
|
+
* 2. `source.save(derived)` lands the copied values under that id (the CMS
|
|
125
|
+
* save is a replace-or-append upsert; a rail save persists its draft).
|
|
126
|
+
*
|
|
127
|
+
* The session store applies both optimistically, so the new row is visible
|
|
128
|
+
* without a reload, and the persister mirrors them onto the operations facade
|
|
129
|
+
* with the user's session — no new trust boundary. Callers gate on the
|
|
130
|
+
* explicit `capabilities.duplicate` flag (the row-menu vocabulary / the header
|
|
131
|
+
* gate) before dispatching — CMS collections only (owner call 2026-07-17;
|
|
132
|
+
* every primitive rail declares `duplicate: false`).
|
|
133
|
+
*/
|
|
134
|
+
export function duplicateFromSource(source, original) {
|
|
135
|
+
// Snapshot the slug pool BEFORE create() inserts the blank record.
|
|
136
|
+
const siblings = source.documents();
|
|
137
|
+
const ref = source.create();
|
|
138
|
+
source.save(deriveDuplicateDocument({ original, newId: ref.id, schema: source.schema, siblings }));
|
|
139
|
+
return ref;
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=duplicate-document.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duplicate-document.js","sourceRoot":"","sources":["../../../src/components/cms/duplicate-document.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CmsDocument, CollectionDef, DocumentRef, FieldDef } from '@elytracms/core/cms-core'\nimport type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source'\n\n/**\n * EC-442 — the duplicate-document derivation (pure core) + the source-composed\n * dispatch behind the list row `⋮` menu's and the detail-editor header's\n * **Duplicate** action.\n *\n * Semantics (ratified 2026-07-17):\n * - The duplicate copies the current DRAFT field values — `values` plus\n * `localized` across ALL locales plus `defaultLocale` — into a fresh document\n * id. It is created as a **draft, never auto-published** (\"live is\n * published-only\"): published-ness is the version pointer (EC-224), and\n * nothing here touches it.\n * - `alias` is deliberately NOT copied: an alias is a unique-within-collection\n * slot handle (EC-284) — code addresses THE document holding it, and a copied\n * alias would immediately surface as uniqueness drift. The duplicate is new\n * content, not the slot holder.\n * - **Slug strategy: uniquify, never clear.** Every slug field (a `text` field\n * named `slug` or opted in via `form.control: 'slug'` — the EC-315\n * convention) gets a deterministic `-copy` / `-copy-2` / … suffix walked past\n * every slug string the collection already holds (base values AND every\n * locale variant of every sibling). Rationale: a cleared slug lands the new\n * draft in an error-looking validation state, while a uniquified one reads as\n * what it is (\"about-copy\") and keeps the draft one review away from\n * publishable. A missing/duplicate slug stays publish-gating only — this\n * derivation never blocks a create.\n * - Composition (`blocks`) field values copy VERBATIM (deep clone): node ids\n * only need uniqueness within one composition value (`validateCompositionValue`\n * checks duplicates per value; the builder's fresh-id counter re-derives from\n * the open value via `maxNodeIdSeq`), so identical node ids across two\n * documents collide nowhere.\n * - References/relations copy as-is (the duplicate points at the same targets);\n * the EC-277 in-transaction reference derivation indexes the new document on\n * its first persisted write because it catches every writer.\n */\n\n/** The conventional slug field name (mirrors `field-control`'s slug auto-upgrade). */\nconst SLUG_FIELD_NAME = 'slug'\n/** The built-in control name that opts any `text` field into slug behaviour (EC-315). */\nconst SLUG_CONTROL = 'slug'\n\n/** The EC-315 slug-field convention: a `text` field named `slug`, or any `text`\n * field with `form.control: 'slug'` (same predicate as the studio slug control). */\nfunction isSlugField(field: FieldDef): boolean {\n return (\n field.type === 'text' && (field.form?.control === SLUG_CONTROL || field.name === SLUG_FIELD_NAME)\n )\n}\n\n/**\n * Every slug string the collection already holds for `fieldName` — the base\n * `values` slot AND every per-locale slot of every sibling (the original\n * included). A union pool across locales is deliberately conservative: a\n * candidate free in the pool can never collide with any sibling in any locale,\n * including through the EC-018 base-value fallback.\n */\nfunction slugPool(fieldName: string, siblings: readonly CmsDocument[]): Set<string> {\n const pool = new Set<string>()\n const add = (value: unknown) => {\n if (typeof value === 'string' && value.trim() !== '') pool.add(value.trim())\n }\n for (const doc of siblings) {\n add(doc.values?.[fieldName])\n for (const localeValues of Object.values(doc.localized ?? {})) add(localeValues[fieldName])\n }\n return pool\n}\n\n/**\n * The deterministic duplicate slug: `<slug>-copy`, then `<slug>-copy-2`,\n * `<slug>-copy-3`, … — the first candidate not in `taken`. Pure function of\n * `(slug, taken)`, so the SAME original string in the base slot and a locale\n * slot maps to the SAME uniquified string (the duplicate keeps the original's\n * internal base/locale relationship).\n */\nexport function uniquifySlug(slug: string, taken: ReadonlySet<string>): string {\n const base = `${slug}-copy`\n if (!taken.has(base)) return base\n let n = 2\n while (taken.has(`${base}-${n}`)) n += 1\n return `${base}-${n}`\n}\n\n/** Uniquify one stored slug slot in place; empty/non-string slots stay untouched\n * (a missing slug remains a publish-gating issue, never a create blocker). */\nfunction uniquifySlot(\n values: Record<string, unknown> | undefined,\n fieldName: string,\n pool: ReadonlySet<string>,\n): void {\n if (!values) return\n const raw = values[fieldName]\n if (typeof raw !== 'string' || raw.trim() === '') return\n values[fieldName] = uniquifySlug(raw.trim(), pool)\n}\n\nexport interface DeriveDuplicateInput {\n /** The document being duplicated — the CURRENT draft values (list row / editor buffer). */\n original: CmsDocument\n /** The freshly minted id for the duplicate (source-owned, via `source.create()`). */\n newId: string\n /** The collection schema — identifies the slug fields to uniquify. */\n schema: CollectionDef\n /** The collection's current rows (the original included) — the slug pool. */\n siblings: readonly CmsDocument[]\n}\n\n/**\n * Derive the duplicate document (pure): deep-copied `values` + `localized` (all\n * locales) + `defaultLocale` under the fresh id, `alias` dropped, every slug\n * field's stored slots uniquified against the collection's slug pool. See the\n * module doc for the ratified semantics each rule pins.\n */\nexport function deriveDuplicateDocument({\n original,\n newId,\n schema,\n siblings,\n}: DeriveDuplicateInput): CmsDocument {\n const duplicate: CmsDocument = {\n collection: original.collection,\n id: newId,\n // Deep clones: composition trees, rich text, nested objects/arrays must be\n // independent of the original (editing one must never mutate the other).\n values: structuredClone(original.values ?? {}),\n ...(original.localized !== undefined ? { localized: structuredClone(original.localized) } : {}),\n ...(original.defaultLocale !== undefined ? { defaultLocale: original.defaultLocale } : {}),\n // `alias` deliberately dropped (unique slot handle — see module doc).\n }\n for (const field of schema.fields) {\n if (!isSlugField(field)) continue\n const pool = slugPool(field.name, siblings)\n uniquifySlot(duplicate.values, field.name, pool)\n for (const localeValues of Object.values(duplicate.localized ?? {})) {\n uniquifySlot(localeValues, field.name, pool)\n }\n }\n return duplicate\n}\n\n/**\n * Duplicate `original` through a {@link CollectionViewSource} — the ONE dispatch\n * both surfaces (list row menu, detail-editor header) share. Composes the seam's\n * EXISTING write paths, never a parallel one (AD-17):\n *\n * 1. `source.create()` mints the fresh id + inserts the rail's blank record\n * (the same collision-free mint the New button uses);\n * 2. `source.save(derived)` lands the copied values under that id (the CMS\n * save is a replace-or-append upsert; a rail save persists its draft).\n *\n * The session store applies both optimistically, so the new row is visible\n * without a reload, and the persister mirrors them onto the operations facade\n * with the user's session — no new trust boundary. Callers gate on the\n * explicit `capabilities.duplicate` flag (the row-menu vocabulary / the header\n * gate) before dispatching — CMS collections only (owner call 2026-07-17;\n * every primitive rail declares `duplicate: false`).\n */\nexport function duplicateFromSource(\n source: CollectionViewSource,\n original: CmsDocument,\n): DocumentRef {\n // Snapshot the slug pool BEFORE create() inserts the blank record.\n const siblings = source.documents()\n const ref = source.create()\n source.save(deriveDuplicateDocument({ original, newId: ref.id, schema: source.schema, siblings }))\n return ref\n}\n"]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CollectionDef, FieldDef } from '@elytracms/core/cms-core';
|
|
2
|
+
/**
|
|
3
|
+
* EC-443 — the pure core of path-aware jump-to-field.
|
|
4
|
+
*
|
|
5
|
+
* The anchor convention itself (`data-ec-field-path="<field>(/<token>)*"`) is
|
|
6
|
+
* defined ONCE, on {@link FIELD_PATH_ATTR} in `ui/field.tsx` — read its doc
|
|
7
|
+
* comment before touching anything here. This module carries the shared
|
|
8
|
+
* plumbing around it:
|
|
9
|
+
*
|
|
10
|
+
* - {@link FieldPathContext}: the current anchor-path prefix, threaded through
|
|
11
|
+
* the recursive object/repeater controls so each nested row knows its own
|
|
12
|
+
* full path without prop-drilling. `null` outside a stamped surface (e.g.
|
|
13
|
+
* the canvas prop inspector), where no anchors are stamped.
|
|
14
|
+
* - {@link FieldFocusRequestContext}: the active focus request (set by
|
|
15
|
+
* `useFieldFocus`), which drives BOTH collapsed-array auto-expansion en
|
|
16
|
+
* route and the ring highlight on the exact nested control.
|
|
17
|
+
* - The pure focus-plan decisions (`anchorCandidates`,
|
|
18
|
+
* `deepestExistingAnchor`, `isPathWithin`, `resolveFieldDefAtPath`) — no
|
|
19
|
+
* DOM, directly pinned in `use-field-focus.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
/** The anchor-path prefix for the controls below this point (see module doc). */
|
|
22
|
+
export declare const FieldPathContext: import("react").Context<string | null>;
|
|
23
|
+
export declare const useFieldPath: () => string | null;
|
|
24
|
+
/**
|
|
25
|
+
* The in-flight jump-to-field request. `path` is a full anchor path
|
|
26
|
+
* (`links/3/badge/text`); `nonce` distinguishes back-to-back jumps to the same
|
|
27
|
+
* path (a re-click must re-expand a manually collapsed item). Cleared when the
|
|
28
|
+
* highlight window ends.
|
|
29
|
+
*/
|
|
30
|
+
export interface FieldFocusRequest {
|
|
31
|
+
path: string;
|
|
32
|
+
nonce: number;
|
|
33
|
+
}
|
|
34
|
+
export declare const FieldFocusRequestContext: import("react").Context<FieldFocusRequest | null>;
|
|
35
|
+
export declare const useFieldFocusRequest: () => FieldFocusRequest | null;
|
|
36
|
+
/** CSS selector for one stamped anchor path. */
|
|
37
|
+
export declare function fieldAnchorSelector(path: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* The anchor lookup order for a parsed field path: every prefix, deepest
|
|
40
|
+
* first, down to the top-level field itself — the graceful-degradation ladder
|
|
41
|
+
* (`links/3/badge/text` → `links/3/badge` → `links/3` → `links`). The
|
|
42
|
+
* top-level field is additionally always reachable via its `#f-<name>`
|
|
43
|
+
* control id, which is the ultimate floor.
|
|
44
|
+
*/
|
|
45
|
+
export declare function anchorCandidates(field: string, rest: readonly string[]): string[];
|
|
46
|
+
/**
|
|
47
|
+
* The deepest candidate that exists right now (`exists` = a DOM probe in
|
|
48
|
+
* production, a fake in tests), or `null` when none does. Combined with
|
|
49
|
+
* {@link anchorCandidates} this IS the degradation decision: deepest existing
|
|
50
|
+
* anchor wins, the top-level field is the floor, never a no-op.
|
|
51
|
+
*/
|
|
52
|
+
export declare function deepestExistingAnchor(candidates: readonly string[], exists: (path: string) => boolean): string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Is `focusPath` at or below `itemPath`? Token-prefix semantics (`links/3`
|
|
55
|
+
* contains `links/3/badge/text` but NOT `links/31`) — the expansion decision a
|
|
56
|
+
* collapsed array item uses to auto-open on the way to a nested target.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isPathWithin(itemPath: string, focusPath: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Walk a parsed field path down the schema and return the DEEPEST resolvable
|
|
61
|
+
* field def: numeric tokens step into an array item (same def), name tokens
|
|
62
|
+
* descend into an `object` field's subfields. An unknown token stops the walk
|
|
63
|
+
* at the last resolved def (label degradation mirrors anchor degradation);
|
|
64
|
+
* an unknown top-level field is `null`. Rich-text node paths therefore resolve
|
|
65
|
+
* to the rich-text field itself.
|
|
66
|
+
*/
|
|
67
|
+
export declare function resolveFieldDefAtPath(collection: CollectionDef, field: string, rest: readonly string[]): FieldDef | null;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import { FIELD_PATH_ATTR } from '@elytracms/studio/ui';
|
|
3
|
+
/**
|
|
4
|
+
* EC-443 — the pure core of path-aware jump-to-field.
|
|
5
|
+
*
|
|
6
|
+
* The anchor convention itself (`data-ec-field-path="<field>(/<token>)*"`) is
|
|
7
|
+
* defined ONCE, on {@link FIELD_PATH_ATTR} in `ui/field.tsx` — read its doc
|
|
8
|
+
* comment before touching anything here. This module carries the shared
|
|
9
|
+
* plumbing around it:
|
|
10
|
+
*
|
|
11
|
+
* - {@link FieldPathContext}: the current anchor-path prefix, threaded through
|
|
12
|
+
* the recursive object/repeater controls so each nested row knows its own
|
|
13
|
+
* full path without prop-drilling. `null` outside a stamped surface (e.g.
|
|
14
|
+
* the canvas prop inspector), where no anchors are stamped.
|
|
15
|
+
* - {@link FieldFocusRequestContext}: the active focus request (set by
|
|
16
|
+
* `useFieldFocus`), which drives BOTH collapsed-array auto-expansion en
|
|
17
|
+
* route and the ring highlight on the exact nested control.
|
|
18
|
+
* - The pure focus-plan decisions (`anchorCandidates`,
|
|
19
|
+
* `deepestExistingAnchor`, `isPathWithin`, `resolveFieldDefAtPath`) — no
|
|
20
|
+
* DOM, directly pinned in `use-field-focus.test.ts`.
|
|
21
|
+
*/
|
|
22
|
+
// --- Contexts -----------------------------------------------------------------
|
|
23
|
+
/** The anchor-path prefix for the controls below this point (see module doc). */
|
|
24
|
+
export const FieldPathContext = createContext(null);
|
|
25
|
+
export const useFieldPath = () => useContext(FieldPathContext);
|
|
26
|
+
export const FieldFocusRequestContext = createContext(null);
|
|
27
|
+
export const useFieldFocusRequest = () => useContext(FieldFocusRequestContext);
|
|
28
|
+
// --- Pure focus-plan decisions ------------------------------------------------
|
|
29
|
+
/** CSS selector for one stamped anchor path. */
|
|
30
|
+
export function fieldAnchorSelector(path) {
|
|
31
|
+
return `[${FIELD_PATH_ATTR}="${path.replace(/"/g, '\\"')}"]`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The anchor lookup order for a parsed field path: every prefix, deepest
|
|
35
|
+
* first, down to the top-level field itself — the graceful-degradation ladder
|
|
36
|
+
* (`links/3/badge/text` → `links/3/badge` → `links/3` → `links`). The
|
|
37
|
+
* top-level field is additionally always reachable via its `#f-<name>`
|
|
38
|
+
* control id, which is the ultimate floor.
|
|
39
|
+
*/
|
|
40
|
+
export function anchorCandidates(field, rest) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (let depth = rest.length; depth >= 0; depth--) {
|
|
43
|
+
out.push([field, ...rest.slice(0, depth)].join('/'));
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The deepest candidate that exists right now (`exists` = a DOM probe in
|
|
49
|
+
* production, a fake in tests), or `null` when none does. Combined with
|
|
50
|
+
* {@link anchorCandidates} this IS the degradation decision: deepest existing
|
|
51
|
+
* anchor wins, the top-level field is the floor, never a no-op.
|
|
52
|
+
*/
|
|
53
|
+
export function deepestExistingAnchor(candidates, exists) {
|
|
54
|
+
for (const candidate of candidates)
|
|
55
|
+
if (exists(candidate))
|
|
56
|
+
return candidate;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Is `focusPath` at or below `itemPath`? Token-prefix semantics (`links/3`
|
|
61
|
+
* contains `links/3/badge/text` but NOT `links/31`) — the expansion decision a
|
|
62
|
+
* collapsed array item uses to auto-open on the way to a nested target.
|
|
63
|
+
*/
|
|
64
|
+
export function isPathWithin(itemPath, focusPath) {
|
|
65
|
+
return focusPath === itemPath || focusPath.startsWith(`${itemPath}/`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Walk a parsed field path down the schema and return the DEEPEST resolvable
|
|
69
|
+
* field def: numeric tokens step into an array item (same def), name tokens
|
|
70
|
+
* descend into an `object` field's subfields. An unknown token stops the walk
|
|
71
|
+
* at the last resolved def (label degradation mirrors anchor degradation);
|
|
72
|
+
* an unknown top-level field is `null`. Rich-text node paths therefore resolve
|
|
73
|
+
* to the rich-text field itself.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveFieldDefAtPath(collection, field, rest) {
|
|
76
|
+
let current = collection.fields.find((f) => f.name === field);
|
|
77
|
+
if (current === undefined)
|
|
78
|
+
return null;
|
|
79
|
+
for (const token of rest) {
|
|
80
|
+
if (/^\d+$/.test(token))
|
|
81
|
+
continue;
|
|
82
|
+
if (current.type !== 'object')
|
|
83
|
+
break;
|
|
84
|
+
const sub = current.fields.find((f) => f.name === token);
|
|
85
|
+
if (sub === undefined)
|
|
86
|
+
break;
|
|
87
|
+
current = sub;
|
|
88
|
+
}
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=field-anchors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"field-anchors.js","sourceRoot":"","sources":["../../../src/components/cms/field-anchors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,OAAO,CAAA;AAEjD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAEtD;;;;;;;;;;;;;;;;;;GAkBG;AAEH,iFAAiF;AAEjF,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,aAAa,CAAgB,IAAI,CAAC,CAAA;AAClE,MAAM,CAAC,MAAM,YAAY,GAAG,GAAkB,EAAE,CAAC,UAAU,CAAC,gBAAgB,CAAC,CAAA;AAY7E,MAAM,CAAC,MAAM,wBAAwB,GAAG,aAAa,CAA2B,IAAI,CAAC,CAAA;AACrF,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAA6B,EAAE,CACjE,UAAU,CAAC,wBAAwB,CAAC,CAAA;AAEtC,iFAAiF;AAEjF,gDAAgD;AAChD,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,OAAO,IAAI,eAAe,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAA;AAC9D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,IAAuB;IACrE,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,KAAK,IAAI,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC;QAClD,GAAG,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IACtD,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAA6B,EAC7B,MAAiC;IAEjC,KAAK,MAAM,SAAS,IAAI,UAAU;QAAE,IAAI,MAAM,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAA;IAC3E,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,SAAiB;IAC9D,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,CAAC,UAAU,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAA;AACvE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAAyB,EACzB,KAAa,EACb,IAAuB;IAEvB,IAAI,OAAO,GAAyB,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAA;IACnF,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,IAAI,CAAA;IACtC,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;QACzB,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,SAAQ;QACjC,IAAI,OAAO,CAAC,IAAI,KAAK,QAAQ;YAAE,MAAK;QACpC,MAAM,GAAG,GAAyB,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAA;QAC9E,IAAI,GAAG,KAAK,SAAS;YAAE,MAAK;QAC5B,OAAO,GAAG,GAAG,CAAA;IACf,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC","sourcesContent":["import { createContext, useContext } from 'react'\nimport type { CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport { FIELD_PATH_ATTR } from '@elytracms/studio/ui'\n\n/**\n * EC-443 — the pure core of path-aware jump-to-field.\n *\n * The anchor convention itself (`data-ec-field-path=\"<field>(/<token>)*\"`) is\n * defined ONCE, on {@link FIELD_PATH_ATTR} in `ui/field.tsx` — read its doc\n * comment before touching anything here. This module carries the shared\n * plumbing around it:\n *\n * - {@link FieldPathContext}: the current anchor-path prefix, threaded through\n * the recursive object/repeater controls so each nested row knows its own\n * full path without prop-drilling. `null` outside a stamped surface (e.g.\n * the canvas prop inspector), where no anchors are stamped.\n * - {@link FieldFocusRequestContext}: the active focus request (set by\n * `useFieldFocus`), which drives BOTH collapsed-array auto-expansion en\n * route and the ring highlight on the exact nested control.\n * - The pure focus-plan decisions (`anchorCandidates`,\n * `deepestExistingAnchor`, `isPathWithin`, `resolveFieldDefAtPath`) — no\n * DOM, directly pinned in `use-field-focus.test.ts`.\n */\n\n// --- Contexts -----------------------------------------------------------------\n\n/** The anchor-path prefix for the controls below this point (see module doc). */\nexport const FieldPathContext = createContext<string | null>(null)\nexport const useFieldPath = (): string | null => useContext(FieldPathContext)\n\n/**\n * The in-flight jump-to-field request. `path` is a full anchor path\n * (`links/3/badge/text`); `nonce` distinguishes back-to-back jumps to the same\n * path (a re-click must re-expand a manually collapsed item). Cleared when the\n * highlight window ends.\n */\nexport interface FieldFocusRequest {\n path: string\n nonce: number\n}\nexport const FieldFocusRequestContext = createContext<FieldFocusRequest | null>(null)\nexport const useFieldFocusRequest = (): FieldFocusRequest | null =>\n useContext(FieldFocusRequestContext)\n\n// --- Pure focus-plan decisions ------------------------------------------------\n\n/** CSS selector for one stamped anchor path. */\nexport function fieldAnchorSelector(path: string): string {\n return `[${FIELD_PATH_ATTR}=\"${path.replace(/\"/g, '\\\\\"')}\"]`\n}\n\n/**\n * The anchor lookup order for a parsed field path: every prefix, deepest\n * first, down to the top-level field itself — the graceful-degradation ladder\n * (`links/3/badge/text` → `links/3/badge` → `links/3` → `links`). The\n * top-level field is additionally always reachable via its `#f-<name>`\n * control id, which is the ultimate floor.\n */\nexport function anchorCandidates(field: string, rest: readonly string[]): string[] {\n const out: string[] = []\n for (let depth = rest.length; depth >= 0; depth--) {\n out.push([field, ...rest.slice(0, depth)].join('/'))\n }\n return out\n}\n\n/**\n * The deepest candidate that exists right now (`exists` = a DOM probe in\n * production, a fake in tests), or `null` when none does. Combined with\n * {@link anchorCandidates} this IS the degradation decision: deepest existing\n * anchor wins, the top-level field is the floor, never a no-op.\n */\nexport function deepestExistingAnchor(\n candidates: readonly string[],\n exists: (path: string) => boolean,\n): string | null {\n for (const candidate of candidates) if (exists(candidate)) return candidate\n return null\n}\n\n/**\n * Is `focusPath` at or below `itemPath`? Token-prefix semantics (`links/3`\n * contains `links/3/badge/text` but NOT `links/31`) — the expansion decision a\n * collapsed array item uses to auto-open on the way to a nested target.\n */\nexport function isPathWithin(itemPath: string, focusPath: string): boolean {\n return focusPath === itemPath || focusPath.startsWith(`${itemPath}/`)\n}\n\n/**\n * Walk a parsed field path down the schema and return the DEEPEST resolvable\n * field def: numeric tokens step into an array item (same def), name tokens\n * descend into an `object` field's subfields. An unknown token stops the walk\n * at the last resolved def (label degradation mirrors anchor degradation);\n * an unknown top-level field is `null`. Rich-text node paths therefore resolve\n * to the rich-text field itself.\n */\nexport function resolveFieldDefAtPath(\n collection: CollectionDef,\n field: string,\n rest: readonly string[],\n): FieldDef | null {\n let current: FieldDef | undefined = collection.fields.find((f) => f.name === field)\n if (current === undefined) return null\n for (const token of rest) {\n if (/^\\d+$/.test(token)) continue\n if (current.type !== 'object') break\n const sub: FieldDef | undefined = current.fields.find((f) => f.name === token)\n if (sub === undefined) break\n current = sub\n }\n return current\n}\n"]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { CmsValidationIssue, CollectionDef, FieldDef } from '@elytracms/core/cms-core';
|
|
2
|
+
/**
|
|
3
|
+
* EC-443 — the pure copy/jump decisions behind the Issues pane. No React, no
|
|
4
|
+
* catalog access: {@link issueJumpTarget} turns a structured issue into the
|
|
5
|
+
* anchor path + deepest resolvable field def the jump link needs, and
|
|
6
|
+
* {@link issueBodyDescriptor} maps an issue code to a localized body template
|
|
7
|
+
* (catalog key + params) where the full body is derivable from `code` +
|
|
8
|
+
* `path`/`meta` alone. Codes whose core message carries underivable detail
|
|
9
|
+
* (e.g. `invalid-field-value`'s per-check phrasing, `custom-validation`'s
|
|
10
|
+
* config-authored text) return `null` — the pane then shows the raw English
|
|
11
|
+
* core message, an HONEST fallback, never a blank.
|
|
12
|
+
*/
|
|
13
|
+
/** Where an issue's jump link lands + what it is called. */
|
|
14
|
+
export interface IssueJumpTarget {
|
|
15
|
+
/** The `FIELD_PATH_ATTR` anchor path (`links/3/badge/text`), locale stripped. */
|
|
16
|
+
path: string;
|
|
17
|
+
/** The content locale the issue belongs to (`localized/<locale>/…` paths). */
|
|
18
|
+
locale?: string;
|
|
19
|
+
/** The deepest field def resolvable along the path (label source), if any. */
|
|
20
|
+
fieldDef: FieldDef | null;
|
|
21
|
+
/** The last named path token — the label fallback when no def resolves. */
|
|
22
|
+
leaf: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Derive the jump target from an issue's structured `path` (the full document
|
|
26
|
+
* index path — `['documents', <key>, 'values'|'localized', …]` from
|
|
27
|
+
* `validateDocument`, or a bare `[<field>]` from studio-built issues like the
|
|
28
|
+
* EC-300 embed errors). The FULL path wins (so a nested issue lands on the
|
|
29
|
+
* exact control — `meta.field` carries only the LEAF name and must never be
|
|
30
|
+
* mistaken for a top-level field); `meta.field` is the fallback for bare
|
|
31
|
+
* paths, and only when it names a real top-level field. `null` → no jump link
|
|
32
|
+
* (document-scoped issues).
|
|
33
|
+
*/
|
|
34
|
+
export declare function issueJumpTarget(collection: CollectionDef, issue: CmsValidationIssue): IssueJumpTarget | null;
|
|
35
|
+
/** A localized issue body: the catalog key + its substitution params. */
|
|
36
|
+
export interface IssueBodyDescriptor {
|
|
37
|
+
key: string;
|
|
38
|
+
vars: Record<string, string | number>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The code → body-template mapping (see module doc for the fallback contract).
|
|
42
|
+
* `fieldLabelText` is the resolved (localized) label of the deepest field along
|
|
43
|
+
* the issue path — supplied by the caller so this stays catalog-free and pure.
|
|
44
|
+
*/
|
|
45
|
+
export declare function issueBodyDescriptor(issue: CmsValidationIssue, fieldLabelText: string): IssueBodyDescriptor | null;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { parseDocumentFieldPath } from '@elytracms/studio/lib/references';
|
|
2
|
+
import { resolveFieldDefAtPath } from './field-anchors.js';
|
|
3
|
+
/** The last non-index token of a path (`links/3/badge/text` → `text`). */
|
|
4
|
+
function lastNameToken(tokens) {
|
|
5
|
+
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
6
|
+
const token = tokens[i];
|
|
7
|
+
if (!/^\d+$/.test(token))
|
|
8
|
+
return token;
|
|
9
|
+
}
|
|
10
|
+
return tokens[tokens.length - 1] ?? '';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Derive the jump target from an issue's structured `path` (the full document
|
|
14
|
+
* index path — `['documents', <key>, 'values'|'localized', …]` from
|
|
15
|
+
* `validateDocument`, or a bare `[<field>]` from studio-built issues like the
|
|
16
|
+
* EC-300 embed errors). The FULL path wins (so a nested issue lands on the
|
|
17
|
+
* exact control — `meta.field` carries only the LEAF name and must never be
|
|
18
|
+
* mistaken for a top-level field); `meta.field` is the fallback for bare
|
|
19
|
+
* paths, and only when it names a real top-level field. `null` → no jump link
|
|
20
|
+
* (document-scoped issues).
|
|
21
|
+
*/
|
|
22
|
+
export function issueJumpTarget(collection, issue) {
|
|
23
|
+
const tokens = issue.path.map(String);
|
|
24
|
+
const relative = tokens[0] === 'documents' ? tokens.slice(2) : tokens;
|
|
25
|
+
const parsed = parseDocumentFieldPath(relative.join('/'));
|
|
26
|
+
if (parsed && collection.fields.some((f) => f.name === parsed.field)) {
|
|
27
|
+
const pathTokens = [parsed.field, ...parsed.rest];
|
|
28
|
+
return {
|
|
29
|
+
path: pathTokens.join('/'),
|
|
30
|
+
...(parsed.locale !== undefined ? { locale: parsed.locale } : {}),
|
|
31
|
+
fieldDef: resolveFieldDefAtPath(collection, parsed.field, parsed.rest),
|
|
32
|
+
leaf: lastNameToken(pathTokens),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const metaField = typeof issue.meta?.field === 'string' ? issue.meta.field : undefined;
|
|
36
|
+
const topLevel = metaField ? collection.fields.find((f) => f.name === metaField) : undefined;
|
|
37
|
+
if (topLevel) {
|
|
38
|
+
return { path: topLevel.name, fieldDef: topLevel, leaf: topLevel.name };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The code → body-template mapping (see module doc for the fallback contract).
|
|
44
|
+
* `fieldLabelText` is the resolved (localized) label of the deepest field along
|
|
45
|
+
* the issue path — supplied by the caller so this stays catalog-free and pure.
|
|
46
|
+
*/
|
|
47
|
+
export function issueBodyDescriptor(issue, fieldLabelText) {
|
|
48
|
+
switch (issue.code) {
|
|
49
|
+
case 'missing-required-field':
|
|
50
|
+
return { key: 'cms.issueBody.missing-required-field', vars: { field: fieldLabelText } };
|
|
51
|
+
case 'missing-localized-value': {
|
|
52
|
+
const locale = typeof issue.meta?.locale === 'string' ? issue.meta.locale : undefined;
|
|
53
|
+
if (locale === undefined)
|
|
54
|
+
return null;
|
|
55
|
+
return issue.severity === 'error'
|
|
56
|
+
? {
|
|
57
|
+
key: 'cms.issueBody.missing-localized-value.default',
|
|
58
|
+
vars: { field: fieldLabelText, locale },
|
|
59
|
+
}
|
|
60
|
+
: {
|
|
61
|
+
key: 'cms.issueBody.missing-localized-value.fallback',
|
|
62
|
+
vars: { field: fieldLabelText, locale },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
case 'unknown-asset': {
|
|
66
|
+
const asset = typeof issue.meta?.asset === 'string' ? issue.meta.asset : undefined;
|
|
67
|
+
if (asset === undefined)
|
|
68
|
+
return null;
|
|
69
|
+
return { key: 'cms.issueBody.unknown-asset', vars: { field: fieldLabelText, asset } };
|
|
70
|
+
}
|
|
71
|
+
case 'document-nesting-depth': {
|
|
72
|
+
const depth = typeof issue.meta?.depth === 'number' ? issue.meta.depth : undefined;
|
|
73
|
+
const limit = typeof issue.meta?.limit === 'number' ? issue.meta.limit : undefined;
|
|
74
|
+
if (depth === undefined || limit === undefined)
|
|
75
|
+
return null;
|
|
76
|
+
return {
|
|
77
|
+
key: issue.severity === 'error'
|
|
78
|
+
? 'cms.issueBody.document-nesting-depth.error'
|
|
79
|
+
: 'cms.issueBody.document-nesting-depth.warning',
|
|
80
|
+
vars: { depth, limit },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
case 'hierarchy-cycle':
|
|
84
|
+
return { key: 'cms.issueBody.hierarchy-cycle', vars: {} };
|
|
85
|
+
default:
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=issue-copy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"issue-copy.js","sourceRoot":"","sources":["../../../src/components/cms/issue-copy.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CmsValidationIssue, CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport { parseDocumentFieldPath } from '@elytracms/studio/lib/references'\nimport { resolveFieldDefAtPath } from './field-anchors'\n\n/**\n * EC-443 — the pure copy/jump decisions behind the Issues pane. No React, no\n * catalog access: {@link issueJumpTarget} turns a structured issue into the\n * anchor path + deepest resolvable field def the jump link needs, and\n * {@link issueBodyDescriptor} maps an issue code to a localized body template\n * (catalog key + params) where the full body is derivable from `code` +\n * `path`/`meta` alone. Codes whose core message carries underivable detail\n * (e.g. `invalid-field-value`'s per-check phrasing, `custom-validation`'s\n * config-authored text) return `null` — the pane then shows the raw English\n * core message, an HONEST fallback, never a blank.\n */\n\n/** Where an issue's jump link lands + what it is called. */\nexport interface IssueJumpTarget {\n /** The `FIELD_PATH_ATTR` anchor path (`links/3/badge/text`), locale stripped. */\n path: string\n /** The content locale the issue belongs to (`localized/<locale>/…` paths). */\n locale?: string\n /** The deepest field def resolvable along the path (label source), if any. */\n fieldDef: FieldDef | null\n /** The last named path token — the label fallback when no def resolves. */\n leaf: string\n}\n\n/** The last non-index token of a path (`links/3/badge/text` → `text`). */\nfunction lastNameToken(tokens: readonly string[]): string {\n for (let i = tokens.length - 1; i >= 0; i--) {\n const token = tokens[i]!\n if (!/^\\d+$/.test(token)) return token\n }\n return tokens[tokens.length - 1] ?? ''\n}\n\n/**\n * Derive the jump target from an issue's structured `path` (the full document\n * index path — `['documents', <key>, 'values'|'localized', …]` from\n * `validateDocument`, or a bare `[<field>]` from studio-built issues like the\n * EC-300 embed errors). The FULL path wins (so a nested issue lands on the\n * exact control — `meta.field` carries only the LEAF name and must never be\n * mistaken for a top-level field); `meta.field` is the fallback for bare\n * paths, and only when it names a real top-level field. `null` → no jump link\n * (document-scoped issues).\n */\nexport function issueJumpTarget(\n collection: CollectionDef,\n issue: CmsValidationIssue,\n): IssueJumpTarget | null {\n const tokens = issue.path.map(String)\n const relative = tokens[0] === 'documents' ? tokens.slice(2) : tokens\n const parsed = parseDocumentFieldPath(relative.join('/'))\n if (parsed && collection.fields.some((f) => f.name === parsed.field)) {\n const pathTokens = [parsed.field, ...parsed.rest]\n return {\n path: pathTokens.join('/'),\n ...(parsed.locale !== undefined ? { locale: parsed.locale } : {}),\n fieldDef: resolveFieldDefAtPath(collection, parsed.field, parsed.rest),\n leaf: lastNameToken(pathTokens),\n }\n }\n const metaField = typeof issue.meta?.field === 'string' ? issue.meta.field : undefined\n const topLevel = metaField ? collection.fields.find((f) => f.name === metaField) : undefined\n if (topLevel) {\n return { path: topLevel.name, fieldDef: topLevel, leaf: topLevel.name }\n }\n return null\n}\n\n/** A localized issue body: the catalog key + its substitution params. */\nexport interface IssueBodyDescriptor {\n key: string\n vars: Record<string, string | number>\n}\n\n/**\n * The code → body-template mapping (see module doc for the fallback contract).\n * `fieldLabelText` is the resolved (localized) label of the deepest field along\n * the issue path — supplied by the caller so this stays catalog-free and pure.\n */\nexport function issueBodyDescriptor(\n issue: CmsValidationIssue,\n fieldLabelText: string,\n): IssueBodyDescriptor | null {\n switch (issue.code) {\n case 'missing-required-field':\n return { key: 'cms.issueBody.missing-required-field', vars: { field: fieldLabelText } }\n case 'missing-localized-value': {\n const locale = typeof issue.meta?.locale === 'string' ? issue.meta.locale : undefined\n if (locale === undefined) return null\n return issue.severity === 'error'\n ? {\n key: 'cms.issueBody.missing-localized-value.default',\n vars: { field: fieldLabelText, locale },\n }\n : {\n key: 'cms.issueBody.missing-localized-value.fallback',\n vars: { field: fieldLabelText, locale },\n }\n }\n case 'unknown-asset': {\n const asset = typeof issue.meta?.asset === 'string' ? issue.meta.asset : undefined\n if (asset === undefined) return null\n return { key: 'cms.issueBody.unknown-asset', vars: { field: fieldLabelText, asset } }\n }\n case 'document-nesting-depth': {\n const depth = typeof issue.meta?.depth === 'number' ? issue.meta.depth : undefined\n const limit = typeof issue.meta?.limit === 'number' ? issue.meta.limit : undefined\n if (depth === undefined || limit === undefined) return null\n return {\n key:\n issue.severity === 'error'\n ? 'cms.issueBody.document-nesting-depth.error'\n : 'cms.issueBody.document-nesting-depth.warning',\n vars: { depth, limit },\n }\n }\n case 'hierarchy-cycle':\n return { key: 'cms.issueBody.hierarchy-cycle', vars: {} }\n default:\n return null\n }\n}\n"]}
|
|
@@ -6,14 +6,23 @@ import type { CmsValidationIssue, CollectionDef } from '@elytracms/core/cms-core
|
|
|
6
6
|
* `InspectorSidebar` (the document-scoped equivalent of the amputated builder's
|
|
7
7
|
* validation sidebar, EC-069). Field-level issues stay inline on the field too;
|
|
8
8
|
* here they additionally list with a jump-to-field action. Document-level issues
|
|
9
|
-
* that carry no
|
|
9
|
+
* that carry no field path (e.g. the EC-260 nesting-depth warning) have no
|
|
10
10
|
* other home — this is theirs.
|
|
11
11
|
*
|
|
12
|
+
* EC-443: the jump carries the issue's FULL parsed path (`issueJumpTarget`), so
|
|
13
|
+
* a nested finding (`links/3/badge/text`) lands on the exact control — the link
|
|
14
|
+
* names the deepest resolvable field's localized label, never a raw leaf token
|
|
15
|
+
* when a def resolves. Card copy is localized studio-side: the title via
|
|
16
|
+
* `cmsIssueLabel` (catalog-routed), the body via the `issueBodyDescriptor`
|
|
17
|
+
* template where one exists, else the raw (English) core message — an honest
|
|
18
|
+
* fallback, never a blank. Core validation messages stay English.
|
|
19
|
+
*
|
|
12
20
|
* Persona boundary (EC-192): rows show the plain-language `cmsIssueLabel`; the raw
|
|
13
21
|
* `code` rides along in `title=` for engineers, never as visible copy.
|
|
14
22
|
*/
|
|
15
23
|
export declare function IssuesPane({ issues, collection, onJumpToField, }: {
|
|
16
24
|
issues: readonly CmsValidationIssue[];
|
|
17
25
|
collection: CollectionDef;
|
|
18
|
-
|
|
26
|
+
/** Receives the EC-443 anchor path (`links/3/badge/text`) + the issue's locale. */
|
|
27
|
+
onJumpToField: (fieldPath: string, locale?: string) => void;
|
|
19
28
|
}): import("react").JSX.Element;
|
|
@@ -5,6 +5,7 @@ import { cn } from '@elytracms/studio/lib/utils';
|
|
|
5
5
|
import { cmsIssueLabel } from '@elytracms/studio/lib/persona/labels';
|
|
6
6
|
import { translate } from '@elytracms/studio/lib/i18n';
|
|
7
7
|
import { fieldLabel } from './cms-labels.js';
|
|
8
|
+
import { issueBodyDescriptor, issueJumpTarget } from './issue-copy.js';
|
|
8
9
|
/**
|
|
9
10
|
* EC-283 — the document-scoped validation surface.
|
|
10
11
|
*
|
|
@@ -12,9 +13,17 @@ import { fieldLabel } from './cms-labels.js';
|
|
|
12
13
|
* `InspectorSidebar` (the document-scoped equivalent of the amputated builder's
|
|
13
14
|
* validation sidebar, EC-069). Field-level issues stay inline on the field too;
|
|
14
15
|
* here they additionally list with a jump-to-field action. Document-level issues
|
|
15
|
-
* that carry no
|
|
16
|
+
* that carry no field path (e.g. the EC-260 nesting-depth warning) have no
|
|
16
17
|
* other home — this is theirs.
|
|
17
18
|
*
|
|
19
|
+
* EC-443: the jump carries the issue's FULL parsed path (`issueJumpTarget`), so
|
|
20
|
+
* a nested finding (`links/3/badge/text`) lands on the exact control — the link
|
|
21
|
+
* names the deepest resolvable field's localized label, never a raw leaf token
|
|
22
|
+
* when a def resolves. Card copy is localized studio-side: the title via
|
|
23
|
+
* `cmsIssueLabel` (catalog-routed), the body via the `issueBodyDescriptor`
|
|
24
|
+
* template where one exists, else the raw (English) core message — an honest
|
|
25
|
+
* fallback, never a blank. Core validation messages stay English.
|
|
26
|
+
*
|
|
18
27
|
* Persona boundary (EC-192): rows show the plain-language `cmsIssueLabel`; the raw
|
|
19
28
|
* `code` rides along in `title=` for engineers, never as visible copy.
|
|
20
29
|
*/
|
|
@@ -26,9 +35,18 @@ export function IssuesPane({ issues, collection, onJumpToField, }) {
|
|
|
26
35
|
}
|
|
27
36
|
function IssueRow({ issue, collection, onJumpToField, }) {
|
|
28
37
|
const isError = issue.severity === 'error';
|
|
29
|
-
const
|
|
30
|
-
|
|
38
|
+
const jump = issueJumpTarget(collection, issue);
|
|
39
|
+
// The (localized) name of the offending field: the deepest resolvable def's
|
|
40
|
+
// `form.label`, degrading to the leaf path token / `meta.field` raw name.
|
|
41
|
+
const fieldName = jump !== null
|
|
42
|
+
? jump.fieldDef !== null
|
|
43
|
+
? fieldLabel(jump.fieldDef)
|
|
44
|
+
: jump.leaf
|
|
45
|
+
: typeof issue.meta?.field === 'string'
|
|
46
|
+
? issue.meta.field
|
|
47
|
+
: '';
|
|
48
|
+
const body = issueBodyDescriptor(issue, fieldName);
|
|
31
49
|
const Icon = isError ? CircleAlert : TriangleAlert;
|
|
32
|
-
return (_jsxs(Stack, { gap: "1", className: cn('rounded-md border px-2.5 py-2', isError ? 'border-destructive/30 bg-destructive/5' : 'border-warning/30 bg-warning/5'), children: [_jsxs(Row, { gap: "2", align: "start", children: [_jsx(Icon, { className: cn('mt-0.5 size-3.5 shrink-0', isError ? 'text-destructive' : 'text-warning'), "aria-hidden": true }), _jsxs(Stack, { gap: "1", className: "min-w-0", children: [_jsx(Text, { size: "xs", weight: "semibold", title: issue.code, children: cmsIssueLabel(issue.code) }), _jsx(Text, { size: "xs", tone: "muted", children: issue.message })] })] }),
|
|
50
|
+
return (_jsxs(Stack, { gap: "1", className: cn('rounded-md border px-2.5 py-2', isError ? 'border-destructive/30 bg-destructive/5' : 'border-warning/30 bg-warning/5'), children: [_jsxs(Row, { gap: "2", align: "start", children: [_jsx(Icon, { className: cn('mt-0.5 size-3.5 shrink-0', isError ? 'text-destructive' : 'text-warning'), "aria-hidden": true }), _jsxs(Stack, { gap: "1", className: "min-w-0", children: [_jsx(Text, { size: "xs", weight: "semibold", title: issue.code, children: cmsIssueLabel(issue.code) }), _jsx(Text, { size: "xs", tone: "muted", children: body !== null ? translate(body.key, body.vars) : issue.message })] })] }), jump !== null ? (_jsx(Row, { className: "pl-[1.375rem]", children: _jsx(Button, { size: "sm", variant: "link", className: "h-auto p-0 text-xs", onClick: () => onJumpToField(jump.path, jump.locale), children: translate('cms.issues.goToField', { field: fieldName }) }) })) : null] }));
|
|
33
51
|
}
|
|
34
52
|
//# sourceMappingURL=issues-pane.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"issues-pane.js","sourceRoot":"","sources":["../../../src/components/cms/issues-pane.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAEtE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC/D,OAAO,EAAE,EAAE,EAAE,MAAM,6BAA6B,CAAA;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,sCAAsC,CAAA;AACpE,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"issues-pane.js","sourceRoot":"","sources":["../../../src/components/cms/issues-pane.tsx"],"names":[],"mappings":"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","sourcesContent":["import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react'\nimport type { CmsValidationIssue, CollectionDef } from '@elytracms/core/cms-core'\nimport { Button, Row, Stack, Text } from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { cmsIssueLabel } from '@elytracms/studio/lib/persona/labels'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { fieldLabel } from './cms-labels'\nimport { issueBodyDescriptor, issueJumpTarget } from './issue-copy'\n\n/**\n * EC-283 — the document-scoped validation surface.\n *\n * Lists every {@link CmsValidationIssue} for the open draft in the right\n * `InspectorSidebar` (the document-scoped equivalent of the amputated builder's\n * validation sidebar, EC-069). Field-level issues stay inline on the field too;\n * here they additionally list with a jump-to-field action. Document-level issues\n * that carry no field path (e.g. the EC-260 nesting-depth warning) have no\n * other home — this is theirs.\n *\n * EC-443: the jump carries the issue's FULL parsed path (`issueJumpTarget`), so\n * a nested finding (`links/3/badge/text`) lands on the exact control — the link\n * names the deepest resolvable field's localized label, never a raw leaf token\n * when a def resolves. Card copy is localized studio-side: the title via\n * `cmsIssueLabel` (catalog-routed), the body via the `issueBodyDescriptor`\n * template where one exists, else the raw (English) core message — an honest\n * fallback, never a blank. Core validation messages stay English.\n *\n * Persona boundary (EC-192): rows show the plain-language `cmsIssueLabel`; the raw\n * `code` rides along in `title=` for engineers, never as visible copy.\n */\nexport function IssuesPane({\n issues,\n collection,\n onJumpToField,\n}: {\n issues: readonly CmsValidationIssue[]\n collection: CollectionDef\n /** Receives the EC-443 anchor path (`links/3/badge/text`) + the issue's locale. */\n onJumpToField: (fieldPath: string, locale?: string) => void\n}) {\n if (issues.length === 0) {\n return (\n <Stack gap=\"2\" align=\"center\" className=\"px-4 py-10 text-center\">\n <CircleCheck className=\"size-5 text-success\" aria-hidden />\n <Text size=\"sm\" weight=\"medium\">\n {translate('cms.issues.none')}\n </Text>\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.issues.satisfied')}\n </Text>\n </Stack>\n )\n }\n\n return (\n <Stack gap=\"1\" className=\"p-2\">\n {issues.map((issue, i) => (\n <IssueRow\n key={`${issue.code}:${issue.path.join('/')}:${i}`}\n issue={issue}\n collection={collection}\n onJumpToField={onJumpToField}\n />\n ))}\n </Stack>\n )\n}\n\nfunction IssueRow({\n issue,\n collection,\n onJumpToField,\n}: {\n issue: CmsValidationIssue\n collection: CollectionDef\n onJumpToField: (fieldPath: string, locale?: string) => void\n}) {\n const isError = issue.severity === 'error'\n const jump = issueJumpTarget(collection, issue)\n // The (localized) name of the offending field: the deepest resolvable def's\n // `form.label`, degrading to the leaf path token / `meta.field` raw name.\n const fieldName =\n jump !== null\n ? jump.fieldDef !== null\n ? fieldLabel(jump.fieldDef)\n : jump.leaf\n : typeof issue.meta?.field === 'string'\n ? issue.meta.field\n : ''\n const body = issueBodyDescriptor(issue, fieldName)\n const Icon = isError ? CircleAlert : TriangleAlert\n\n return (\n <Stack\n gap=\"1\"\n className={cn(\n 'rounded-md border px-2.5 py-2',\n isError ? 'border-destructive/30 bg-destructive/5' : 'border-warning/30 bg-warning/5',\n )}\n >\n <Row gap=\"2\" align=\"start\">\n <Icon\n className={cn('mt-0.5 size-3.5 shrink-0', isError ? 'text-destructive' : 'text-warning')}\n aria-hidden\n />\n <Stack gap=\"1\" className=\"min-w-0\">\n <Text size=\"xs\" weight=\"semibold\" title={issue.code}>\n {cmsIssueLabel(issue.code)}\n </Text>\n <Text size=\"xs\" tone=\"muted\">\n {body !== null ? translate(body.key, body.vars) : issue.message}\n </Text>\n </Stack>\n </Row>\n {jump !== null ? (\n <Row className=\"pl-[1.375rem]\">\n <Button\n size=\"sm\"\n variant=\"link\"\n className=\"h-auto p-0 text-xs\"\n onClick={() => onJumpToField(jump.path, jump.locale)}\n >\n {translate('cms.issues.goToField', { field: fieldName })}\n </Button>\n </Row>\n ) : null}\n </Stack>\n )\n}\n"]}
|
|
@@ -3,6 +3,8 @@ import type { FieldDef, ObjectFieldDef } from '@elytracms/core/cms-core';
|
|
|
3
3
|
* The nested subfield form for one object item (EC-253). Reuses {@link FieldControl}
|
|
4
4
|
* recursively, so any subfield type — including a nested object/repeater — works for
|
|
5
5
|
* free. Writing a subfield merges it back into the item and bubbles the new item up.
|
|
6
|
+
* EC-443: each subfield row stamps its path anchor (`<itemPath>/<sub>`), rings when
|
|
7
|
+
* it is the exact focus target, and provides its path down for deeper nesting.
|
|
6
8
|
*/
|
|
7
9
|
export declare function ObjectItemForm({ fields, value, onChange, readOnly, idPrefix, }: {
|
|
8
10
|
fields: readonly FieldDef[];
|