@actuate-media/cms-admin 0.11.0 → 0.12.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/LICENSE +21 -21
- package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
- package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
- package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
- package/dist/fields/ComponentBlockField.d.ts +25 -0
- package/dist/fields/ComponentBlockField.d.ts.map +1 -0
- package/dist/fields/ComponentBlockField.js +74 -0
- package/dist/fields/ComponentBlockField.js.map +1 -0
- package/dist/fields/FieldRenderer.d.ts +3 -0
- package/dist/fields/FieldRenderer.d.ts.map +1 -1
- package/dist/fields/FieldRenderer.js +3 -1
- package/dist/fields/FieldRenderer.js.map +1 -1
- package/dist/fields/PropInput.d.ts +14 -0
- package/dist/fields/PropInput.d.ts.map +1 -0
- package/dist/fields/PropInput.js +163 -0
- package/dist/fields/PropInput.js.map +1 -0
- package/dist/fields/component-block-helpers.d.ts +96 -0
- package/dist/fields/component-block-helpers.d.ts.map +1 -0
- package/dist/fields/component-block-helpers.js +323 -0
- package/dist/fields/component-block-helpers.js.map +1 -0
- package/dist/fields/index.d.ts +4 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +2 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +10 -3
- package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
- package/src/fields/ComponentBlockField.tsx +179 -0
- package/src/fields/FieldRenderer.tsx +8 -0
- package/src/fields/PropInput.tsx +552 -0
- package/src/fields/component-block-helpers.ts +341 -0
- package/src/fields/index.ts +4 -0
- package/src/index.ts +7 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Top-level admin field for component-aware blocks. Consumes a
|
|
5
|
+
* {@link Manifest} produced by `@actuate-media/component-blocks`,
|
|
6
|
+
* renders a component picker (when more than one component is allowed)
|
|
7
|
+
* and a recursive props form via {@link PropInput}.
|
|
8
|
+
*
|
|
9
|
+
* Storage shape:
|
|
10
|
+
* { component: 'Hero', props: { title: 'Welcome', alignment: 'center' } }
|
|
11
|
+
*
|
|
12
|
+
* Live validation runs through `validateComponentBlockValue` from
|
|
13
|
+
* `@actuate-media/cms-core`. The whole-value error is shown at the top
|
|
14
|
+
* of the form, and the per-prop error map (parsed out of the message
|
|
15
|
+
* string) is forwarded into `PropInput` so each input can render its
|
|
16
|
+
* own inline error.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
20
|
+
|
|
21
|
+
import type { Manifest } from '@actuate-media/component-blocks'
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
buildClientValidator,
|
|
25
|
+
getAllowedComponents,
|
|
26
|
+
parsePerPropErrors,
|
|
27
|
+
seedPropsForComponent,
|
|
28
|
+
} from './component-block-helpers.js'
|
|
29
|
+
import { PropInput } from './PropInput.js'
|
|
30
|
+
|
|
31
|
+
export interface ComponentBlockValue {
|
|
32
|
+
component: string
|
|
33
|
+
props: Record<string, unknown>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ComponentBlockFieldProps {
|
|
37
|
+
label?: string
|
|
38
|
+
manifest: Manifest
|
|
39
|
+
/** Optional whitelist of component names. */
|
|
40
|
+
allow?: string[]
|
|
41
|
+
/** Default component picked when the field is first used. */
|
|
42
|
+
defaultComponent?: string
|
|
43
|
+
value?: ComponentBlockValue
|
|
44
|
+
onChange: (value: ComponentBlockValue) => void
|
|
45
|
+
/**
|
|
46
|
+
* Optional async validator hook. Receives the proposed value and the
|
|
47
|
+
* field config; should return `true` for ok or a string error.
|
|
48
|
+
* Falls back to a built-in structural validator when not provided —
|
|
49
|
+
* which is what cms-core would do server-side at save time anyway.
|
|
50
|
+
*/
|
|
51
|
+
validate?: (value: ComponentBlockValue) => string | true
|
|
52
|
+
helpText?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ComponentBlockField({
|
|
56
|
+
label,
|
|
57
|
+
manifest,
|
|
58
|
+
allow,
|
|
59
|
+
defaultComponent,
|
|
60
|
+
value,
|
|
61
|
+
onChange,
|
|
62
|
+
validate,
|
|
63
|
+
helpText,
|
|
64
|
+
}: ComponentBlockFieldProps) {
|
|
65
|
+
const allowedComponents = useMemo(() => getAllowedComponents(manifest, allow), [manifest, allow])
|
|
66
|
+
// Track the initial seed application so we don't repeatedly stomp
|
|
67
|
+
// the editor's prop values on every render. The defaultComponent
|
|
68
|
+
// seed only fires once, when value is undefined on mount.
|
|
69
|
+
const seededRef = useRef(false)
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (seededRef.current) return
|
|
73
|
+
if (value) {
|
|
74
|
+
seededRef.current = true
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
const picked =
|
|
78
|
+
allowedComponents.find((c) => c.name === defaultComponent) ?? allowedComponents[0]
|
|
79
|
+
if (!picked) return
|
|
80
|
+
seededRef.current = true
|
|
81
|
+
onChange({ component: picked.name, props: seedPropsForComponent(picked) })
|
|
82
|
+
}, [allowedComponents, defaultComponent, value, onChange])
|
|
83
|
+
|
|
84
|
+
const currentSpec =
|
|
85
|
+
(value && manifest.components.find((c) => c.name === value.component)) ?? allowedComponents[0]
|
|
86
|
+
|
|
87
|
+
// Pure structural validation when caller didn't provide their own.
|
|
88
|
+
// Keeps the form usable even without a live validator wired in.
|
|
89
|
+
const builtinValidate = useMemo(() => buildClientValidator(manifest, allow), [manifest, allow])
|
|
90
|
+
const validator = validate ?? builtinValidate
|
|
91
|
+
|
|
92
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
93
|
+
const perPropErrors = useMemo(() => parsePerPropErrors(errorMessage), [errorMessage])
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!value) {
|
|
97
|
+
setErrorMessage(null)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const result = validator(value)
|
|
101
|
+
setErrorMessage(result === true ? null : result)
|
|
102
|
+
}, [value, validator])
|
|
103
|
+
|
|
104
|
+
function handleComponentChange(componentName: string) {
|
|
105
|
+
const spec = manifest.components.find((c) => c.name === componentName)
|
|
106
|
+
if (!spec) return
|
|
107
|
+
onChange({ component: spec.name, props: seedPropsForComponent(spec) })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function handlePropChange(propName: string, next: unknown) {
|
|
111
|
+
if (!value) return
|
|
112
|
+
onChange({
|
|
113
|
+
...value,
|
|
114
|
+
props: { ...value.props, [propName]: next },
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!currentSpec) {
|
|
119
|
+
return (
|
|
120
|
+
<div className="rounded-md border border-[var(--destructive)] bg-red-50 p-3 text-sm text-[var(--destructive)]">
|
|
121
|
+
Component block has no components to choose from.
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="space-y-3">
|
|
128
|
+
{label ? <label className="block text-sm font-medium">{label}</label> : null}
|
|
129
|
+
|
|
130
|
+
{allowedComponents.length > 1 ? (
|
|
131
|
+
<label className="block text-sm">
|
|
132
|
+
<div className="mb-1 text-xs tracking-wide text-[var(--muted-foreground)] uppercase">
|
|
133
|
+
Component
|
|
134
|
+
</div>
|
|
135
|
+
<select
|
|
136
|
+
value={currentSpec.name}
|
|
137
|
+
onChange={(e) => handleComponentChange(e.target.value)}
|
|
138
|
+
className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
|
139
|
+
>
|
|
140
|
+
{allowedComponents.map((spec) => (
|
|
141
|
+
<option key={spec.name} value={spec.name}>
|
|
142
|
+
{spec.displayName}
|
|
143
|
+
</option>
|
|
144
|
+
))}
|
|
145
|
+
</select>
|
|
146
|
+
</label>
|
|
147
|
+
) : null}
|
|
148
|
+
|
|
149
|
+
{errorMessage ? (
|
|
150
|
+
<div className="rounded-md border border-[var(--destructive)] bg-red-50 px-3 py-2 text-xs text-[var(--destructive)]">
|
|
151
|
+
{errorMessage}
|
|
152
|
+
</div>
|
|
153
|
+
) : null}
|
|
154
|
+
|
|
155
|
+
<fieldset className="rounded-md border border-[var(--border)] bg-[var(--card)] p-4">
|
|
156
|
+
<legend className="px-1 text-xs tracking-wide text-[var(--muted-foreground)] uppercase">
|
|
157
|
+
{currentSpec.displayName}
|
|
158
|
+
</legend>
|
|
159
|
+
{currentSpec.description ? (
|
|
160
|
+
<p className="mb-3 text-xs text-[var(--muted-foreground)]">{currentSpec.description}</p>
|
|
161
|
+
) : null}
|
|
162
|
+
<div className="space-y-4">
|
|
163
|
+
{currentSpec.props.map((prop) => (
|
|
164
|
+
<PropInput
|
|
165
|
+
key={prop.name}
|
|
166
|
+
prop={prop}
|
|
167
|
+
value={value?.props?.[prop.name]}
|
|
168
|
+
onChange={(next) => handlePropChange(prop.name, next)}
|
|
169
|
+
errors={perPropErrors}
|
|
170
|
+
path={prop.name}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
</fieldset>
|
|
175
|
+
|
|
176
|
+
{helpText ? <p className="text-xs text-[var(--muted-foreground)]">{helpText}</p> : null}
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -13,6 +13,7 @@ import { BlockBuilderField } from './BlockBuilderField.js'
|
|
|
13
13
|
import { GroupField } from './GroupField.js'
|
|
14
14
|
import { NavBuilderField } from './NavBuilderField.js'
|
|
15
15
|
import { NumberField } from './NumberField.js'
|
|
16
|
+
import { ComponentBlockField } from './ComponentBlockField.js'
|
|
16
17
|
|
|
17
18
|
export interface FieldDefinition {
|
|
18
19
|
name: string
|
|
@@ -28,6 +29,9 @@ export interface FieldDefinition {
|
|
|
28
29
|
multi?: boolean
|
|
29
30
|
fields?: FieldDefinition[]
|
|
30
31
|
blocks?: any[]
|
|
32
|
+
manifest?: unknown
|
|
33
|
+
allow?: string[]
|
|
34
|
+
defaultComponent?: string
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export interface FieldRendererProps {
|
|
@@ -50,6 +54,7 @@ const FIELD_MAP: Record<string, React.ComponentType<any>> = {
|
|
|
50
54
|
group: GroupField,
|
|
51
55
|
nav: NavBuilderField,
|
|
52
56
|
number: NumberField,
|
|
57
|
+
componentBlock: ComponentBlockField,
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
export function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
@@ -79,6 +84,9 @@ export function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
|
79
84
|
fields={field.fields}
|
|
80
85
|
blocks={field.blocks}
|
|
81
86
|
name={field.name}
|
|
87
|
+
manifest={field.manifest}
|
|
88
|
+
allow={field.allow}
|
|
89
|
+
defaultComponent={field.defaultComponent}
|
|
82
90
|
/>
|
|
83
91
|
)
|
|
84
92
|
}
|