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
60 changes: 60 additions & 0 deletions .changeset/retire-standalone-validation-resource-4132.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
'@object-ui/app-shell': patch
---

metadata-admin: retire the standalone `validation` resource, and move
`ValidationPreview` onto the embedded path the framework actually evaluates
(objectui#4132)

`anchors.ts` registered a standalone `validation` resource anchored by
`anchorByField('object')`, complete with `createFields` / `createSchema` /
`createDefaults`. That gave every object's Related tab a "Validations" group
whose `+` routed to `validation/_new`, and the file justified it in a comment:
"usually embedded in the object, but standalone variants do exist."

They do not. ADR-0088 / objectstack#4509 retired `validation` as a metadata
kind, and the framework ledger (`packages/spec/liveness/validation.json`)
records that the door never led anywhere in the first place:

> a STANDALONE `validation` item (file `*.validation.ts` or Studio) never
> reached any object's write path, because the schema has no object-binding key
> and — every variant being `.strict()` — an author could not add one; no merge
> code existed […] A state machine authored through that door saved cleanly and
> gated nothing.

Re-measured against the installed `@objectstack/spec` 17.0.0-rc.6 by parsing the
shipped registries rather than grepping source: `validation` is in neither
`DEFAULT_METADATA_TYPE_REGISTRY` (27 kinds) nor
`listUnregisteredKindSchemaTypes()` (`connector`, `sharing_rule`, `webhook`). So
the console was offering an authoring affordance for a kind the framework does
not have — the "shipped false signpost" shape, where the most confident surface
in the product is the one for the thing that does not work.

**What is gone**: the standalone registration, its create affordance, and the
stale comment. **What stays**: the embedded anchor `__object_validation`
(`editAs: 'validation'`, `embeddedPath: 'validations'`) — rules live inside their
object, and that is the path the framework evaluates.

**The renderer moved rather than retiring with the door.** `ValidationPreview`
renders a rule's label, description, severity/message callout, per-variant body
and tags, and until now the only route that mounted it was `ResourceEditPage`'s
Preview tab on the retired standalone resource; the governed route (Related tab
→ `MetadataDetailDrawer` → `EmbeddedItemEditor`) drew a bare `SchemaForm`.
`EmbeddedItemEditor` now looks a preview up by the anchor's `editAs` and mounts
it above the form on the live draft. The lookup is generic, so this is not a
`validation` special case: an embedded sub-type with no registered preview
(`index`) is unchanged and grows no empty preview chrome. This also re-opens
objectstack#7427's `validation.label` / `.description` / `.tags` ledger rows,
which were graded `dead` precisely because the render was unreachable on the
evaluated path.

**One read went with the door.** `ValidationPreview` painted an
`object: <name>` pill, exempted from the objectui#3275 cleanup on the stated
ground that "anchors.ts registers a standalone `validation` resource … so a
standalone rule really does carry it". With that registration gone the read has
no producer, and it never had one on the governed path either — measured,
`ValidationRuleSchema.safeParse({ type: 'script', …, object: 'account' })`
returns `unrecognized_keys: ['object']`. The pill could only ever confirm a key
that makes the rule unsaveable. Its test case is replaced rather than re-spelled:
the schema's verdict is now the instrument, and the preview is asserted not to
paint the key.
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The embedded editor mounts the registered preview (objectui#4132).
*
* `ValidationPreview` is a complete renderer — rule label, description, the
* severity/message callout, per-variant bodies, tags — and until #4132 the only
* route that mounted it was `ResourceEditPage`'s Preview tab on the STANDALONE
* `validation` resource, i.e. the door ADR-0088 / objectstack#4509 retired. The
* governed path (`object.validations`, which is what the framework actually
* evaluates) goes Related tab → `MetadataDetailDrawer` → `EmbeddedItemEditor`,
* and that rendered a bare `SchemaForm`. Retiring the door without moving the
* renderer would have deleted a working surface from the product; moving it is
* the point of the change (#4132's shape B).
*
* SCOPE OF THIS FILE: `EmbeddedItemEditor` is the entirety of the drawer's
* embedded branch — `MetadataDetailDrawer` renders `<EmbeddedItemEditor …/>`
* and nothing else for `kind: 'embedded'`. It is mounted directly here rather
* than through the drawer because the drawer wraps it in a Radix `Sheet`, which
* does not settle under this project's light DOM setup (measured: the same
* assertions through `<MetadataDetailDrawer>` never completed, while the editor
* alone renders in ~6s). The drawer's half — that it forwards `editAs` to this
* component — is pinned off source text in `anchors.validation-retired.test.ts`,
* so both halves of the path are asserted, neither by a hanging render.
*
* The wiring is deliberately GENERIC — `getMetadataPreview(editAs)` — not a
* `validation` special case. Today only `validation` has both an `editAs` anchor
* and a registered preview, so today it is the only sub-type affected; the
* control below pins that a sub-type WITHOUT a registered preview (`index`)
* still renders its editor and grows no empty preview chrome.
*
* Read points asserted here are exactly the ones objectstack#7427's liveness
* sweep graded `dead` for being unreachable — `label`, `description`, `tags` —
* so this file is the evidence those rows can be re-graded.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';

/**
* Deliberately WITHOUT a `condition` property. `SchemaForm` routes that name
* (`CONDITION_FIELD_NAMES`) to the CEL `ConditionWidget`, whose formula loader
* is a dynamic import — an unbounded module load inside a bounded test window,
* the trap AGENTS.md §测试纪律 describes (measured here: the file never
* settles). The rule's `condition` is still asserted below, where it belongs:
* on the PREVIEW, which reads it straight off the draft.
*/
const VALIDATION_SCHEMA = {
type: 'object',
properties: {
name: { type: 'string', title: 'Name' },
label: { type: 'string', title: 'Label' },
message: { type: 'string', title: 'Error message' },
},
} satisfies Record<string, unknown>;

vi.mock('./useMetadata', () => ({
useMetadataClient: () => ({
layered: async () => ({ effective: {}, code: null, overlay: null, overlayScope: null }),
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,
}),
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [
{
type: 'validation',
label: 'Validation Rule',
allowOrgOverride: true,
schema: VALIDATION_SCHEMA,
},
],
}),
}));

import { EmbeddedItemEditor } from './EmbeddedItemEditor';
import { getMetadataPreview, registerMetadataPreview } from './preview-registry';
import { ValidationPreview } from './previews/ValidationPreview';

// Registered directly rather than via `registerBuiltinPreviews()`: the barrel
// pulls every preview module (flow canvas, dashboard, report…) into this file's
// graph, which is minutes of transform for one form. That the BARREL still
// carries the `validation` line is pinned off its source text in
// `anchors.validation-retired.test.ts`.
registerMetadataPreview('validation', ValidationPreview);

afterEach(cleanup);

/** A spec-shaped script rule as it lives inside `object.validations`. */
const RULE: Record<string, unknown> = {
name: 'amount_positive',
label: 'Amount Must Be Positive',
description: 'Blocks orders whose amount is zero or negative.',
type: 'script',
condition: 'record.amount <= 0',
message: 'Order amount must be greater than zero.',
severity: 'error',
active: true,
events: ['insert', 'update'],
priority: 10,
tags: ['finance', 'guardrail'],
};

function openValidation() {
return render(
<EmbeddedItemEditor
parentType="object"
parentName="sales_order"
embeddedPath="validations"
itemName="amount_positive"
editAs="validation"
initialRaw={RULE}
/>,
);
}

describe('a preview is registered for the embedded sub-type', () => {
it('`validation` still has a registered preview after the standalone retirement', () => {
// Shape B: the door goes, the renderer moves. If this were ever
// unregistered, the assertions below would go green for the wrong reason
// (nothing to mount), so it is asserted before them, not inferred from them.
expect(getMetadataPreview('validation')).toBeTruthy();
});

it('control: a sub-type with no preview is genuinely unregistered', () => {
expect(getMetadataPreview('index')).toBeUndefined();
});
});

describe('the embedded editor renders the preview alongside the form', () => {
it('renders the rule label — objectstack#7427 `validation.label`', () => {
openValidation();
expect(screen.getByText('Amount Must Be Positive')).toBeInTheDocument();
});

it('renders the rule description — `validation.description`', () => {
openValidation();
expect(screen.getByText('Blocks orders whose amount is zero or negative.')).toBeInTheDocument();
});

it('renders the rule tags — `validation.tags`', () => {
openValidation();
expect(screen.getByText('finance')).toBeInTheDocument();
expect(screen.getByText('guardrail')).toBeInTheDocument();
});

it('renders the type-specific body, not just the envelope', () => {
openValidation();
// `script` → the CEL condition block. This is the half that proves the
// whole preview mounted, rather than a stray label somewhere in the form.
expect(screen.getByText('Condition')).toBeInTheDocument();
expect(screen.getByText('record.amount <= 0')).toBeInTheDocument();
// The severity callout carries the message as TEXT (the form carries it as
// an input VALUE, which `getByText` does not see).
expect(screen.getByText('Order amount must be greater than zero.')).toBeInTheDocument();
});

it('ALONGSIDE, not instead of: the SchemaForm is still mounted and editable', () => {
const { container } = openValidation();
// The form's own labels…
expect(screen.getByText('Error message')).toBeInTheDocument();
// …bound to the draft, and the save affordance below it.
expect(screen.getByDisplayValue('Order amount must be greater than zero.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save into object/i })).toBeInTheDocument();
// Not the raw-JSON fallback branch (which renders a textarea instead).
expect(container.querySelector('textarea')).toBeNull();
});
});

describe('sub-types without a registered preview are unchanged (control)', () => {
it('renders the editor with no preview chrome', () => {
render(
<EmbeddedItemEditor
parentType="object"
parentName="sales_order"
embeddedPath="indexes"
itemName="idx_email"
editAs="index"
initialRaw={{ name: 'idx_email', fields: ['email'], unique: true }}
/>,
);
// `index` falls back to EmbeddedItemEditor's inline FALLBACK_SCHEMAS entry,
// so the form is there…
expect(screen.getByText('Fields')).toBeInTheDocument();
// …and nothing preview-shaped is drawn for a type that has no preview.
expect(screen.queryByText('Amount Must Be Positive')).toBeNull();
expect(screen.queryByText(/no preview available/i)).toBeNull();
});
});
28 changes: 28 additions & 0 deletions packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@
*
* If the sub-type isn't registered (e.g. `index` has no `editAs`), we
* fall back to a raw-JSON editor so users can still hand-edit and save.
*
* When the sub-type has a registered Preview renderer, it is mounted above the
* form on the live draft (objectui#4132). Previews used to be reachable only
* from `ResourceEditPage`'s Preview tab, i.e. only for STANDALONE metadata —
* which meant `ValidationPreview` rendered only on the standalone `validation`
* door ADR-0088 retired, and never on `object.validations`, the path the
* framework actually evaluates. The lookup is generic (`getMetadataPreview`),
* so any embedded sub-type that has a preview gets it; one that has none is
* unchanged, with no empty preview chrome.
*/

import * as React from 'react';
import { Loader2, Save, AlertTriangle } from 'lucide-react';
import { Button } from '@object-ui/components';
import { SchemaForm, type SchemaFormIssue } from './SchemaForm';
import { getMetadataPreview } from './preview-registry';
import { useMetadataClient, useMetadataTypes } from './useMetadata';
import { useMetadataLocale, t, tFormat, translateValidationMessage } from './i18n';
import { errorCodeIsAnyOf } from '@object-ui/types';
Expand Down Expand Up @@ -60,6 +70,10 @@ export function EmbeddedItemEditor({
const fallback = !subEntry?.schema && editAs ? FALLBACK_SCHEMAS[editAs] : undefined;
const schema = (subEntry?.schema as Record<string, unknown> | undefined) ?? fallback?.schema;
const form = (subEntry?.form as any) ?? fallback?.form;
// Opt-in per sub-type, exactly like the Preview tab on the full page: a type
// with no registered renderer gets no surface at all (never a "preview not
// available" placeholder).
const Preview = editAs ? getMetadataPreview(editAs) : undefined;

const [draft, setDraft] = React.useState<Record<string, unknown>>(initialRaw);
const [saving, setSaving] = React.useState(false);
Expand Down Expand Up @@ -190,6 +204,20 @@ export function EmbeddedItemEditor({
</div>
)}

{Preview && (
// Read-only: the drawer edits through the form below, so no `onPatch`
// is handed over and `editing` stays false. `draft` is the live value,
// so the preview follows keystrokes the way the full-page tab does.
/* eslint-disable-next-line react-hooks/static-components -- getMetadataPreview returns a registered component (stable), not one created during render */
<Preview
type={editAs as string}
name={itemName}
draft={draft}
locale={locale}
editing={false}
/>
)}

<SchemaForm
schema={schema}
form={form}
Expand Down
53 changes: 12 additions & 41 deletions packages/app-shell/src/views/metadata-admin/anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,47 +342,18 @@ export function registerBuiltinAnchors(): void {
// a `hook` (lifecycle events); async automation is a `record_change` flow.
// Neither anchors here, so there is no standalone "Triggers" group.

// validation: usually embedded in the object, but standalone variants
// do exist. Match anything whose `object` points back at us.
registerMetadataResource({
type: 'validation',
anchors: [{
anchorType: 'object',
match: anchorByField('object'),
groupLabel: 'Validations',
order: 25,
}],
createFields: ['label', 'name', 'message'],
createDerive: [
{ from: 'label', to: 'name', transform: 'slugify', untilUserEdits: true },
],
createSchema: {
type: 'object',
required: ['label', 'name', 'message'],
properties: {
label: { type: 'string', title: 'Label', description: 'Human-readable rule name.' },
name: {
type: 'string',
title: 'Name',
description: 'Machine name (snake_case). Used in URLs.',
pattern: '^[a-z_][a-z0-9_]*$',
},
message: {
type: 'string',
title: 'Error message',
description: 'Shown to the user when the rule blocks save.',
},
},
},
createDefaults: {
type: 'script',
active: true,
events: ['insert', 'update'],
priority: 10,
severity: 'error',
condition: 'false',
},
});
// ADR-0088 / objectstack#4509: `validation` retired as a metadata type. A
// validation rule is embedded in its object (`object.validations`) and only
// there — that anchor is `__object_validation` at the top of this file, and it
// stays. There is no standalone "Validations" group and no create affordance,
// because a standalone rule was never evaluated: the schema has no
// object-binding key, every variant is `.strict()` so an author could not add
// one, and no merge code ever read such an item. It saved cleanly and gated
// nothing (framework ledger, `packages/spec/liveness/validation.json`).
// Re-measured at `@objectstack/spec` 17.0.0-rc.6: `validation` is in neither
// the registered-kind registry nor the unregistered-kind schema list.
// Console history: this file registered that door (with `createFields` /
// `createSchema`) until objectui#4132 removed it.

// permission has a sparse object-keyed map under `objects`. Match by
// membership of the parent name in that map.
Expand Down
Loading
Loading