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
11 changes: 11 additions & 0 deletions .changeset/11786-autosave-incomplete-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
---

Studio keeps an incomplete field or step as an unsaved edit with a hint, instead of showing a red error before the author can finish it (objectui#11786).

- **Data page.** Switching a field to Picklist (or Radio) before it has an option, or to Lookup (or Master-detail) before its target is picked, no longer draws a red "Changes not saved" strip. The edit stays on screen, unsaved. The inspector says what the field needs under the options editor ("Add at least one option") or under the related-object picker ("Pick the object to link to"), and a neutral line names the field, with "Show me" when its inspector is not open. Adding the option or picking the target saves the change as usual.
- **Automations page.** A step added before its required inputs are filled in, such as a new Notify step without a Title, is no longer sent, so it no longer draws a 422 and a red strip. The step's inspector says "Required" under that input, and the same neutral line names the step and the input.
- **Publish.** While a page holds such an edit, Publish refuses and names the field or step, instead of publishing the last saved drafts without the edit.

Studio decides "incomplete" with the same checks that would refuse the save: the object write guard for fields, and the flow rules from `@objectstack/spec` for steps. A save of a finished draft that is refused still shows the red strip, with the raw text under "Details", as before. Nothing is added to the package entry: no export, prop, type member or language-pack key. The new strings are rows in the metadata-admin designer's own string tables (en and zh).
22 changes: 22 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2589,6 +2589,10 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'designer.field.moveUp': 'Move up',
'designer.field.moveDown': 'Move down',
'designer.field.removeValue': 'Remove',
// objectui#11786 — the inline hint under the input that holds the save: the
// object write guard refuses this field until it is filled in.
'designer.field.hint.addOption': 'Add at least one option. Changes are saved once the field has one.',
'designer.field.hint.pickTarget': 'Pick the object to link to. Changes are saved once the field has one.',
'designer.field.relatedObject': 'Related object',
'designer.field.relationshipName': 'Relationship name',
'designer.field.relationshipNameHint': 'Inverse collection key on the parent',
Expand Down Expand Up @@ -2797,6 +2801,16 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.refusal.navItemInput': '{input} on the navigation item “{item}”',
'engine.studio.refusal.details': 'Details',
'engine.studio.refusal.show': 'Show me',
// objectui#11786 — an edit held, not refused: an incomplete-but-normal body
// (a choice field before its options, a relationship before its target, a
// step before its required inputs) stays on screen unsent. Each `needs*` row
// is a clause the held line and the Publish refusal both build on.
'engine.studio.held.needsOptions': 'the field “{field}” needs at least one option',
'engine.studio.held.needsTarget': 'the field “{field}” needs the object it links to',
'engine.studio.held.needsInput': '{input} on the step “{step}” needs a value',
'engine.studio.held.line': 'Not saved yet: {clause}. Your changes are kept here and saved once it is filled in.',
'engine.studio.held.publish': 'Not published: {clause}, so that change is not saved yet. Finish it, then publish.',
'engine.studio.held.inputHint': 'Required. Changes are saved once this has a value.',
'engine.studio.unpublishedDraft': 'Unpublished draft',
'engine.studio.unpublished': 'Unpublished',
'engine.studio.new': 'New',
Expand Down Expand Up @@ -5598,6 +5612,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'designer.field.moveUp': '上移',
'designer.field.moveDown': '下移',
'designer.field.removeValue': '删除',
'designer.field.hint.addOption': '请至少添加一个选项。字段有选项后才会保存更改。',
'designer.field.hint.pickTarget': '请选择要关联的对象。选定后才会保存更改。',
'designer.field.relatedObject': '关联对象',
'designer.field.relationshipName': '关系名称',
'designer.field.relationshipNameHint': '父对象上的反向集合键',
Expand Down Expand Up @@ -5778,6 +5794,12 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.refusal.navItemInput': '导航项「{item}」的「{input}」',
'engine.studio.refusal.details': '详情',
'engine.studio.refusal.show': '定位',
'engine.studio.held.needsOptions': '字段「{field}」至少需要一个选项',
'engine.studio.held.needsTarget': '字段「{field}」需要指定要关联的对象',
'engine.studio.held.needsInput': '步骤「{step}」的「{input}」需要填写',
'engine.studio.held.line': '尚未保存:{clause}。更改已保留在此处,补全后会自动保存。',
'engine.studio.held.publish': '未发布:{clause},该更改尚未保存。请先补全,再发布。',
'engine.studio.held.inputHint': '必填。填写后才会保存更改。',
'engine.studio.unpublishedDraft': '未发布草稿',
'engine.studio.unpublished': '未发布',
'engine.studio.new': '新建',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -734,28 +734,39 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
// objectui#11660 — `readFieldValue`: a block switch over an
// absent block draws OFF, not its declared default.
: readFieldValue(node, effField);
const required = requiredness.get(field.id)?.required;
return (
<FlowNodeConfigField
key={field.id}
field={effField}
value={value}
onCommit={(v) => setField(field, v)}
disabled={readOnly}
locale={locale}
context={{ draft, node }}
scopeGroups={scopeGroups}
approvalScopeGroups={approvalExpressionGroups}
triggerScope={triggerScope}
// objectui#6499 — a gated field that survived the filter above ONLY
// because it holds a stored value is inert config wearing a live
// control's clothes. Name it, and offer the deliberate clear.
// Computed from `field` (not `effField`): the read is by `path`,
// which the nested-branch rewrite above does not touch.
inactiveRetained={inactiveRetainedKind(field, node, fields, flowKind)}
onClearInactive={readOnly ? undefined : () => setField(field, undefined)}
required={requiredness.get(field.id)?.required}
requiredColumns={requiredness.get(field.id)?.columns}
/>
<React.Fragment key={field.id}>
<FlowNodeConfigField
field={effField}
value={value}
onCommit={(v) => setField(field, v)}
disabled={readOnly}
locale={locale}
context={{ draft, node }}
scopeGroups={scopeGroups}
approvalScopeGroups={approvalExpressionGroups}
triggerScope={triggerScope}
// objectui#6499 — a gated field that survived the filter above ONLY
// because it holds a stored value is inert config wearing a live
// control's clothes. Name it, and offer the deliberate clear.
// Computed from `field` (not `effField`): the read is by `path`,
// which the nested-branch rewrite above does not touch.
inactiveRetained={inactiveRetainedKind(field, node, fields, flowKind)}
onClearInactive={readOnly ? undefined : () => setField(field, undefined)}
required={required}
requiredColumns={requiredness.get(field.id)?.columns}
/>
{/* objectui#11786 — a required input the node leaves out is what holds
Studio's autosave of this step (and what the server refuses), so
it says so under the marker. Absent, not blank: the spec's judges
refuse the key's absence. */}
{required && getFieldValue(node, field) === undefined && (
<p data-testid="flow-field-held-hint" className="-mt-1 text-[11px] leading-snug text-muted-foreground">
{t('engine.studio.held.inputHint', locale)}
</p>
)}
</React.Fragment>
);
})}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ import {
} from '@objectstack/spec/data';
import { ValueDomainSchema } from '@objectstack/spec/shared';
import type { MetadataInspectorProps } from '../inspector-registry.js';
import { MetadataClient } from '@object-ui/data-objectstack';
import {
assertObjectMetadataWritable,
CHOICE_TYPES_REQUIRING_OPTIONS,
MetadataClient,
OBJECT_METADATA_TYPE,
RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
} from '@object-ui/data-objectstack';
import { useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n';
import { useMetadataClient } from '../useMetadata.js';
import {
Expand Down Expand Up @@ -288,6 +294,35 @@ function isLookup(type: string): boolean {
return type === 'lookup' || type === 'master_detail' || type === 'tree';
}

/**
* objectui#11786 — what the object write guard holds this field for: its
* options (a choice type with no option source) or its target (a relationship
* with no usable `reference`), else nothing. Asked of the guard itself, on the
* field alone, so the hint under the input names exactly what keeps the save
* from going: Studio's autosave holds the edit on the same verdict, and the
* other hosts' doors refuse on it.
*/
function guardHeldNeed(name: string, def: Record<string, unknown>): 'options' | 'target' | null {
try {
assertObjectMetadataWritable(OBJECT_METADATA_TYPE, { fields: { [name]: def } }, 'ObjectFieldInspector');
return null;
} catch {
const type = String(def.type);
if (CHOICE_TYPES_REQUIRING_OPTIONS.includes(type)) return 'options';
if (RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(type)) return 'target';
return null;
}
}

/** objectui#11786 — the inline hint under the input that holds the save. */
function HeldHint({ children }: { children: React.ReactNode }) {
return (
<p data-testid="field-held-hint" className="text-[11px] leading-snug text-muted-foreground">
{children}
</p>
);
}

function isComputed(type: string): boolean {
return type === 'formula' || type === 'summary';
}
Expand Down Expand Up @@ -880,6 +915,8 @@ export function ObjectFieldInspector({
);

const typeMetaLabel = typeMeta ? t(`engine.fieldType.${typeMeta.id}`, locale) : undefined;
// objectui#11786 — read off the field as it stands, for the hints below.
const heldNeed = readOnly ? null : guardHeldNeed(entry.name, def);

return (
<InspectorShell
Expand Down Expand Up @@ -1010,6 +1047,7 @@ export function ObjectFieldInspector({
locale={locale}
/>
)}
{heldNeed === 'options' && <HeldHint>{tr('designer.field.hint.addOption')}</HeldHint>}
{isLookup(type) && (
<>
<ObjectPicker
Expand All @@ -1020,6 +1058,7 @@ export function ObjectFieldInspector({
placeholder={tr('designer.field.objectNamePlaceholder')}
locale={locale}
/>
{heldNeed === 'target' && <HeldHint>{tr('designer.field.hint.pickTarget')}</HeldHint>}
<InspectorTextField
label={tr('designer.field.relationshipName')}
value={typeof def.relationshipName === 'string' ? (def.relationshipName as string) : ''}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11786 — on the Studio Automations page, a step just added is held
* until its required inputs are filled, not refused.
*
* The card's measured case: adding a Notify step sent the draft at once
* (`PUT …/meta/flow/NAME?mode=draft`) and the server answered 422, a red strip
* before the author could fill the step in. A fresh Notify (the canvas seed,
* `channels: ['inbox'], recipients: []`) is refused for one key, its `title`:
* the spec's flow parse names `nodes.N.config.title` and nothing else
* (`metadataError.heldEdit-11786.test.ts` measures it on the installed spec).
*
* Now the pillar asks the spec's own judges before sending (`specRequiresField`
* over the inputs the step's inspector offers): the edit stays dirty and
* unsent, the inspector says so under the required input, a neutral line names
* it, and filling it in lets the next autosave go. A server refusal of a
* complete draft keeps objectui#11785's red strip
* (`AutomationsPillar.authorRefusal-11785.test.tsx`, unchanged).
*
* The canvas and inspector are the real registered `FlowPreview` and
* `FlowInspector`; the client double records every save.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor, within, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const PKG = 'com.acme.app';

const FLOW = {
name: 'approval',
label: 'Approval',
type: 'autolaunched',
status: 'obsolete',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
};

const mockClient = vi.hoisted(() => ({
saves: [] as Array<Record<string, unknown>>,
list: vi.fn(async () => [{ name: 'approval', label: 'Approval' }]),
listDrafts: vi.fn(async () => []),
listTypes: vi.fn(async () => ({ entries: [] })),
get: vi.fn(async () => null),
references: vi.fn(async () => []),
layered: vi.fn(async () => ({
code: null,
overlay: null,
overlayScope: null,
effective: JSON.parse(JSON.stringify(FLOW)),
editable: true,
deletable: true,
resettable: false,
lock: 'none',
})),
getDraft: vi.fn(async (type: string, name: string) => {
throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
}),
save: vi.fn(async (_type: string, _name: string, item: unknown) => {
mockClient.saves.push(JSON.parse(JSON.stringify(item)) as Record<string, unknown>);
return {};
}),
publish: vi.fn(async () => ({ success: true })),
reset: vi.fn(async () => ({})),
}));

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => dataSource };
});

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

import { AutomationsPillar } from './StudioDesignSurface';
import { createEmptyDataSource } from './__tests__/emptyDataSource';
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { FlowPreview } from '../metadata-admin/previews/FlowPreview';
import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector';
import { browserClick } from '../metadata-admin/previews/__tests__/browserClick';
import { t, tFormat } from '../metadata-admin/i18n';

const dataSource = createEmptyDataSource();

// The pillar's `/automation/_status` probe, the palette's engine overlay and the
// inspector's catalog reads go through the global `fetch`; "absent" keeps each
// on its documented offline fallback.
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
);

registerMetadataPreview('flow', FlowPreview);
registerMetadataInspector('flow', FlowInspector);

beforeEach(() => {
mockClient.saves.length = 0;
for (const fn of Object.values(mockClient)) {
if (typeof fn === 'function') (fn as unknown as { mockClear: () => void }).mockClear();
}
});

afterEach(() => {
cleanup();
localStorage.clear();
});

/** Past the autosave's 1.5 s debounce. */
async function outlastDebounce(): Promise<void> {
await act(async () => {
await new Promise((r) => setTimeout(r, 2300));
});
}

/** Open the flow and add a Notify step on its one edge, as an author does. */
async function addNotify(): Promise<string> {
render(
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
<AutomationsPillar packageId={PKG} />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: 'Insert node here' }, { timeout: 8000 }));
const option = screen.getAllByRole('option').find((o) => o.textContent?.startsWith('Notify'));
expect(option, 'the palette offers Notify').toBeDefined();
browserClick(option!);
const rail = screen.getByRole('complementary');
const id = (await within(rail).findByLabelText('ID', undefined, { timeout: 8000 })) as HTMLInputElement;
return id.value;
}

const heldLine = (clause: string) => tFormat('engine.studio.held.line', 'en', { clause });

describe('Automations page — a new Notify step is held until it is filled in (objectui#11786)', () => {
it('sends no draft and shows no red strip; Title carries the hint; filling it lets the save go', async () => {
const id = await addNotify();
await outlastDebounce();

expect(mockClient.save, 'a step missing a required input is not sent').not.toHaveBeenCalled();
expect(screen.queryByTestId('studio-refusal'), 'no red strip on the normal path').toBeNull();
const held = screen.getByTestId('studio-held');
expect(within(held).getByTestId('studio-held-message')).toHaveTextContent(
heldLine(tFormat('engine.studio.held.needsInput', 'en', { input: 'Title', step: 'Notify' })),
);
// The step is the one open: the line offers no "Show me".
expect(within(held).queryByRole('button', { name: t('engine.studio.refusal.show', 'en') })).toBeNull();
const rail = screen.getByRole('complementary');
const hints = within(rail).getAllByTestId('flow-field-held-hint');
expect(hints, 'one hint, under the one required input the step leaves out').toHaveLength(1);
expect(hints[0]).toHaveTextContent(t('engine.studio.held.inputHint', 'en'));
expect(hints[0].previousElementSibling).toHaveTextContent(/^Title/);

fireEvent.change(within(rail).getByPlaceholderText('Your request was approved'), {
target: { value: 'Request approved' },
});
await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1), { timeout: 4000 });
const sent = mockClient.saves[0].nodes as Array<Record<string, unknown>>;
expect(sent.find((n) => n.id === id)).toEqual(
expect.objectContaining({ type: 'notify', config: expect.objectContaining({ title: 'Request approved' }) }),
);
await waitFor(() => expect(screen.queryByTestId('studio-held')).toBeNull());
expect(within(rail).queryByTestId('flow-field-held-hint')).toBeNull();
expect(screen.queryByTestId('studio-refusal')).toBeNull();
});

it('"Show me" on the line reopens the held step once another selection closed it', async () => {
const id = await addNotify();
await outlastDebounce();

fireEvent.click(screen.getByRole('button', { name: t('engine.studio.deselect', 'en') }));
const rail = screen.getByRole('complementary');
expect(within(rail).queryByLabelText('ID')).toBeNull();

fireEvent.click(
within(screen.getByTestId('studio-held')).getByRole('button', { name: t('engine.studio.refusal.show', 'en') }),
);
expect(((await within(rail).findByLabelText('ID')) as HTMLInputElement).value).toBe(id);
expect(mockClient.save).not.toHaveBeenCalled();
});
});
Loading
Loading