Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.data.labelPlaceholder': 'Display name (e.g. Repair Ticket)',
'engine.studio.data.idPlaceholder': 'Identifier (e.g. repair_ticket)',
'engine.studio.data.newObject': 'New object',
'engine.studio.data.readOnlyPackage': 'This package is read-only — switch to or create a writable package to add objects',
'engine.studio.data.firstObjectTitle': 'Start with your first object',
'engine.studio.data.firstObjectHint':
'Objects are your app’s data foundation (e.g. “Orders”, “Customers”). Enter a display name and identifier at the bottom-left to create one; then design its fields, forms, and automations, and publish once at the end.',
Expand Down Expand Up @@ -1807,6 +1808,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.data.labelPlaceholder': '显示名(如:报修工单)',
'engine.studio.data.idPlaceholder': '标识符(如:repair_ticket)',
'engine.studio.data.newObject': '新建对象',
'engine.studio.data.readOnlyPackage': '此包为只读——请切换到或新建一个可写的包后再添加对象',
'engine.studio.data.firstObjectTitle': '从第一个对象开始',
'engine.studio.data.firstObjectHint':
'对象是应用的数据基座(如「订单」「客户」)。在左下角输入显示名与标识符即可创建;之后为它设计字段、表单与自动化,最后一次发布。',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';

Expand Down Expand Up @@ -85,6 +86,59 @@ describe('ObjectFieldInspector — duplicate field', () => {
});
});

describe('ObjectFieldInspector — label derives API name until customised', () => {
it('syncs field_<N> (the nextFieldName() auto-name) to the label live, per keystroke', () => {
const { onPatch, onSelectionChange } = renderField(
{ field_2: { type: 'text', label: '' } },
'field_2',
);
fireEvent.change(controlFor('Label'), { target: { value: 'Status' } });
const patch = onPatch.mock.calls.at(-1)![0];
expect(Object.keys(patch.fields)).toEqual(['status']);
expect(patch.fields.status).toMatchObject({ label: 'Status' });
expect(onSelectionChange).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'field', id: 'status' }),
);
});

it('stops deriving once the API name has been hand-edited', () => {
// Simulate the real parent, which feeds each onPatch back into `draft` —
// a single static-props render (like the other cases here) can't observe
// this, since the second edit needs the FIRST edit's rename reflected in
// `entry.name` before it decides whether the name is still auto-generated.
function Stateful() {
const [fields, setFields] = React.useState<Record<string, Record<string, unknown>>>({
field_2: { type: 'text', label: '' },
});
return (
<ObjectFieldInspector
type="object"
name="account"
draft={{ name: 'account', fields }}
selection={{ kind: 'field', id: Object.keys(fields)[0] }}
onPatch={(patch: any) => setFields(patch.fields)}
onClearSelection={() => {}}
onSelectionChange={() => {}}
readOnly={false}
locale={'en-US'}
/>
);
}
render(<Stateful />);
fireEvent.change(controlFor('API name'), { target: { value: 'ticket_status' } });
fireEvent.change(controlFor('Label'), { target: { value: 'Status' } });
expect(controlFor('API name')).toHaveValue('ticket_status');
});

it('leaves an already-meaningful name untouched when the label changes', () => {
const { onPatch } = renderField({ priority: { type: 'text', label: 'Priority' } }, 'priority');
fireEvent.change(controlFor('Label'), { target: { value: 'Urgency' } });
const patch = onPatch.mock.calls.at(-1)![0];
expect(Object.keys(patch.fields)).toEqual(['priority']);
expect(patch.fields.priority.label).toBe('Urgency');
});
});

describe('ObjectFieldInspector — default value', () => {
it('commits a text default for a text field', () => {
const { onPatch } = renderField({ note: { type: 'text', label: 'Note' } }, 'note');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
*/

import * as React from 'react';
import { slugify } from '../createDerive';
import type { MetadataInspectorProps } from '../inspector-registry';
import { MetadataClient } from '@object-ui/data-objectstack';
import { useMetadataClient } from '../useMetadata';
Expand Down Expand Up @@ -195,25 +194,30 @@ export function ObjectFieldInspector({
onSelectionChange?.({ kind: 'field', id: nextName, label: String(def.label ?? nextName) });
};

// Derive the API name from the label (on blur, so we use the complete
// string — not per keystroke, which would churn the field key) while the
// name is still an auto-generated default and the user hasn't customised it.
// Mirrors the object Name behaviour; slugify() returns '' for non-Latin
// labels, in which case the unique default name is kept.
const maybeDeriveName = (label: string) => {
if (readOnly) return;
// Derive the API name from the label live, per keystroke — with
// toFieldNameLoose (prefix-stable, unlike slugify which trims trailing
// underscores and would fight mid-word typing) — while the name is still
// an auto-generated default and the user hasn't customised it. Mirrors the
// object/app Name behaviour. toFieldNameLoose returns '' for non-Latin
// labels, in which case the unique default name is kept. Pure — the
// caller applies the label and (if any) the derived name in one write,
// since two separate writeView() calls from the same stale `entry` closure
// would have the second clobber the first.
const deriveNameFor = (label: string): string | null => {
if (readOnly) return null;
const base = type === 'select' ? 'status' : type;
const isAutoName =
entry.name === base ||
(entry.name.startsWith(`${base}_`) && /^\d+$/.test(entry.name.slice(base.length + 1)));
if (!isAutoName) return;
const derived = slugify(label);
if (!derived || derived === entry.name) return;
if (view.entries.some((e, i) => i !== idx && e.name === derived)) return;
const nextEntries = [...view.entries];
nextEntries[idx] = { ...entry, name: derived };
writeView({ shape: view.shape, entries: nextEntries });
onSelectionChange?.({ kind: 'field', id: derived, label: String(def.label ?? derived) });
(entry.name.startsWith(`${base}_`) && /^\d+$/.test(entry.name.slice(base.length + 1))) ||
// Freshly added fields are named by nextFieldName() as `field_<N>`
// (StudioDesignSurface.tsx), independent of the field's type — match
// that scheme too, or a type-typed rename right after add never derives.
/^field_\d+$/.test(entry.name);
if (!isAutoName) return null;
const derived = toFieldNameLoose(label);
if (!derived || derived === entry.name) return null;
if (view.entries.some((e, i) => i !== idx && e.name === derived)) return null;
return derived;
};

const removeField = () => {
Expand Down Expand Up @@ -319,8 +323,19 @@ export function ObjectFieldInspector({
<InspectorTextField
label={tr('designer.field.label')}
value={typeof def.label === 'string' ? (def.label as string) : ''}
onCommit={(v) => patchDef({ label: v })}
onBlur={maybeDeriveName}
onCommit={(v) => {
const derivedName = deriveNameFor(v);
const nextEntries = [...view.entries];
nextEntries[idx] = {
...entry,
name: derivedName ?? entry.name,
def: { ...def, label: v },
};
writeView({ shape: view.shape, entries: nextEntries });
if (derivedName) {
onSelectionChange?.({ kind: 'field', id: derivedName, label: v });
}
}}
disabled={readOnly}
testId="field-label-input"
/>
Expand Down
20 changes: 11 additions & 9 deletions packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ export interface ObjectFormDesignerProps {
selectedField?: string | null;
/** Select a field → opens the shared field inspector. */
onSelectField: (name: string) => void;
/** Append a new field (reuses the pillar's add-field). */
onAddField: () => void;
/** Append a new field (reuses the pillar's add-field). Omit to hide the button — e.g. a read-only package. */
onAddField?: () => void;
}

/** A faithful, non-interactive preview of a field's control (by type). */
Expand Down Expand Up @@ -423,13 +423,15 @@ export function ObjectFormDesigner({
>
<Plus className="h-3.5 w-3.5" /> 添加分组
</button>
<button
type="button"
onClick={onAddField}
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" /> 添加字段
</button>
{onAddField && (
<button
type="button"
onClick={onAddField}
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" /> 添加字段
</button>
)}
</div>

<DndContext
Expand Down
67 changes: 58 additions & 9 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,29 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
const tab = params.tab ?? 'interfaces';
const locale = useMetadataLocale();

// Courtesy client-side gate (ADR-0057 D10) mirroring the server's
// writable_package_required check (ADR-0070) — read-only (code/installed)
// packages reject authoring writes late, at save time. Default to `true`
// (don't gate) until the fetch resolves or if it fails, so a slow/broken
// packages endpoint never blocks legitimate authoring — the server remains
// the actual authority either way.
const [packageWritable, setPackageWritable] = React.useState(true);
React.useEffect(() => {
let cancelled = false;
fetchPackages()
.then((pkgs) => {
if (cancelled) return;
const current = pkgs.find((p) => p.id === packageId);
setPackageWritable(current ? current.writable : true);
})
.catch(() => {
if (!cancelled) setPackageWritable(true);
});
return () => {
cancelled = true;
};
}, [packageId]);

// Package-level publish (ADR-0033/0037/0048): edits accumulate as per-item
// drafts STAMPED with this package (each save passes packageId → the draft row's
// sys_metadata.package_id). Publishing promotes exactly THIS package's drafts in
Expand Down Expand Up @@ -606,7 +629,12 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React

<div className="min-h-0 flex-1">
{tab === 'data' ? (
<DataPillar packageId={packageId} publishNonce={publishNonce} onDraftSaved={onDraftSaved} />
<DataPillar
packageId={packageId}
publishNonce={publishNonce}
onDraftSaved={onDraftSaved}
writable={packageWritable}
/>
) : tab === 'automations' ? (
<AutomationsPillar packageId={packageId} publishNonce={publishNonce} onDraftSaved={onDraftSaved} />
) : tab === 'access' ? (
Expand Down Expand Up @@ -1350,10 +1378,20 @@ function DataPillar({
packageId,
publishNonce = 0,
onDraftSaved,
writable = true,
}: {
packageId: string;
publishNonce?: number;
onDraftSaved?: () => void;
/**
* Courtesy client-side gate (ADR-0057 D10) mirroring the server's
* writable_package_required check — a read-only (code/installed) package
* rejects authoring writes late, at save time, so hide/disable the "Add
* field" and "New object" affordances up front instead of letting the user
* mutate the canvas and then hit a save-time error. The server remains the
* authority; this only sets expectations.
*/
writable?: boolean;
}): React.ReactElement {
const client = useMetadataClient();
const adapter = useAdapter();
Expand Down Expand Up @@ -1419,7 +1457,7 @@ function DataPillar({
setCurrent((c) => c ?? items[0] ?? null);
// First-run: an empty writable package opens the creator right away —
// the first thing to do here is make an object, so put the inputs up.
if (items.length === 0) setCreating(true);
if (items.length === 0 && writable) setCreating(true);
} catch (e) {
if (!cancelled) setError(formatMetadataError(e));
} finally {
Expand All @@ -1429,7 +1467,7 @@ function DataPillar({
return () => {
cancelled = true;
};
}, [client, packageId]);
}, [client, packageId, writable]);

React.useEffect(() => {
if (!current) return;
Expand Down Expand Up @@ -1478,21 +1516,25 @@ function DataPillar({
}, []);

// "+ add field": append a fresh text field and select it for editing in the panel.
// Guarded by `writable` in addition to being hidden — belt-and-suspenders,
// since it's also wired through GridFieldAuthoringProvider/ObjectFormDesigner.
const addField = React.useCallback(() => {
if (!writable) return;
const view = readFields(objDraft.fields);
const name = nextFieldName(view.entries.map((e) => e.name));
view.entries.push(newField(name, 'text', t('engine.studio.data.newFieldLabel', locale)));
setObjDraft((d) => ({ ...d, fields: writeFields(view) }));
setDirty(true);
setFieldSel({ kind: 'field', id: name });
}, [objDraft]);
}, [objDraft, writable]);

// "+ new object": create a fresh object as a DRAFT in this package (runtime
// create — same path the classic Studio editor uses), seeded with one text
// field so the form/grid isn't empty. It stays draft-only (no physical table)
// until the package publish, so we land on 表单·布局 — the metadata-level
// surface that never fires data SQL.
const doCreateObject = React.useCallback(async () => {
if (!writable) return;
const label = newLabel.trim();
const name = toFieldName(newName.trim() || label);
if (!label || !name || name === 'field') return; // CJK label → identifier must be typed
Expand Down Expand Up @@ -1520,7 +1562,7 @@ function DataPillar({
} finally {
setCreateBusy(false);
}
}, [newLabel, newName, objects, client, packageId, onDraftSaved]);
}, [newLabel, newName, objects, client, packageId, onDraftSaved, writable]);

const doSave = React.useCallback(async () => {
if (!current) return;
Expand Down Expand Up @@ -1640,7 +1682,14 @@ function DataPillar({
))}
</div>
<div className="shrink-0 border-t p-2">
{creating ? (
{!writable ? (
<div
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground/60"
title={t('engine.studio.data.readOnlyPackage', locale)}
>
<Plus className="h-3.5 w-3.5" /> {t('engine.studio.data.newObject', locale)}
</div>
) : creating ? (
<div className="flex flex-col gap-1.5">
<input
autoFocus
Expand Down Expand Up @@ -1772,7 +1821,7 @@ function DataPillar({
? t('engine.studio.data.badge.formLayout', locale)
: t('engine.studio.data.badge.formPreview', locale)}
</span>
{(viewMode === 'grid' || viewMode === 'form') && (
{(viewMode === 'grid' || viewMode === 'form') && writable && (
<button
type="button"
onClick={addField}
Expand Down Expand Up @@ -1825,7 +1874,7 @@ function DataPillar({
<div className="min-h-0 flex-1 overflow-auto rounded-lg border bg-background">
<GridFieldAuthoringProvider
value={{
onAddColumn: addField,
onAddColumn: writable ? addField : undefined,
addColumnLabel: t('engine.studio.data.addField', locale),
onEditColumn: (fieldName) => {
// ignore non-field columns (e.g. the row-actions column)
Expand Down Expand Up @@ -1914,7 +1963,7 @@ function DataPillar({
onChange={onPatch}
selectedField={fieldSel?.kind === 'field' ? fieldSel.id : null}
onSelectField={(name) => setFieldSel({ kind: 'field', id: name })}
onAddField={addField}
onAddField={writable ? addField : undefined}
/>
) : !hasBaseline ? (
/* Draft-only object: there is no published definition to preview yet. */
Expand Down
Loading