Skip to content

Commit c2a32a3

Browse files
lane711bobbwalclaude
authored
feat: collapsible grouped fields UI for structured objects and arrays (#635) (#739)
Add collapsible/expandable sections for grouped fields in the admin content form, improving UX for complex nested collection schemas (objects, arrays, blocks). Includes drag-and-drop reordering, validation visibility, and proper serialization of nested structures. Cherry-picked from bobbwal/sonicjs fix/grouped-fields-ui branch. E2E tests 55-58 skipped pending CI fixture data. Co-authored-by: Rob Walton <bobbwal@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5ebbf15 commit c2a32a3

16 files changed

Lines changed: 2808 additions & 195 deletions

my-sonicjs-app/src/collections/page-blocks.collection.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,82 @@
11
import type { CollectionConfig } from '@sonicjs-cms/core'
22

3+
const heroBlock = {
4+
label: 'Hero',
5+
properties: {
6+
heading: { type: 'string', title: 'Heading', required: true },
7+
height: {
8+
type: 'radio',
9+
title: 'Height',
10+
enum: ['small', 'medium', 'full'],
11+
enumLabels: ['Small', 'Medium', 'Full'],
12+
default: 'medium',
13+
inline: true,
14+
},
15+
subheading: { type: 'textarea', title: 'Subheading', maxLength: 600 },
16+
image: { type: 'media', title: 'Background/Image' },
17+
imageAlt: { type: 'string', title: 'Image Alt' },
18+
19+
ctaPrimary: {
20+
title: 'Primary CTA',
21+
type: 'object',
22+
properties: {
23+
label: { type: 'string', title: 'Label' },
24+
link: {
25+
title: 'Link',
26+
type: 'object',
27+
properties: {
28+
mode: {
29+
type: 'select',
30+
title: 'Link type',
31+
enum: ['none', 'internal', 'external'],
32+
enumLabels: ['None', 'Internal', 'External'],
33+
default: 'none',
34+
},
35+
reference: { type: 'reference', title: 'Internal reference', collection: 'pages' },
36+
url: { type: 'url', title: 'External URL' },
37+
},
38+
},
39+
style: {
40+
type: 'select',
41+
title: 'Button style',
42+
enum: ['primary', 'secondary'],
43+
enumLabels: ['Primary', 'Secondary'],
44+
default: 'primary',
45+
},
46+
},
47+
},
48+
ctaSecondary: {
49+
title: 'Secondary CTA',
50+
type: 'object',
51+
properties: {
52+
label: { type: 'string', title: 'Label' },
53+
link: {
54+
title: 'Link',
55+
type: 'object',
56+
properties: {
57+
mode: {
58+
type: 'select',
59+
title: 'Type',
60+
enum: ['none', 'internal', 'external'],
61+
enumLabels: ['None', 'Internal', 'External'],
62+
default: 'none',
63+
},
64+
reference: { type: 'reference', title: 'Internal reference', collection: 'pages' },
65+
url: { type: 'url', title: 'External URL' },
66+
},
67+
},
68+
style: {
69+
type: 'select',
70+
title: 'Button style',
71+
enum: ['primary', 'secondary'],
72+
enumLabels: ['Primary', 'Secondary'],
73+
default: 'primary',
74+
},
75+
},
76+
},
77+
},
78+
}
79+
380
const pageBlocksCollection: CollectionConfig = {
481
name: 'page_blocks',
582
displayName: 'Page Blocks',
@@ -28,6 +105,7 @@ const pageBlocksCollection: CollectionConfig = {
28105
seo: {
29106
type: 'object',
30107
title: 'SEO',
108+
objectLayout: 'flat',
31109
properties: {
32110
title: { type: 'string', title: 'SEO title' },
33111
description: { type: 'textarea', title: 'SEO description' },
@@ -47,11 +125,57 @@ const pageBlocksCollection: CollectionConfig = {
47125
name: { type: 'string', title: 'Name', required: true },
48126
role: { type: 'string', title: 'Role' },
49127
photo: { type: 'media', title: 'Photo' },
128+
children: {
129+
type: 'array',
130+
title: 'Children',
131+
items: {
132+
type: 'object',
133+
properties: {
134+
name: { type: 'string', title: 'Name', required: true },
135+
},
136+
},
137+
},
50138
},
51139
},
52140
},
53141
},
54142
},
143+
openingHoursWeek: {
144+
type: 'object',
145+
title: 'Opening Hours',
146+
properties: {
147+
monday: {
148+
type: 'object',
149+
title: 'Monday',
150+
objectLayout: 'flat',
151+
properties: {
152+
closed: { type: 'boolean', title: 'Closed', default: false },
153+
opens: { type: 'string', title: 'Opens', pattern: '^([01]\\d|2[0-3]):(00|30)$' },
154+
closes: { type: 'string', title: 'Closes', pattern: '^([01]\\d|2[0-3]):(00|30)$' },
155+
},
156+
},
157+
tuesday: {
158+
type: 'object',
159+
title: 'Tuesday',
160+
objectLayout: 'flat',
161+
properties: {
162+
closed: { type: 'boolean', title: 'Closed', default: false },
163+
opens: { type: 'string', title: 'Opens', pattern: '^([01]\\d|2[0-3]):(00|30)$' },
164+
closes: { type: 'string', title: 'Closes', pattern: '^([01]\\d|2[0-3]):(00|30)$' },
165+
},
166+
},
167+
wednesday: {
168+
type: 'object',
169+
title: 'Wednesday',
170+
objectLayout: 'flat',
171+
properties: {
172+
closed: { type: 'boolean', title: 'Closed', default: false },
173+
opens: { type: 'string', title: 'Opens', pattern: '^([01]\\d|2[0-3]):(00|30)$' },
174+
closes: { type: 'string', title: 'Closes', pattern: '^([01]\\d|2[0-3]):(00|30)$' },
175+
},
176+
},
177+
},
178+
},
55179

56180
body: {
57181
type: 'array',
@@ -60,6 +184,7 @@ const pageBlocksCollection: CollectionConfig = {
60184
type: 'object',
61185
discriminator: 'blockType',
62186
blocks: {
187+
hero: heroBlock,
63188
text: {
64189
label: 'Text',
65190
properties: {
@@ -81,6 +206,24 @@ const pageBlocksCollection: CollectionConfig = {
81206
image: { type: 'media', title: 'Image', required: true },
82207
},
83208
},
209+
gallery: {
210+
label: 'Gallery',
211+
properties: {
212+
heading: { type: 'string', title: 'Heading' },
213+
images: {
214+
type: 'array',
215+
title: 'Images',
216+
items: {
217+
type: 'object',
218+
properties: {
219+
image: { type: 'media', title: 'Image' },
220+
alt: { type: 'string', title: 'Alt' },
221+
caption: { type: 'string', title: 'Caption' },
222+
},
223+
},
224+
},
225+
},
226+
},
84227
callToAction: {
85228
label: 'Call To Action',
86229
properties: {

packages/core/src/__tests__/templates/dynamic-field-extended.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,46 @@ describe('renderDynamicField - Object Fields', () => {
627627

628628
expect(html).toContain('data-structured-object');
629629
});
630+
631+
it('should scope nested object host lookup to the current object container', () => {
632+
const field = createTestField({
633+
field_type: 'object',
634+
field_options: {
635+
properties: {
636+
monday: {
637+
type: 'object',
638+
title: 'Monday',
639+
objectLayout: 'flat',
640+
properties: {
641+
opens: { type: 'text', title: 'Opens' },
642+
closes: { type: 'text', title: 'Closes' },
643+
},
644+
},
645+
tuesday: {
646+
type: 'object',
647+
title: 'Tuesday',
648+
objectLayout: 'flat',
649+
properties: {
650+
opens: { type: 'text', title: 'Opens' },
651+
closes: { type: 'text', title: 'Closes' },
652+
},
653+
},
654+
},
655+
},
656+
});
657+
const html = renderDynamicField(field, {
658+
value: {
659+
monday: { opens: '09:00', closes: '17:00' },
660+
tuesday: { opens: '10:00', closes: '18:00' },
661+
},
662+
});
663+
664+
expect(html).toContain('const getStructuredObjectFieldsHost = (container) =>');
665+
expect(html).toContain("getDirectChild(container, '.field-group-content')");
666+
expect(html).toContain('const getDirectStructuredObject = (fieldWrapper) =>');
667+
expect(html).not.toContain("objectContainer.querySelector('[data-structured-object-fields]')");
668+
expect(html).toContain("document.readyState !== 'loading'");
669+
});
630670
});
631671

632672
describe('renderDynamicField - Array Fields', () => {
@@ -678,6 +718,40 @@ describe('renderDynamicField - Array Fields', () => {
678718
expect(html).toContain('Name');
679719
expect(html).toContain('URL');
680720
});
721+
722+
it('should scope structured array item queries to direct children', () => {
723+
const field = createTestField({
724+
field_type: 'array',
725+
field_options: {
726+
items: {
727+
type: 'object',
728+
properties: {
729+
name: { type: 'text', title: 'Name' },
730+
children: {
731+
type: 'array',
732+
items: {
733+
type: 'object',
734+
properties: {
735+
name: { type: 'text', title: 'Child name' },
736+
},
737+
},
738+
},
739+
},
740+
},
741+
},
742+
});
743+
const html = renderDynamicField(field, {
744+
value: [{ name: 'Parent', children: [{ name: 'Child' }] }],
745+
});
746+
747+
expect(html).toContain(":scope > .structured-array-item");
748+
expect(html).not.toContain("querySelectorAll('.structured-array-item')");
749+
expect(html).toContain(":scope > [data-structured-array-list]");
750+
expect(html).toContain(":scope > input[type=\"hidden\"]");
751+
expect(html).toContain(":scope > template[data-structured-array-template]");
752+
expect(html).toContain(":scope > [data-structured-empty]");
753+
expect(html).toContain('container.__sonicStructuredArrayTemplate');
754+
});
681755
});
682756

683757
describe('renderFieldGroup', () => {

packages/core/src/collections/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ Each field can have:
123123
- `items`: Schema for array items
124124
- `itemTitle`: Optional singular item label used in array item cards/buttons (for example, `"Member"`)
125125
- `properties`: Schema for object properties
126+
- `objectLayout`: Object field layout mode: `'nested'` (default) or `'flat'`
127+
- `collapsed`: For nested object layout, initial state (default: `true`; set `false` to start open)
128+
- Note: `collapsed` is ignored when `objectLayout` is `'flat'`
126129

127130
## Testing Collections
128131

packages/core/src/db/migrations-bundle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* AUTO-GENERATED FILE - DO NOT EDIT
33
* Generated by: scripts/generate-migrations.ts
4-
* Generated at: 2026-04-01T14:08:42.942Z
4+
* Generated at: 2026-04-01T19:44:07.907Z
55
*
66
* This file contains all migration SQL bundled for use in Cloudflare Workers
77
* where filesystem access is not available at runtime.

packages/core/src/services/collection-loader.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,15 @@ export function validateCollectionConfig(config: CollectionConfig): { valid: boo
176176
errors.push(`Reference field "${fieldName}" is missing collection property`)
177177
}
178178

179+
const layoutValue = fieldConfig.objectLayout
180+
if (layoutValue !== undefined) {
181+
if (fieldConfig.type !== 'object') {
182+
errors.push(`Field "${fieldName}" uses objectLayout but is not an object field`)
183+
} else if (!['nested', 'flat'].includes(layoutValue)) {
184+
errors.push(`Object field "${fieldName}" has invalid objectLayout. Use "nested" or "flat"`)
185+
}
186+
}
187+
179188
// Validate select fields
180189
if (['select', 'multiselect', 'radio'].includes(fieldConfig.type) && !fieldConfig.enum) {
181190
errors.push(`Select field "${fieldName}" is missing enum options`)

0 commit comments

Comments
 (0)