@forumone/throughline-design-system 0.0.0 → 1.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/bin/check-block-props.mjs +51 -0
- package/bin/stub-styles.mjs +28 -0
- package/dist/admin/BlockGuidance.d.ts +13 -0
- package/dist/admin/BlockGuidance.d.ts.map +1 -0
- package/dist/admin/BlockGuidance.js +13 -0
- package/dist/admin/BlockGuidance.js.map +1 -0
- package/dist/admin/BlockSummary.d.ts +10 -0
- package/dist/admin/BlockSummary.d.ts.map +1 -0
- package/dist/admin/BlockSummary.js +73 -0
- package/dist/admin/BlockSummary.js.map +1 -0
- package/dist/admin/RowSummary.d.ts +15 -0
- package/dist/admin/RowSummary.d.ts.map +1 -0
- package/dist/admin/RowSummary.js +16 -0
- package/dist/admin/RowSummary.js.map +1 -0
- package/dist/admin/summary.d.ts +18 -0
- package/dist/admin/summary.d.ts.map +1 -0
- package/dist/admin/summary.js +48 -0
- package/dist/admin/summary.js.map +1 -0
- package/dist/altText.d.ts +16 -0
- package/dist/altText.d.ts.map +1 -0
- package/dist/altText.js +54 -0
- package/dist/altText.js.map +1 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +12 -0
- package/dist/client.js.map +1 -0
- package/dist/contract/index.d.ts +5 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +4 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/lint.d.ts +38 -0
- package/dist/contract/lint.d.ts.map +1 -0
- package/dist/contract/lint.js +110 -0
- package/dist/contract/lint.js.map +1 -0
- package/dist/contract/loader.d.ts +49 -0
- package/dist/contract/loader.d.ts.map +1 -0
- package/dist/contract/loader.js +95 -0
- package/dist/contract/loader.js.map +1 -0
- package/dist/contract/manifest.d.ts +454 -0
- package/dist/contract/manifest.d.ts.map +1 -0
- package/dist/contract/manifest.js +29 -0
- package/dist/contract/manifest.js.map +1 -0
- package/dist/contract/schema.d.ts +334 -0
- package/dist/contract/schema.d.ts.map +1 -0
- package/dist/contract/schema.js +224 -0
- package/dist/contract/schema.js.map +1 -0
- package/dist/generate/blocks.d.ts +79 -0
- package/dist/generate/blocks.d.ts.map +1 -0
- package/dist/generate/blocks.js +118 -0
- package/dist/generate/blocks.js.map +1 -0
- package/dist/generate/fields.d.ts +60 -0
- package/dist/generate/fields.d.ts.map +1 -0
- package/dist/generate/fields.js +478 -0
- package/dist/generate/fields.js.map +1 -0
- package/dist/generate/guidance.d.ts +5 -0
- package/dist/generate/guidance.d.ts.map +1 -0
- package/dist/generate/guidance.js +29 -0
- package/dist/generate/guidance.js.map +1 -0
- package/dist/generate/index.d.ts +13 -0
- package/dist/generate/index.d.ts.map +1 -0
- package/dist/generate/index.js +9 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/generate/labels.d.ts +42 -0
- package/dist/generate/labels.d.ts.map +1 -0
- package/dist/generate/labels.js +226 -0
- package/dist/generate/labels.js.map +1 -0
- package/dist/generate/layout.d.ts +42 -0
- package/dist/generate/layout.d.ts.map +1 -0
- package/dist/generate/layout.js +252 -0
- package/dist/generate/layout.js.map +1 -0
- package/dist/generate/selectOptionSnapshot.d.ts +26 -0
- package/dist/generate/selectOptionSnapshot.d.ts.map +1 -0
- package/dist/generate/selectOptionSnapshot.js +47 -0
- package/dist/generate/selectOptionSnapshot.js.map +1 -0
- package/dist/generate/selectOptions.d.ts +64 -0
- package/dist/generate/selectOptions.d.ts.map +1 -0
- package/dist/generate/selectOptions.js +145 -0
- package/dist/generate/selectOptions.js.map +1 -0
- package/dist/overrides.d.ts +71 -0
- package/dist/overrides.d.ts.map +1 -0
- package/dist/overrides.js +16 -0
- package/dist/overrides.js.map +1 -0
- package/dist/render/RenderBlocks.d.ts +67 -0
- package/dist/render/RenderBlocks.d.ts.map +1 -0
- package/dist/render/RenderBlocks.js +31 -0
- package/dist/render/RenderBlocks.js.map +1 -0
- package/dist/render/coerce.d.ts +78 -0
- package/dist/render/coerce.d.ts.map +1 -0
- package/dist/render/coerce.js +243 -0
- package/dist/render/coerce.js.map +1 -0
- package/dist/render/index.d.ts +5 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +3 -0
- package/dist/render/index.js.map +1 -0
- package/dist/testing/checkBlockProps.d.ts +66 -0
- package/dist/testing/checkBlockProps.d.ts.map +1 -0
- package/dist/testing/checkBlockProps.js +241 -0
- package/dist/testing/checkBlockProps.js.map +1 -0
- package/dist/testing/checkBlockPropsCli.d.ts +12 -0
- package/dist/testing/checkBlockPropsCli.d.ts.map +1 -0
- package/dist/testing/checkBlockPropsCli.js +86 -0
- package/dist/testing/checkBlockPropsCli.js.map +1 -0
- package/dist/testing/contractDefaults.d.ts +21 -0
- package/dist/testing/contractDefaults.d.ts.map +1 -0
- package/dist/testing/contractDefaults.js +65 -0
- package/dist/testing/contractDefaults.js.map +1 -0
- package/dist/testing/describeBlockInvariants.d.ts +39 -0
- package/dist/testing/describeBlockInvariants.d.ts.map +1 -0
- package/dist/testing/describeBlockInvariants.js +71 -0
- package/dist/testing/describeBlockInvariants.js.map +1 -0
- package/dist/testing/index.d.ts +18 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +14 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/untouchedBlocks.d.ts +48 -0
- package/dist/testing/untouchedBlocks.d.ts.map +1 -0
- package/dist/testing/untouchedBlocks.js +155 -0
- package/dist/testing/untouchedBlocks.js.map +1 -0
- package/package.json +108 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { groupOf } from '../contract/index.js';
|
|
2
|
+
import { withAltFallback } from '../altText.js';
|
|
3
|
+
import { toPayloadField } from './fields.js';
|
|
4
|
+
import { blockGuidance, humanize } from './guidance.js';
|
|
5
|
+
import { groupLabel } from './labels.js';
|
|
6
|
+
import { arrange, blockSummaryFields } from './layout.js';
|
|
7
|
+
const BLOCK_SUMMARY = '@forumone/throughline-design-system/client#BlockSummary';
|
|
8
|
+
/**
|
|
9
|
+
* Which components are authorable as blocks.
|
|
10
|
+
*
|
|
11
|
+
* `inline` placement means the component is a building block used *inside*
|
|
12
|
+
* another one — a `Card` within a `CardGrid`, a `Button` within a CTA. It
|
|
13
|
+
* reaches the page through its parent's fields, so offering it in the palette
|
|
14
|
+
* would let an author drop a bare card onto a page with nothing around it.
|
|
15
|
+
*
|
|
16
|
+
* `page` and `section` are the two that stand on their own. `notABlock` in the
|
|
17
|
+
* overrides removes the handful that are page-level but not authored — the
|
|
18
|
+
* chrome a template renders rather than a block an author places.
|
|
19
|
+
*/
|
|
20
|
+
export function isBlockCandidate(component, overrides) {
|
|
21
|
+
if (overrides[component.name]?.notABlock)
|
|
22
|
+
return false;
|
|
23
|
+
return component.composition.placement.some(p => p === 'page' || p === 'section');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The block slug is the manifest component name, byte for byte.
|
|
27
|
+
*
|
|
28
|
+
* Not a stylistic choice. `@forumone/throughline/publishing`'s composition step maps
|
|
29
|
+
* `blockType` straight to `type` with no transformation and looks it up in the
|
|
30
|
+
* manifest; a miss is an `unknown-component` **error**, which blocks publish.
|
|
31
|
+
* Kebab-casing the slugs would make every block on every page unpublishable,
|
|
32
|
+
* and the error would point at the design system rather than at the naming.
|
|
33
|
+
*/
|
|
34
|
+
export function generateBlock(component, options) {
|
|
35
|
+
const ctx = {
|
|
36
|
+
component: component.name,
|
|
37
|
+
overrides: options.overrides,
|
|
38
|
+
mediaCollection: options.mediaCollection,
|
|
39
|
+
linkCollections: options.linkCollections,
|
|
40
|
+
resolveSelectOptions: options.resolveSelectOptions,
|
|
41
|
+
resolveNamedOptions: options.resolveNamedOptions,
|
|
42
|
+
...(options.optionLabels ? { optionLabels: options.optionLabels } : {}),
|
|
43
|
+
};
|
|
44
|
+
// Arranged for an author to read — pairs grouped, settings behind a
|
|
45
|
+
// disclosure. Presentational only; see `./layout.ts`. An alt beside its image
|
|
46
|
+
// falls back to the media's, so it is generated optional; see `../altText.ts`.
|
|
47
|
+
const contract = withAltFallback(component.content.fields);
|
|
48
|
+
const generated = contract.map(field => toPayloadField(field, ctx));
|
|
49
|
+
const fields = arrange(contract, generated, { disclose: true });
|
|
50
|
+
const preview = options.resolvePreview?.(component.name) ?? null;
|
|
51
|
+
const guidance = blockGuidance(component.intent, Object.keys(options.manifest.components));
|
|
52
|
+
// The picker's name for it. An override for the few names a mechanical split
|
|
53
|
+
// gets wrong — `AtAGlance` → "At AGlance" — or that mislead.
|
|
54
|
+
const label = options.overrides[component.name]?.label ?? humanize(component.name);
|
|
55
|
+
return {
|
|
56
|
+
slug: component.name,
|
|
57
|
+
interfaceName: `${component.name}Block`,
|
|
58
|
+
// The preview is described by the block's label, not its slug: a screen
|
|
59
|
+
// reader announced "A preview of the HighImpactCTA component".
|
|
60
|
+
...(preview
|
|
61
|
+
? { imageURL: preview.url, imageAltText: preview.alt ?? `A preview of the ${label} block` }
|
|
62
|
+
: {}),
|
|
63
|
+
labels: {
|
|
64
|
+
singular: label,
|
|
65
|
+
plural: label,
|
|
66
|
+
},
|
|
67
|
+
admin: {
|
|
68
|
+
// A collapsed block says what it holds, not only what type it is — see
|
|
69
|
+
// `../admin/BlockSummary.tsx`. Every block gets it, including one with
|
|
70
|
+
// no text to show, so every header on a page is drawn by the same code.
|
|
71
|
+
components: {
|
|
72
|
+
Label: {
|
|
73
|
+
path: BLOCK_SUMMARY,
|
|
74
|
+
clientProps: {
|
|
75
|
+
fields: blockSummaryFields(component.content.fields, generated),
|
|
76
|
+
singular: label,
|
|
77
|
+
slug: component.name,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
// Which shelf of the picker this block sits on.
|
|
82
|
+
//
|
|
83
|
+
// `groupOf` is the contract's resolver: a component's `group` when it
|
|
84
|
+
// sets one, its `category` when it does not. Both fields are read through
|
|
85
|
+
// it rather than either directly, so a component that has not been given
|
|
86
|
+
// a group still lands somewhere sensible instead of on a nameless shelf.
|
|
87
|
+
//
|
|
88
|
+
// The two are different questions. `category` is what the component *is*,
|
|
89
|
+
// and other consumers read it that way — the components MCP server
|
|
90
|
+
// filters on it. `group` is where an author looks for it. Grouping on
|
|
91
|
+
// `category` alone put over half the picker under "Section", which is the
|
|
92
|
+
// flat list the grouping exists to avoid.
|
|
93
|
+
group: groupLabel(groupOf(component)),
|
|
94
|
+
},
|
|
95
|
+
// A block with no authorable fields is still legitimate: some components
|
|
96
|
+
// are entirely presentational. Payload needs a field array, not a non-empty
|
|
97
|
+
// one.
|
|
98
|
+
//
|
|
99
|
+
// Headed by what the block is for — see `./guidance.ts`. A `ui` field, so
|
|
100
|
+
// it stores nothing and is absent from the generated types.
|
|
101
|
+
fields: guidance ? [guidanceField(guidance), ...fields] : fields,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const BLOCK_GUIDANCE = '@forumone/throughline-design-system/client#BlockGuidance';
|
|
105
|
+
function guidanceField(text) {
|
|
106
|
+
return {
|
|
107
|
+
name: 'blockGuidance',
|
|
108
|
+
type: 'ui',
|
|
109
|
+
admin: { components: { Field: { path: BLOCK_GUIDANCE, clientProps: { text } } } },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function generateBlocks(options) {
|
|
113
|
+
return Object.values(options.manifest.components)
|
|
114
|
+
.filter(component => isBlockCandidate(component, options.overrides))
|
|
115
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
116
|
+
.map(component => ({ block: generateBlock(component, options), component }));
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=blocks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blocks.js","sourceRoot":"","sources":["../../src/generate/blocks.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Field } from 'payload';
|
|
2
|
+
import { type Overrides } from '../overrides.js';
|
|
3
|
+
/**
|
|
4
|
+
* One field of a component's content model, as the manifest describes it.
|
|
5
|
+
* Restated rather than imported from `@forumone/throughline-design-system/contract`
|
|
6
|
+
* because that package exports the Zod-inferred shape, and this only needs the
|
|
7
|
+
* parts the generator reads.
|
|
8
|
+
*/
|
|
9
|
+
export interface ContentField {
|
|
10
|
+
name: string;
|
|
11
|
+
type: 'text' | 'richtext' | 'link' | 'image' | 'video' | 'select' | 'group' | 'array' | 'boolean' | 'number';
|
|
12
|
+
required: boolean;
|
|
13
|
+
maxLength?: number;
|
|
14
|
+
/** `boolean` only — see the contract schema. */
|
|
15
|
+
defaultValue?: boolean;
|
|
16
|
+
constraints?: string;
|
|
17
|
+
/**
|
|
18
|
+
* An optional field most authors should leave alone: an override of a
|
|
19
|
+
* default the component already chooses well, or screen-reader and status
|
|
20
|
+
* copy it already supplies. Drawn in a collapsed "More options" section —
|
|
21
|
+
* see `./layout.ts`.
|
|
22
|
+
*/
|
|
23
|
+
advanced?: boolean;
|
|
24
|
+
of?: ContentField[];
|
|
25
|
+
}
|
|
26
|
+
export interface FieldContext {
|
|
27
|
+
/** Component name, for override lookup and error messages. */
|
|
28
|
+
component: string;
|
|
29
|
+
overrides: Overrides;
|
|
30
|
+
/** Upload collection an `image` field points at. */
|
|
31
|
+
mediaCollection: string;
|
|
32
|
+
/** Collections an internal link may target. */
|
|
33
|
+
linkCollections: string[];
|
|
34
|
+
/** Resolves `select` options for `<Component>.<path>`, or null if it cannot. */
|
|
35
|
+
resolveSelectOptions: (component: string, path: string) => readonly string[] | null;
|
|
36
|
+
/** Resolves a named exported union — used for the global icon set. */
|
|
37
|
+
resolveNamedOptions: (typeName: string) => readonly string[] | null;
|
|
38
|
+
/** The host's words for option values particular to its design system — see `optionLabel`. */
|
|
39
|
+
optionLabels?: Readonly<Record<string, string>>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A link, as one group rather than 41 ad-hoc ones.
|
|
43
|
+
*
|
|
44
|
+
* Internal links hold a relationship, not a path, so renaming a page cannot
|
|
45
|
+
* break every link that pointed at it — the renderer resolves the current slug
|
|
46
|
+
* at read time.
|
|
47
|
+
*
|
|
48
|
+
* `required` is enforced by a validate rather than by Payload's own flag, for
|
|
49
|
+
* the reason `requiredLink` gives.
|
|
50
|
+
*/
|
|
51
|
+
export declare function linkField(name: string, ctx: FieldContext, description?: string, required?: boolean): Field;
|
|
52
|
+
/**
|
|
53
|
+
* Turns one contract field into one Payload field.
|
|
54
|
+
*
|
|
55
|
+
* Returns null when the field is deliberately not authorable — an omitted
|
|
56
|
+
* runtime-state field, or a slot, which is a component position rather than
|
|
57
|
+
* content and is filled by the template.
|
|
58
|
+
*/
|
|
59
|
+
export declare function toPayloadField(field: ContentField, ctx: FieldContext, path?: string): Field | null;
|
|
60
|
+
//# sourceMappingURL=fields.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields.d.ts","sourceRoot":"","sources":["../../src/generate/fields.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,KAAK,EAAE,MAAM,SAAS,CAAA;AAGpD,OAAO,EAAiB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAI/D;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EACA,MAAM,GACN,UAAU,GACV,MAAM,GACN,OAAO,GACP,OAAO,GACP,QAAQ,GACR,OAAO,GACP,OAAO,GACP,SAAS,GACT,QAAQ,CAAA;IACZ,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gDAAgD;IAChD,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,EAAE,CAAC,EAAE,YAAY,EAAE,CAAA;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,SAAS,CAAA;IACpB,oDAAoD;IACpD,eAAe,EAAE,MAAM,CAAA;IACvB,+CAA+C;IAC/C,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB,gFAAgF;IAChF,oBAAoB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,GAAG,IAAI,CAAA;IACnF,sEAAsE;IACtE,mBAAmB,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,GAAG,IAAI,CAAA;IACnE,8FAA8F;IAC9F,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAChD;AAsCD;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,YAAY,EACjB,WAAW,CAAC,EAAE,MAAM,EACpB,QAAQ,UAAQ,GACf,KAAK,CA0DP;AAED;;;;;;GAMG;AAwCH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,YAAY,EACjB,IAAI,GAAE,MAAmB,GACxB,KAAK,GAAG,IAAI,CAKd"}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { formatLabels } from 'payload/shared';
|
|
2
|
+
import { withAltFallback } from '../altText.js';
|
|
3
|
+
import { fieldOverride } from '../overrides.js';
|
|
4
|
+
import { labelFor, optionLabel, selectDescription } from './labels.js';
|
|
5
|
+
import { arrange, oneLine, summaryFields } from './layout.js';
|
|
6
|
+
/** The design system's exported union of every glyph name. */
|
|
7
|
+
const ICON_NAME_TYPE = 'IconName';
|
|
8
|
+
/** The import-map path of the header a generated array row draws. */
|
|
9
|
+
const ROW_SUMMARY = '@forumone/throughline-design-system/client#RowSummary';
|
|
10
|
+
/** Above this, a single-line input stops being the right control. */
|
|
11
|
+
const TEXTAREA_THRESHOLD = 160;
|
|
12
|
+
function describe(field) {
|
|
13
|
+
return field.constraints;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A select, with every option labelled and its description rid of the
|
|
17
|
+
* sentences that only listed the options — see `optionLabel` and
|
|
18
|
+
* `selectDescription` in `./labels.ts`. Every generated select comes through
|
|
19
|
+
* here, whichever branch resolved its values.
|
|
20
|
+
*/
|
|
21
|
+
function selectField(field, values, ctx, required) {
|
|
22
|
+
const label = (value) => optionLabel(value, ctx.optionLabels);
|
|
23
|
+
const description = selectDescription(describe(field), values, label);
|
|
24
|
+
return {
|
|
25
|
+
name: field.name,
|
|
26
|
+
type: 'select',
|
|
27
|
+
options: values.map(value => ({ label: label(value), value })),
|
|
28
|
+
...required,
|
|
29
|
+
...(description ? { admin: { description } } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A link, as one group rather than 41 ad-hoc ones.
|
|
34
|
+
*
|
|
35
|
+
* Internal links hold a relationship, not a path, so renaming a page cannot
|
|
36
|
+
* break every link that pointed at it — the renderer resolves the current slug
|
|
37
|
+
* at read time.
|
|
38
|
+
*
|
|
39
|
+
* `required` is enforced by a validate rather than by Payload's own flag, for
|
|
40
|
+
* the reason `requiredLink` gives.
|
|
41
|
+
*/
|
|
42
|
+
export function linkField(name, ctx, description, required = false) {
|
|
43
|
+
return {
|
|
44
|
+
name,
|
|
45
|
+
type: 'group',
|
|
46
|
+
...(description ? { admin: { description } } : {}),
|
|
47
|
+
...(required ? { validate: requiredLink() } : {}),
|
|
48
|
+
fields: [
|
|
49
|
+
{
|
|
50
|
+
name: 'mode',
|
|
51
|
+
type: 'radio',
|
|
52
|
+
label: 'Links to',
|
|
53
|
+
defaultValue: 'internal',
|
|
54
|
+
options: [
|
|
55
|
+
{ label: 'A page on this site', value: 'internal' },
|
|
56
|
+
{ label: 'Another site', value: 'external' },
|
|
57
|
+
{ label: 'Somewhere on this page', value: 'anchor' },
|
|
58
|
+
],
|
|
59
|
+
admin: { layout: 'horizontal' },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'reference',
|
|
63
|
+
type: 'relationship',
|
|
64
|
+
label: 'Page',
|
|
65
|
+
// The package is generic over any Payload config, so it cannot know
|
|
66
|
+
// this project's slugs; `CollectionSlug` is generated per app.
|
|
67
|
+
relationTo: ctx.linkCollections,
|
|
68
|
+
admin: { condition: (_d, sibling) => sibling?.mode === 'internal' },
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'url',
|
|
72
|
+
type: 'text',
|
|
73
|
+
label: 'URL',
|
|
74
|
+
admin: {
|
|
75
|
+
condition: (_d, sibling) => sibling?.mode === 'external',
|
|
76
|
+
description: 'Include the protocol — https://…',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'anchor',
|
|
81
|
+
type: 'text',
|
|
82
|
+
label: 'Anchor',
|
|
83
|
+
admin: {
|
|
84
|
+
condition: (_d, sibling) => sibling?.mode === 'anchor',
|
|
85
|
+
description: 'The id of the element to scroll to, without the #.',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'newTab',
|
|
90
|
+
type: 'checkbox',
|
|
91
|
+
label: 'Open in a new tab',
|
|
92
|
+
admin: {
|
|
93
|
+
condition: (_d, sibling) => sibling?.mode === 'external',
|
|
94
|
+
description: 'Opening a new tab takes the back button away, so reserve it for leaving the site.',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Turns one contract field into one Payload field.
|
|
102
|
+
*
|
|
103
|
+
* Returns null when the field is deliberately not authorable — an omitted
|
|
104
|
+
* runtime-state field, or a slot, which is a component position rather than
|
|
105
|
+
* content and is filled by the template.
|
|
106
|
+
*/
|
|
107
|
+
/*
|
|
108
|
+
An upload field cannot be stopped from editing the document it points at.
|
|
109
|
+
|
|
110
|
+
Worth writing down, because the fix looks obvious, was tried, shipped inert in
|
|
111
|
+
0.4.3, and would be tried again.
|
|
112
|
+
|
|
113
|
+
Payload's upload field offers two actions side by side and they look alike. The
|
|
114
|
+
picker changes which library document this block points at: local to the block,
|
|
115
|
+
carried by the draft, published when the page is. The edit pencil opens that
|
|
116
|
+
document in a drawer whose file can be removed and replaced: global, immediate,
|
|
117
|
+
and shared by every block that picked it. Downstream that turned "change the
|
|
118
|
+
photograph on this page" into "change it on all five pages that picked this
|
|
119
|
+
photograph, now, while the page you are looking at is still a draft".
|
|
120
|
+
|
|
121
|
+
`admin: { allowEdit: false }` does not prevent it, for three independent
|
|
122
|
+
reasons, in payload 3.87.1:
|
|
123
|
+
|
|
124
|
+
- it is not an option. `UploadAdmin` is `allowCreate` and `isSortable`;
|
|
125
|
+
`allowEdit` belongs to `RelationshipAdmin`, a different field type
|
|
126
|
+
- it would not survive the trip. `UploadAdminClient` is
|
|
127
|
+
`AdminClient & Pick<UploadAdmin, 'allowCreate' | 'isSortable'>`
|
|
128
|
+
- and the component does not read it. `@payloadcms/ui`'s
|
|
129
|
+
`fields/Upload/HasOne` renders `allowEdit: !readonly`, hardcoded. Compare
|
|
130
|
+
`allowCreate`, which *is* wired through `fields/Upload/index.js`
|
|
131
|
+
|
|
132
|
+
So the only way to lose the pencil is `readOnly`, which takes the picker with
|
|
133
|
+
it. The asymmetry with `allowCreate` looks like an upstream oversight rather
|
|
134
|
+
than a decision, and is filed as such — until it moves, this is a Payload
|
|
135
|
+
behaviour a host has to design around rather than a generator setting.
|
|
136
|
+
|
|
137
|
+
**How the inert version passed every gate**, which is the part worth keeping.
|
|
138
|
+
It was written through a helper returning `{ admin: Record<string, unknown> }`,
|
|
139
|
+
and that widening is exactly what suppresses the excess-property check: written
|
|
140
|
+
inline, `tsc` rejects `allowEdit` on an upload's admin, and does so today. The
|
|
141
|
+
tests then asserted `allowEdit` was present *on the generated config object* —
|
|
142
|
+
one end of a string whose other end nothing reads. That is the same shape as a
|
|
143
|
+
cache tag nobody listens to, and it is why the assertions in this file are
|
|
144
|
+
about behaviour wherever behaviour is reachable.
|
|
145
|
+
*/
|
|
146
|
+
export function toPayloadField(field, ctx, path = field.name) {
|
|
147
|
+
const built = buildField(field, ctx, path);
|
|
148
|
+
// Every generated field is labelled explicitly — see `./labels.ts` for why
|
|
149
|
+
// Payload's own title-cased fallback is not good enough.
|
|
150
|
+
return built ? { ...built, label: labelFor(field) } : null;
|
|
151
|
+
}
|
|
152
|
+
function buildField(field, ctx, path) {
|
|
153
|
+
const override = fieldOverride(ctx.overrides, ctx.component, path);
|
|
154
|
+
if (override?.omit)
|
|
155
|
+
return null;
|
|
156
|
+
const description = describe(field);
|
|
157
|
+
const admin = description ? { admin: { description } } : {};
|
|
158
|
+
const required = field.required ? { required: true } : {};
|
|
159
|
+
if (override?.as === 'icon') {
|
|
160
|
+
const options = override.options ??
|
|
161
|
+
ctx.resolveSelectOptions(ctx.component, path) ??
|
|
162
|
+
ctx.resolveNamedOptions(ICON_NAME_TYPE);
|
|
163
|
+
if (!options) {
|
|
164
|
+
throw new Error(`${ctx.component}.${path} is marked as an icon field but no options could be resolved, ` +
|
|
165
|
+
`and neither could the design system's ${ICON_NAME_TYPE}.`);
|
|
166
|
+
}
|
|
167
|
+
return selectField(field, options, ctx, required);
|
|
168
|
+
}
|
|
169
|
+
switch (field.type) {
|
|
170
|
+
case 'text': {
|
|
171
|
+
/*
|
|
172
|
+
A text field whose prop is a union of string literals is a select that
|
|
173
|
+
the contract could not say was one — `ContentField` has ten types and no
|
|
174
|
+
way to mean "one of these names". The component's own type does say it,
|
|
175
|
+
so read it: `IntroSection.actions[].icon` is `keyof typeof Icons`, and a
|
|
176
|
+
free-text box there would let an author type a glyph name that renders
|
|
177
|
+
nothing at all.
|
|
178
|
+
*/
|
|
179
|
+
const asUnion = ctx.resolveSelectOptions(ctx.component, path);
|
|
180
|
+
if (asUnion) {
|
|
181
|
+
return selectField(field, asUnion, ctx, required);
|
|
182
|
+
}
|
|
183
|
+
// A 600-character answer in a single-line input is a usability bug, not
|
|
184
|
+
// a styling preference. `text` and `textarea` are separate field types in
|
|
185
|
+
// Payload, so the branch has to produce whole objects rather than a
|
|
186
|
+
// computed `type`.
|
|
187
|
+
const long = field.maxLength === undefined || field.maxLength > TEXTAREA_THRESHOLD;
|
|
188
|
+
const length = field.maxLength === undefined ? {} : { maxLength: field.maxLength };
|
|
189
|
+
return long
|
|
190
|
+
? { name: field.name, type: 'textarea', ...length, ...required, ...admin }
|
|
191
|
+
: { name: field.name, type: 'text', ...length, ...required, ...admin };
|
|
192
|
+
}
|
|
193
|
+
case 'number':
|
|
194
|
+
return { name: field.name, type: 'number', ...required, ...admin };
|
|
195
|
+
case 'boolean':
|
|
196
|
+
// `required` on a checkbox would mean "must be ticked", which is never
|
|
197
|
+
// what a contract means by it.
|
|
198
|
+
//
|
|
199
|
+
// `defaultValue` was hardcoded `false` here, which made a component's own
|
|
200
|
+
// default unreachable: `coerce` turns whatever is stored into a real
|
|
201
|
+
// boolean, so the prop is never `undefined` and a signature default like
|
|
202
|
+
// `hasFacade = true` can never apply. A contract that wants the box
|
|
203
|
+
// ticked to begin with now says so and is believed.
|
|
204
|
+
return {
|
|
205
|
+
name: field.name,
|
|
206
|
+
type: 'checkbox',
|
|
207
|
+
defaultValue: field.defaultValue ?? false,
|
|
208
|
+
...admin,
|
|
209
|
+
};
|
|
210
|
+
case 'richtext':
|
|
211
|
+
return { name: field.name, type: 'richText', ...required, ...admin };
|
|
212
|
+
case 'image':
|
|
213
|
+
return {
|
|
214
|
+
name: field.name,
|
|
215
|
+
type: 'upload',
|
|
216
|
+
relationTo: ctx.mediaCollection,
|
|
217
|
+
...required,
|
|
218
|
+
...admin,
|
|
219
|
+
};
|
|
220
|
+
case 'video':
|
|
221
|
+
/*
|
|
222
|
+
Two kinds of video wear the same contract type, and the override is what
|
|
223
|
+
tells them apart.
|
|
224
|
+
|
|
225
|
+
`videoUpload` is a file the site serves itself — `VideoHero`'s background
|
|
226
|
+
clip — so it is an upload against the same media collection an image
|
|
227
|
+
uses, which already accepts `video/mp4`. Everything else is a provider
|
|
228
|
+
embed URL: `VideoEmbed` takes a YouTube/Vimeo/Wistia src and has a
|
|
229
|
+
separate `poster` field that *is* an image.
|
|
230
|
+
*/
|
|
231
|
+
if (override?.as === 'videoUpload') {
|
|
232
|
+
return {
|
|
233
|
+
name: field.name,
|
|
234
|
+
type: 'upload',
|
|
235
|
+
relationTo: ctx.mediaCollection,
|
|
236
|
+
...required,
|
|
237
|
+
...admin,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
name: field.name,
|
|
242
|
+
type: 'text',
|
|
243
|
+
...required,
|
|
244
|
+
admin: {
|
|
245
|
+
description: description ?? 'A YouTube or Vimeo URL.',
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
case 'link':
|
|
249
|
+
return linkField(field.name, ctx, description, field.required);
|
|
250
|
+
case 'select': {
|
|
251
|
+
const options = override?.options ?? ctx.resolveSelectOptions(ctx.component, path);
|
|
252
|
+
if (!options) {
|
|
253
|
+
throw new Error(`${ctx.component}.${path} is a select, but its allowed values could not be resolved. ` +
|
|
254
|
+
`The manifest does not carry them — they live in the component's own literal union ` +
|
|
255
|
+
`type, or in an \`options\` override when it has none.`);
|
|
256
|
+
}
|
|
257
|
+
return selectField(field, options, ctx, required);
|
|
258
|
+
}
|
|
259
|
+
case 'group': {
|
|
260
|
+
// A group with no children is a *slot* — a place a component renders
|
|
261
|
+
// another component, declared in `composition.allowedSlots`. It holds no
|
|
262
|
+
// authored content, so there is nothing to put in the CMS.
|
|
263
|
+
if (!field.of || field.of.length === 0)
|
|
264
|
+
return null;
|
|
265
|
+
const of = withAltFallback(field.of);
|
|
266
|
+
const children = childFields(of, ctx, path);
|
|
267
|
+
/*
|
|
268
|
+
An optional group holds its children to a weaker promise than it looks.
|
|
269
|
+
|
|
270
|
+
`ProseSection.image` is `required: false` with `src` and `alt` both
|
|
271
|
+
required inside it — meaning "an image is optional, but a *half* image is
|
|
272
|
+
not". Generated literally, Payload validates those children whether or
|
|
273
|
+
not the group has anything in it, so every block carrying an optional
|
|
274
|
+
image became a block that could not be published without one. Six of the
|
|
275
|
+
eight blocks on the About page failed that way.
|
|
276
|
+
|
|
277
|
+
So inside an optional group the children are relaxed, and the promise the
|
|
278
|
+
contract actually made is enforced on the group instead: fill it or leave
|
|
279
|
+
it, but do not half-fill it.
|
|
280
|
+
*/
|
|
281
|
+
if (field.required) {
|
|
282
|
+
return { name: field.name, type: 'group', ...admin, fields: children };
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
name: field.name,
|
|
286
|
+
type: 'group',
|
|
287
|
+
...admin,
|
|
288
|
+
validate: allOrNothing(of),
|
|
289
|
+
fields: relaxRequired(children),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
case 'array': {
|
|
293
|
+
if (!field.of || field.of.length === 0) {
|
|
294
|
+
throw new Error(`${ctx.component}.${path} is an array with no \`of\` describing its rows.`);
|
|
295
|
+
}
|
|
296
|
+
// A row is drawn on one line and named by its contents where it can be
|
|
297
|
+
// — see the end of `./layout.ts`.
|
|
298
|
+
const of = withAltFallback(field.of);
|
|
299
|
+
const generated = generateChildren(of, ctx, path);
|
|
300
|
+
const summary = summaryFields(of, generated);
|
|
301
|
+
const rowLabel = summary.length > 0
|
|
302
|
+
? {
|
|
303
|
+
components: {
|
|
304
|
+
RowLabel: {
|
|
305
|
+
path: ROW_SUMMARY,
|
|
306
|
+
clientProps: { fields: summary, singular: formatLabels(field.name).singular },
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
: {};
|
|
311
|
+
return {
|
|
312
|
+
name: field.name,
|
|
313
|
+
type: 'array',
|
|
314
|
+
// Payload has no "required array"; a minimum of one row is what the
|
|
315
|
+
// contract means.
|
|
316
|
+
...(field.required ? { minRows: 1 } : {}),
|
|
317
|
+
...(description || summary.length > 0
|
|
318
|
+
? { admin: { ...(description ? { description } : {}), ...rowLabel } }
|
|
319
|
+
: {}),
|
|
320
|
+
fields: oneLine(childFields(of, ctx, path, generated)),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Strip `required` from a group's children, recursing into nested groups.
|
|
327
|
+
*
|
|
328
|
+
* Only ever applied inside an optional group — see the `group` case. An array
|
|
329
|
+
* keeps its `minRows`, because Payload does not enforce rows on an array whose
|
|
330
|
+
* parent group is empty.
|
|
331
|
+
*/
|
|
332
|
+
function relaxRequired(fields) {
|
|
333
|
+
return fields.map(field => {
|
|
334
|
+
const next = 'required' in field && field.required ? { ...field, required: false } : field;
|
|
335
|
+
return 'fields' in next && Array.isArray(next.fields) && next.type === 'group'
|
|
336
|
+
? { ...next, fields: relaxRequired(next.fields) }
|
|
337
|
+
: next;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* "Fill it or leave it, but do not half-fill it."
|
|
342
|
+
*
|
|
343
|
+
* The validation an optional group needs and Payload has no field-level way to
|
|
344
|
+
* express: silent while the group is empty, and demanding of exactly the
|
|
345
|
+
* children the contract marked required once any of them is filled in.
|
|
346
|
+
*/
|
|
347
|
+
function allOrNothing(children) {
|
|
348
|
+
const names = children.filter(child => child.required).map(child => child.name);
|
|
349
|
+
/*
|
|
350
|
+
Booleans this group defaults to ticked.
|
|
351
|
+
|
|
352
|
+
`isEmpty` answers for a value alone and reads `false` as "nobody touched
|
|
353
|
+
this", which is right for a checkbox that starts unticked. A checkbox the
|
|
354
|
+
contract starts *ticked* says the same thing with the opposite value, and
|
|
355
|
+
`isEmpty` cannot know that without being told which field it is looking at.
|
|
356
|
+
Left unhandled, such a field makes its group permanently non-empty and
|
|
357
|
+
`allOrNothing` demands the group's required children of an author who has
|
|
358
|
+
typed nothing — the third route to the defect this file's `isEmpty` comment
|
|
359
|
+
describes.
|
|
360
|
+
*/
|
|
361
|
+
const defaultTicked = new Set(children.filter(child => child.type === 'boolean' && child.defaultValue === true).map(child => child.name));
|
|
362
|
+
const untouched = (name, v) => defaultTicked.has(name) ? v === true || isEmpty(v) : isEmpty(v);
|
|
363
|
+
return (value) => {
|
|
364
|
+
if (!value || typeof value !== 'object')
|
|
365
|
+
return true;
|
|
366
|
+
const entries = Object.entries(value);
|
|
367
|
+
if (entries.every(([k, v]) => untouched(k, v)))
|
|
368
|
+
return true;
|
|
369
|
+
const missing = names.filter(name => isEmpty(value[name]));
|
|
370
|
+
if (missing.length === 0)
|
|
371
|
+
return true;
|
|
372
|
+
return missing.length === 1
|
|
373
|
+
? `${missing[0]} is needed once anything else here is filled in. Clear the rest to leave this out entirely.`
|
|
374
|
+
: `${missing.join(' and ')} are needed once anything else here is filled in. Clear the rest to leave this out entirely.`;
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* A required link must actually go somewhere.
|
|
379
|
+
*
|
|
380
|
+
* `required: true` on a contract link field bought nothing for as long as this
|
|
381
|
+
* generator existed: the flag was read for every other field type and dropped
|
|
382
|
+
* for this one, and Payload's own `required` could not have replaced it anyway
|
|
383
|
+
* — on a group it asserts only that the object exists, which it always does,
|
|
384
|
+
* and on the children it would demand a `url` of a link whose author chose the
|
|
385
|
+
* internal mode. So the promise is kept here, against the branch the author
|
|
386
|
+
* actually picked.
|
|
387
|
+
*
|
|
388
|
+
* It cost a live defect. `FeaturedWork` marks each item's `href` required and
|
|
389
|
+
* types it `string`; two items on `/work` were saved with the link untouched,
|
|
390
|
+
* `resolveHref` returned `undefined`, React dropped the attribute, and the page
|
|
391
|
+
* shipped four `<a>` elements with no `href` — not links at all: skipped by
|
|
392
|
+
* crawlers, unreachable by keyboard, and scoring 0 on Lighthouse's
|
|
393
|
+
* `crawlable-anchors`. Nothing in the CMS had objected. See #483.
|
|
394
|
+
*
|
|
395
|
+
* **Silent until the surroundings are filled in.** A block or an array row
|
|
396
|
+
* Payload has only just created holds defaults, and refusing to save one before
|
|
397
|
+
* anybody has typed into it is the #354 defect — an error on a block the editor
|
|
398
|
+
* cannot yet act on. So the rule reads its siblings: everything around it empty
|
|
399
|
+
* means nobody has been here, and the link is left alone. That is the same
|
|
400
|
+
* bargain `allOrNothing` strikes one level up, and it lands on the case that
|
|
401
|
+
* matters — a row carrying a title and a paragraph and no destination.
|
|
402
|
+
*/
|
|
403
|
+
function requiredLink() {
|
|
404
|
+
return (value, options) => {
|
|
405
|
+
const link = (value ?? {});
|
|
406
|
+
const mode = link.mode ?? 'internal';
|
|
407
|
+
if (mode === 'internal' && !isEmpty(link.reference))
|
|
408
|
+
return true;
|
|
409
|
+
if (mode === 'external' && !isEmpty(link.url))
|
|
410
|
+
return true;
|
|
411
|
+
if (mode === 'anchor' && !isEmpty(link.anchor))
|
|
412
|
+
return true;
|
|
413
|
+
if (isEmpty(options?.siblingData))
|
|
414
|
+
return true;
|
|
415
|
+
if (mode === 'external')
|
|
416
|
+
return 'Enter the URL this link goes to.';
|
|
417
|
+
if (mode === 'anchor')
|
|
418
|
+
return 'Name the id this link scrolls to.';
|
|
419
|
+
return 'Choose the page this link goes to.';
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/*
|
|
423
|
+
"Nobody has been here yet", and why it has to be recursive — which is not
|
|
424
|
+
obvious until it bites.
|
|
425
|
+
|
|
426
|
+
A group holding a link sub-group is never literally empty: `linkField` gives
|
|
427
|
+
`mode` a default of `'internal'`, so an untouched `caseStudy` arrives as
|
|
428
|
+
`{ stat: {}, href: { mode: 'internal' } }`. Compared shallowly that is a filled
|
|
429
|
+
group, and `allOrNothing` then demands the title of an author who has typed
|
|
430
|
+
nothing — that validation firing on exactly the case it exists to allow.
|
|
431
|
+
|
|
432
|
+
`mode` is skipped for that reason: it is a discriminator that says which *kind*
|
|
433
|
+
of link this would be if there were one, and it is present whether or not
|
|
434
|
+
anybody chose anything. It is never evidence that a group was filled in.
|
|
435
|
+
|
|
436
|
+
**An unticked checkbox is the same thing, and is why `false` counts as empty.**
|
|
437
|
+
`boolean` fields are generated with `defaultValue: false` unless the contract
|
|
438
|
+
declares otherwise, so a group holding one is never literally empty either — and `false` is indistinguishable from
|
|
439
|
+
"nobody touched this", because Payload stores the default and an author's
|
|
440
|
+
deliberate untick identically. Treating it as filled makes the rule demand the
|
|
441
|
+
group's required children of somebody who has typed nothing, which is the same
|
|
442
|
+
defect this comment already describes, arriving by a second route.
|
|
443
|
+
|
|
444
|
+
It did arrive: `ManagedForm.consent` holds a `required` boolean beside a
|
|
445
|
+
required `text`, and adding the block failed to save with "Consent is invalid"
|
|
446
|
+
before an editor could touch it.
|
|
447
|
+
|
|
448
|
+
A checkbox the contract starts *ticked* says "nobody touched this" with `true`
|
|
449
|
+
instead, which this function cannot see — it is handed a value, not a field.
|
|
450
|
+
`allOrNothing` knows the field shapes and handles that case itself.
|
|
451
|
+
|
|
452
|
+
`requiredLink` asks the same question of a link's *siblings*, which is what
|
|
453
|
+
keeps it quiet on a block or a row nobody has typed into yet.
|
|
454
|
+
*/
|
|
455
|
+
function isEmpty(value) {
|
|
456
|
+
if (value === undefined || value === null || value === '')
|
|
457
|
+
return true;
|
|
458
|
+
if (value === false)
|
|
459
|
+
return true;
|
|
460
|
+
if (Array.isArray(value))
|
|
461
|
+
return value.every(isEmpty);
|
|
462
|
+
if (typeof value === 'object') {
|
|
463
|
+
return Object.entries(value).every(([key, held]) => key === 'mode' || isEmpty(held));
|
|
464
|
+
}
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
function generateChildren(children, ctx, parentPath) {
|
|
468
|
+
return children.map(child => toPayloadField(child, ctx, `${parentPath}.${child.name}`));
|
|
469
|
+
}
|
|
470
|
+
function childFields(children, ctx, parentPath, generated = generateChildren(children, ctx, parentPath)) {
|
|
471
|
+
const fields = arrange(children, generated, { disclose: false });
|
|
472
|
+
if (fields.length === 0) {
|
|
473
|
+
throw new Error(`${ctx.component}.${parentPath} has no authorable children left after overrides. ` +
|
|
474
|
+
`Payload rejects a group or array with an empty \`fields\`.`);
|
|
475
|
+
}
|
|
476
|
+
return fields;
|
|
477
|
+
}
|
|
478
|
+
//# sourceMappingURL=fields.js.map
|