diff --git a/.changeset/studio-group-properties-inspector.md b/.changeset/studio-group-properties-inspector.md new file mode 100644 index 000000000..9aaf3ed98 --- /dev/null +++ b/.changeset/studio-group-properties-inspector.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': minor +--- + +Studio form designer: select a field group to edit its properties. + +Field groups (sections) in the Data → Form → Layout designer could previously only be renamed inline — there was no way to reach a group's other properties. Each group header now carries a settings affordance that selects the group into a dedicated **Group properties** inspector in the right rail (mirroring the field inspector): edit the group **name** and its **collapse behaviour** — the spec-canonical `collapse` enum (`none` / collapsible-expanded / collapsible-collapsed) that the form renderer consumes via `@objectstack/spec`'s `deriveFieldGroupLayout`, so the setting takes effect in the actual form/preview. + +`readGroups` now preserves all authored group props (icon/description/collapse/…) instead of narrowing to `{key,label}`, so a read-modify-write round-trip (rename/reorder/inspector edit) never silently drops a property the source set. `icon`/`description` are round-trip-preserved but intentionally not surfaced as editable controls yet, since no renderer consumes them (no dead metadata). diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 6b03fb9fa..224ae5231 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1132,6 +1132,16 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.data.form.noPublishedHint': '“Preview” renders the published runtime form, but this object isn’t published yet. Confirm the draft in “Layout”, then click “Publish” in the top bar to preview.', 'engine.studio.data.fieldProps': 'Field properties', + 'engine.studio.data.groupProps': 'Group properties', + 'engine.studio.designer.group.kind': 'Group', + 'engine.studio.designer.group.settings': 'Group settings', + 'engine.studio.designer.group.nameLabel': 'Group name', + 'engine.studio.designer.group.missing': 'This group no longer exists.', + 'engine.studio.designer.group.collapseLabel': 'Collapse behavior', + 'engine.studio.designer.group.collapseNone': 'Not collapsible', + 'engine.studio.designer.group.collapseExpanded': 'Collapsible · expanded by default', + 'engine.studio.designer.group.collapseCollapsed': 'Collapsible · collapsed by default', + 'engine.studio.designer.group.collapseHint': 'Controls how this group appears in the actual form (see Preview).', // Automations pillar 'engine.studio.auto.nodeStart': 'Start', 'engine.studio.auto.nodeEnd': 'End', @@ -2167,6 +2177,16 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.data.form.noPublishedHint': '「预览」渲染已发布的运行态表单,而这个对象还未发布。在「布局」里确认草稿,点顶栏「发布」后即可预览。', 'engine.studio.data.fieldProps': '字段属性', + 'engine.studio.data.groupProps': '分组属性', + 'engine.studio.designer.group.kind': '分组', + 'engine.studio.designer.group.settings': '分组设置', + 'engine.studio.designer.group.nameLabel': '分组名称', + 'engine.studio.designer.group.missing': '该分组已不存在。', + 'engine.studio.designer.group.collapseLabel': '折叠方式', + 'engine.studio.designer.group.collapseNone': '不可折叠', + 'engine.studio.designer.group.collapseExpanded': '可折叠 · 默认展开', + 'engine.studio.designer.group.collapseCollapsed': '可折叠 · 默认折叠', + 'engine.studio.designer.group.collapseHint': '控制该分组在真实表单(预览)中的折叠方式。', // Automations pillar 'engine.studio.auto.nodeStart': '开始', 'engine.studio.auto.nodeEnd': '结束', diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.test.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.test.ts index 01a6d48fc..15849ad2b 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.test.ts @@ -10,6 +10,7 @@ import { genGroupKey, addGroup, renameGroup, + updateGroup, removeGroup, moveGroup, clearFieldGroup, @@ -40,6 +41,18 @@ describe('readGroups', () => { { key: 'b', label: '' }, ]); }); + + it('preserves extra authored props (icon/description/collapse) for round-trips', () => { + // A rename/reorder reads → mutates → writes; if readGroups dropped these, + // an author's `collapse: 'collapsed'` would vanish on the next edit. + expect( + readGroups([ + { key: 'billing', label: 'Billing', icon: 'wallet', description: 'x', collapse: 'collapsed' }, + ]), + ).toEqual([ + { key: 'billing', label: 'Billing', icon: 'wallet', description: 'x', collapse: 'collapsed' }, + ]); + }); }); /* ─────────────── genGroupKey ─────────────── */ @@ -102,6 +115,32 @@ describe('renameGroup', () => { }); }); +/* ─────────────── updateGroup ─────────────── */ + +describe('updateGroup', () => { + const groups = [ + { key: 'a', label: 'A' }, + { key: 'b', label: 'B', collapse: 'collapsed' as const }, + ]; + + it('merges a patch onto the matching group only', () => { + expect(updateGroup(groups, 'a', { collapse: 'expanded', icon: 'user' })).toEqual([ + { key: 'a', label: 'A', collapse: 'expanded', icon: 'user' }, + { key: 'b', label: 'B', collapse: 'collapsed' }, + ]); + }); + + it('removes a property when the patch value is undefined (no stale key)', () => { + const [, b] = updateGroup(groups, 'b', { collapse: undefined }); + expect(b).toEqual({ key: 'b', label: 'B' }); + expect('collapse' in b).toBe(false); + }); + + it('is a no-op for an unknown key', () => { + expect(updateGroup(groups, 'zzz', { label: 'X' })).toEqual(groups); + }); +}); + /* ─────────────── removeGroup / moveGroup ─────────────── */ describe('removeGroup', () => { diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts index b12eb1027..7ea7b5f17 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts @@ -144,17 +144,38 @@ export function toFieldNameLoose(raw: string): string { export interface FieldGroup { key: string; label: string; + /** Optional group icon (Lucide name). Spec-defined; preserved on round-trip. */ + icon?: string; + /** Optional group description. Spec-defined; preserved on round-trip. */ + description?: string; + /** + * Collapse behaviour — the spec-canonical control the form renderer consumes + * (via `@objectstack/spec`'s `deriveFieldGroupLayout`): `'none'` → not + * collapsible; `'expanded'` → collapsible, open by default; `'collapsed'` → + * collapsible, closed by default. + */ + collapse?: 'none' | 'expanded' | 'collapsed'; + /** Legacy boolean aliases (still normalized by the shared derivation). */ + collapsible?: boolean; + collapsed?: boolean; + defaultExpanded?: boolean; } -/** Read `draft.fieldGroups` into a normalized, well-typed list. */ +/** + * Read `draft.fieldGroups` into a normalized, well-typed list. Unknown/extra + * authored props (icon, description, collapse, …) are PRESERVED so a + * read-modify-write round-trip (rename/reorder/inspector edit) never silently + * drops a property the source set — only `key`/`label` are coerced to strings. + */ export function readGroups(fieldGroupsInput: unknown): FieldGroup[] { if (!Array.isArray(fieldGroupsInput)) return []; return fieldGroupsInput - .filter((g): g is { key?: unknown; label?: unknown } => !!g && typeof g === 'object') + .filter((g): g is Record => !!g && typeof g === 'object') .map((g) => ({ + ...g, key: typeof g.key === 'string' ? g.key : '', label: typeof g.label === 'string' ? g.label : '', - })) + }) as FieldGroup) .filter((g) => g.key); } @@ -186,6 +207,27 @@ export function renameGroup(groups: FieldGroup[], key: string, label: string): F return groups.map((g) => (g.key === key ? { ...g, label: clean } : g)); } +/** + * Merge a partial patch onto one group (by key). A patch value of `undefined` + * REMOVES that property (so e.g. resetting collapse to its `'none'` default + * leaves no stale key behind) rather than persisting an explicit `undefined`. + */ +export function updateGroup( + groups: FieldGroup[], + key: string, + patch: Partial, +): FieldGroup[] { + return groups.map((g) => { + if (g.key !== key) return g; + const next = { ...g } as Record; + for (const [k, v] of Object.entries(patch)) { + if (v === undefined) delete next[k]; + else next[k] = v; + } + return next as unknown as FieldGroup; + }); +} + /** Remove a group declaration (callers should also clear members' `group`). */ export function removeGroup(groups: FieldGroup[], key: string): FieldGroup[] { return groups.filter((g) => g.key !== key); diff --git a/packages/app-shell/src/views/studio-design/ObjectFormDesigner.test.tsx b/packages/app-shell/src/views/studio-design/ObjectFormDesigner.test.tsx index c53ca4e05..63f5497f2 100644 --- a/packages/app-shell/src/views/studio-design/ObjectFormDesigner.test.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectFormDesigner.test.tsx @@ -5,8 +5,8 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { ObjectFormDesigner } from './ObjectFormDesigner'; @@ -96,3 +96,37 @@ describe('ObjectFormDesigner — responsive field grid (parity with the runtime expect(plainCard.className).not.toContain('col-span-full'); }); }); + +describe('ObjectFormDesigner — group selection', () => { + const draft = { + fields: [{ name: 'email', type: 'text', label: 'Email', group: 'contact' }], + fieldGroups: [{ key: 'contact', label: 'Contact' }], + }; + + it('exposes a group-settings affordance that selects the group by key', () => { + const onSelectGroup = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText('Group settings')); + expect(onSelectGroup).toHaveBeenCalledWith('contact'); + }); + + it('hides the group-settings affordance when no handler is provided', () => { + render( + , + ); + expect(screen.queryByLabelText('Group settings')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx b/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx index ef4c3bc09..d0c0f26ff 100644 --- a/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx @@ -38,7 +38,7 @@ import { sortableKeyboardCoordinates, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { GripVertical, Plus, Trash2, ChevronUp, ChevronDown, Rows3 } from 'lucide-react'; +import { GripVertical, Plus, Trash2, ChevronUp, ChevronDown, Rows3, Settings2 } from 'lucide-react'; import { inferColumns, containerGridColsFor, isWideFieldType } from '@object-ui/plugin-form'; import { readFields, @@ -73,6 +73,10 @@ export interface ObjectFormDesignerProps { onSelectField: (name: string) => void; /** Append a new field (reuses the pillar's add-field). Omit to hide the button — e.g. a read-only package. */ onAddField?: () => void; + /** Currently selected group key (its section is highlighted). */ + selectedGroup?: string | null; + /** Select a group (section) → opens the group property inspector. Omit to hide the affordance. */ + onSelectGroup?: (key: string) => void; /** Courtesy gate: layout stays viewable, but add/rename/reorder/delete are off. */ readOnly?: boolean; } @@ -186,6 +190,8 @@ function Section({ entryByName, selectedField, onSelectField, + selected = false, + onSelect, onRename, onDelete, onMove, @@ -201,6 +207,8 @@ function Section({ entryByName: Map; selectedField?: string | null; onSelectField: (name: string) => void; + selected?: boolean; + onSelect?: () => void; onRename: (label: string) => void; onDelete: () => void; onMove: (dir: -1 | 1) => void; @@ -211,8 +219,13 @@ function Section({ // `@container` scopes the field grid's container queries to THIS section's // width, so a wide screen spreads fields to the same column count the real // form uses — while a narrow panel collapses to one column. + const stateCls = isOver + ? 'border-primary bg-primary/5' + : selected + ? 'border-primary/50 bg-primary/5 ring-2 ring-primary' + : 'bg-muted/20'; return ( -
+
{isDeclared && !readOnly ? ( {title} )} + {isDeclared && onSelect && ( + + )} {isDeclared && !readOnly && ( <>
- {React.createElement(inspector, { - type: 'object', - name: current.name, - draft: objDraft, - selection: fieldSel, - onPatch, - onClearSelection: () => setFieldSel(null), - onSelectionChange: setFieldSel, - readOnly, - locale, - })} + {fieldSel.kind === 'group' ? ( + setFieldSel(null)} + readOnly={readOnly} + locale={locale} + /> + ) : ( + inspector && + React.createElement(inspector, { + type: 'object', + name: current.name, + draft: objDraft, + selection: fieldSel, + onPatch, + onClearSelection: () => setFieldSel(null), + onSelectionChange: setFieldSel, + readOnly, + locale, + }) + )}
)}