Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/studio-group-properties-inspector.md
Original file line number Diff line number Diff line change
@@ -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).
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,16 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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',
Expand Down Expand Up @@ -2167,6 +2177,16 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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': '结束',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
genGroupKey,
addGroup,
renameGroup,
updateGroup,
removeGroup,
moveGroup,
clearFieldGroup,
Expand Down Expand Up @@ -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 ─────────────── */
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> => !!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);
}

Expand Down Expand Up @@ -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>,
): FieldGroup[] {
return groups.map((g) => {
if (g.key !== key) return g;
const next = { ...g } as Record<string, unknown>;
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
<ObjectFormDesigner
draft={draft}
systemFieldNames={new Set()}
onChange={noop}
onSelectField={noop}
onSelectGroup={onSelectGroup}
/>,
);
fireEvent.click(screen.getByLabelText('Group settings'));
expect(onSelectGroup).toHaveBeenCalledWith('contact');
});

it('hides the group-settings affordance when no handler is provided', () => {
render(
<ObjectFormDesigner
draft={draft}
systemFieldNames={new Set()}
onChange={noop}
onSelectField={noop}
/>,
);
expect(screen.queryByLabelText('Group settings')).toBeNull();
});
});
35 changes: 33 additions & 2 deletions packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -186,6 +190,8 @@ function Section({
entryByName,
selectedField,
onSelectField,
selected = false,
onSelect,
onRename,
onDelete,
onMove,
Expand All @@ -201,6 +207,8 @@ function Section({
entryByName: Map<string, FieldEntry>;
selectedField?: string | null;
onSelectField: (name: string) => void;
selected?: boolean;
onSelect?: () => void;
onRename: (label: string) => void;
onDelete: () => void;
onMove: (dir: -1 | 1) => void;
Expand All @@ -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 (
<div className={'@container rounded-lg border ' + (isOver ? 'border-primary bg-primary/5' : 'bg-muted/20')}>
<div className={'@container rounded-lg border ' + stateCls}>
<div className="flex items-center gap-1 border-b px-3 py-1.5">
{isDeclared && !readOnly ? (
<input
Expand All @@ -226,6 +239,20 @@ function Section({
) : (
<span className="flex-1 px-1 text-[13px] font-medium text-muted-foreground">{title}</span>
)}
{isDeclared && onSelect && (
<button
type="button"
onClick={onSelect}
aria-label={t('engine.studio.designer.group.settings', locale)}
title={t('engine.studio.designer.group.settings', locale)}
className={
'rounded p-0.5 hover:bg-muted ' +
(selected ? 'text-primary' : 'text-muted-foreground hover:text-foreground')
}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
)}
{isDeclared && !readOnly && (
<>
<button
Expand Down Expand Up @@ -298,6 +325,8 @@ export function ObjectFormDesigner({
selectedField,
onSelectField,
onAddField,
selectedGroup,
onSelectGroup,
readOnly = false,
}: ObjectFormDesignerProps): React.ReactElement {
const locale = useMetadataLocale();
Expand Down Expand Up @@ -506,6 +535,8 @@ export function ObjectFormDesigner({
entryByName={entryByName}
selectedField={selectedField}
onSelectField={onSelectField}
selected={!isUngrouped && selectedGroup === unCid(c)}
onSelect={!isUngrouped && onSelectGroup ? () => onSelectGroup(unCid(c)) : undefined}
onRename={(label) => renameSection(unCid(c), label)}
onDelete={() => deleteSection(unCid(c))}
onMove={(dir) => moveSection(unCid(c), dir)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { ObjectGroupInspector } from './ObjectGroupInspector';

const draftWith = (group: Record<string, unknown>) => ({ fieldGroups: [group] });

describe('ObjectGroupInspector', () => {
it('renders the group name and edits it via onPatch, preserving other props', () => {
const onPatch = vi.fn();
render(
<ObjectGroupInspector
draft={draftWith({ key: 'billing', label: 'Billing', collapse: 'collapsed' })}
groupKey="billing"
onPatch={onPatch}
onClose={() => {}}
/>,
);
const input = screen.getByTestId('group-label') as HTMLInputElement;
expect(input.value).toBe('Billing');

fireEvent.change(input, { target: { value: 'Billing & Tax' } });
const patch = onPatch.mock.calls[0][0];
// Renaming through the inspector must not wipe the collapse setting.
expect(patch.fieldGroups[0]).toEqual({ key: 'billing', label: 'Billing & Tax', collapse: 'collapsed' });
});

it('surfaces the collapse control and its hint', () => {
render(
<ObjectGroupInspector
draft={draftWith({ key: 'billing', label: 'Billing' })}
groupKey="billing"
onPatch={() => {}}
onClose={() => {}}
/>,
);
expect(screen.getByText('Collapse behavior')).toBeTruthy();
expect(screen.getByText(/actual form/i)).toBeTruthy();
});

it('shows an empty state when the group no longer exists', () => {
render(
<ObjectGroupInspector
draft={draftWith({ key: 'billing', label: 'Billing' })}
groupKey="ghost"
onPatch={() => {}}
onClose={() => {}}
/>,
);
expect(screen.getByText('This group no longer exists.')).toBeTruthy();
});

it('disables editing when readOnly', () => {
render(
<ObjectGroupInspector
draft={draftWith({ key: 'billing', label: 'Billing' })}
groupKey="billing"
onPatch={() => {}}
onClose={() => {}}
readOnly
/>,
);
expect((screen.getByTestId('group-label') as HTMLInputElement).disabled).toBe(true);
});
});
Loading
Loading