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
5 changes: 5 additions & 0 deletions .changeset/12810-seed-placeholder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': patch
---

Metadata designer create flows no longer save the spec create seed's placeholder identity. The seed's `label` and its placeholder object binding are taken from the author or not at all, so a dataset created without a base object is no longer saved bound to the seed's placeholder object (whose reads were then refused). A placeholder the type requires and the author left unset — a dataset's base object, or a required label — now refuses Save in the browser with a message naming it, and no request is sent. The seed's structural defaults (for example a dataset's `count` measure) are still carried.
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12810 — a dataset created with no base object is refused at the
* client; it is never saved bound to the spec seed's placeholder object.
*
* The REAL page, with the real dataset designer (preview + curated inspector),
* over a REAL `MetadataClient` whose transport is an in-memory server that
* records every `PUT`. The `/meta/types` entry carries what the live server
* serves for `dataset`: the spec's create seed (`getMetadataCreateSeed`) and
* the dataset JSON schema (its `required` list).
*
* (a) no base object picked → Save is refused with a stated reason, and no
* `PUT` is sent;
* (b) a base object picked → the body carries the picked object and the
* author's label, and no placeholder anywhere.
*
* That the seed's structural defaults survive `buildCreateModeBody` is pinned
* in `createBody.seedPlaceholders-12810.test.ts` (here the designer's own
* object picker resets `measures` when the object first changes).
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { z } from 'zod';
import { getMetadataCreateSeed, getMetadataTypeSchema } from '@objectstack/spec/kernel';
import { MetadataClient } from '@object-ui/data-objectstack';

const server = {
puts: [] as Array<{ name: string; body: Record<string, unknown> }>,
};

const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const url = new URL(String(input), 'http://console.test');
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
if (!meta) return json(200, []);
const seg = meta[1].split('/').map(decodeURIComponent);
if (seg[0] === '_drafts') return json(200, { drafts: [] });
if (seg.length === 1) return json(200, { items: [] });
if (method === 'PUT') {
server.puts.push({ name: seg[1]!, body: JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown> });
return json(200, { success: true, version: 'hmac-sha256:v1', seq: 1, state: 'draft', message: `Saved ${seg[0]}/${seg[1]}` });
}
return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } });
}

const client = vi.hoisted(() => ({ current: null as unknown }));

vi.mock('./useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('./useMetadata')>();
return {
...mod,
useMetadataClient: () => client.current,
useMetadataTypes: () => ({
loading: false,
entries: [
{
type: 'dataset',
label: 'Dataset',
domain: 'analytics',
allowEnvironmentOverlay: true,
allowRuntimeCreate: true,
schema: DATASET_JSON_SCHEMA,
createSeed: getMetadataCreateSeed('dataset'),
},
],
}),
};
});

import { MetadataResourceEditPage } from './ResourceEditPage';
import { registerDefaultMetadataSchemas } from './default-schemas';
import { registerBuiltinPreviews } from './previews';
import { registerBuiltinInspectors } from './inspectors';

const DATASET_JSON_SCHEMA = z.toJSONSchema(getMetadataTypeSchema('dataset') as z.ZodType, {
io: 'input',
unrepresentable: 'any',
}) as Record<string, unknown>;
/** The spec seed's placeholder object, read from the seed (never restated). */
const PLACEHOLDER = (getMetadataCreateSeed('dataset') as { object: string }).object;

client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });

registerDefaultMetadataSchemas();
registerBuiltinPreviews();
registerBuiltinInspectors();

beforeEach(() => {
server.puts = [];
window.localStorage.setItem('metadata-admin:autosave', '0');
});
afterEach(() => {
cleanup();
window.localStorage.removeItem('metadata-admin:autosave');
});

const saveButton = () => screen.getByRole('button', { name: /Save \(⌘S\)/ });

/** Open a new dataset, type its Label (which derives the Name). */
async function openNewDataset(label: string) {
render(
<MemoryRouter initialEntries={['/metadata/dataset/new']}>
<Routes>
<Route path="/metadata/dataset/new" element={<MetadataResourceEditPage type="dataset" name="" createMode />} />
<Route path="/metadata/dataset/:name" element={<div data-testid="landed" />} />
</Routes>
</MemoryRouter>,
);
const labelBox = await screen.findByRole('textbox', { name: 'Label' }, { timeout: 8000 });
fireEvent.change(labelBox, { target: { value: label } });
fireEvent.blur(labelBox);
await waitFor(() => expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('probe_ds'));
}

describe('dataset create with no base object (objectui#12810)', () => {
it('(a) Save is refused at the client, naming the unset base object, and no PUT is sent', async () => {
expect(DATASET_JSON_SCHEMA.required).toContain('object');
await openNewDataset('Probe Ds');
fireEvent.click(saveButton());
const alert = await screen.findByText(/before saving: a new item is never saved with the placeholder/, {}, { timeout: 8000 });
expect(alert.textContent).toContain('object');
// Give any stray save a chance to land before reading the server's log.
await new Promise((r) => setTimeout(r, 50));
expect(server.puts).toEqual([]);
expect(screen.queryByTestId('landed')).toBeNull();
});

it('(b) with a base object picked, the body carries the picked object', async () => {
await openNewDataset('Probe Ds');
const baseObject = screen.getByRole('combobox', { name: 'Base object' });
fireEvent.click(baseObject);
const search = await screen.findByPlaceholderText(/Search objects/i);
fireEvent.change(search, { target: { value: 'crm_account' } });
fireEvent.keyDown(search, { key: 'Enter' });
await waitFor(() => expect(baseObject).toHaveTextContent('crm_account'));
fireEvent.click(saveButton());
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 });
const body = server.puts[0]!.body;
expect(server.puts[0]!.name).toBe('probe_ds');
expect(body.object).toBe('crm_account');
expect(body.label).toBe('Probe Ds');
expect(JSON.stringify(body)).not.toContain(PLACEHOLDER);
});
});
24 changes: 23 additions & 1 deletion packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ import { useMetadataLocale, t, tFormat, translateMetadataType, translateValidati
import { JsonSourceEditor } from './JsonSourceEditor.js';
import { validateMetadataDraft, hasClientValidator, type DraftMode } from './clientValidation.js';
import { describeIssuePath, issueSlicePath, issueSliceFingerprint } from './issuePath.js';
import { buildCreateModeBody } from './createBody.js';
import { buildCreateModeBody, unsetSeedPlaceholders } from './createBody.js';
import { errorCodeIs, errorCodeIsAnyOf } from '@object-ui/types';
import { useCanAuthorMetadata, useCanReadAuthoringTrail } from '../../hooks/useCanAuthorMetadata.js';
import { environmentScopeSuffix, isEnvironmentScope, useWalledCatalogReadOnly } from './catalog-scope.js';
Expand Down Expand Up @@ -1657,6 +1657,28 @@ function MetadataResourceEditPageImpl({
setSaving(false);
return;
}
// objectui#12810 — a create never saves the seed's placeholder identity
// (`buildCreateModeBody` leaves it out). One the author left unset that
// the type requires — a dataset's base object — is refused HERE, named,
// with no request sent, as an empty name is above.
const unsetPlaceholders = createMode
? unsetSeedPlaceholders(
config,
itemToSave as Record<string, unknown>,
specCreateSeed,
(entry?.schema as { required?: unknown } | undefined)?.required,
identityField,
)
: [];
if (unsetPlaceholders.length > 0) {
setError(
tFormat('engine.validation.seedPlaceholderUnset', locale, {
fields: unsetPlaceholders.map((key) => `“${labelForIssuePath(key)}”`).join(', '),
}),
);
setSaving(false);
return;
}
// Save lands in the draft buffer — the runtime keeps serving the
// last published version until the operator clicks Publish. The
// backend defaults to publish mode for backward-compatibility, so
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12810 — the enumeration pin over every spec create seed.
*
* The spec's create-seed module says each seed carries placeholder identity
* (`name` / `label` / object binding) and that "a create flow overrides those
* from user input". For EVERY entry `listMetadataCreateSeedTypes()` lists, this
* file asserts that each placeholder identity the seed carries is either
* overridden from user input by `buildCreateModeBody` (the author's value, or
* none — never the seed's) or refused at save (`unsetSeedPlaceholders`, for a
* key the type requires; an empty `name` has the save path's own refusal).
*
* `PLACEHOLDERS` below is the per-type status listing. A seed added later, or
* a seed that gains a placeholder, turns the first test red until it is
* listed here, and the rest then judge it. A placeholder nested where
* `buildCreateModeBody` cannot take it out (it only drops top-level keys)
* turns the second test red.
*
* The placeholder values are read from the spec (`getMetadataCreateSeed`),
* never restated. `required` is read from the type's spec schema as JSON
* schema, the shape the live `/meta/types` entry serves.
*/

import { beforeAll, describe, expect, it } from 'vitest';
import { z } from 'zod';
import {
getMetadataCreateSeed,
getMetadataTypeSchema,
listMetadataCreateSeedTypes,
} from '@objectstack/spec/kernel';
import {
buildCreateModeBody,
seedPlaceholderKeys,
specSeedPlaceholderObjects,
unsetSeedPlaceholders,
} from './createBody';
import { registerBuiltinAnchors } from './anchors';
import { registerDefaultMetadataSchemas } from './default-schemas';
import { resolveResourceConfig } from './registry';

/**
* Per seed type: its placeholder identity keys, and what happens to each when
* the author leaves it unset — `refused` (the save is refused at the client),
* `name` (the save path's empty-name refusal), `omitted` (not required by the
* type: saved without it), or `built` (the type's `createBuildBody` builds the
* body from the draft alone; the seed is not read).
*/
const PLACEHOLDERS: Record<string, Record<string, 'refused' | 'name' | 'omitted' | 'built'>> = {
action: { name: 'name', label: 'refused' },
agent: { name: 'name', label: 'refused' },
dashboard: { name: 'name', label: 'refused' },
dataset: { name: 'name', label: 'refused', object: 'refused' },
email_template: { name: 'name', label: 'refused' },
flow: { name: 'name', label: 'refused' },
hook: { name: 'name', label: 'omitted', object: 'refused' },
mapping: { name: 'name', label: 'omitted', targetObject: 'refused' },
object: { name: 'name', label: 'omitted' },
page: { name: 'name', label: 'refused', object: 'omitted' },
permission: { name: 'name', label: 'omitted' },
skill: { name: 'name', label: 'refused' },
tool: { name: 'name', label: 'refused' },
translation: { name: 'name', label: 'omitted' },
view: { name: 'built', object: 'built', label: 'built' },
};

const seedOf = (type: string) => getMetadataCreateSeed(type) as Record<string, unknown>;

function requiredOf(type: string): string[] {
const json = z.toJSONSchema(getMetadataTypeSchema(type) as z.ZodType, {
io: 'input',
unrepresentable: 'any',
}) as { required?: string[] };
return json.required ?? [];
}

/** Every path in `value` whose leaf is one of the spec's placeholder objects. */
function placeholderObjectPaths(value: unknown, path: string[] = []): string[] {
const objects = specSeedPlaceholderObjects();
if (typeof value === 'string') {
// A qualified name built from the placeholder (`<placeholder>.new_view`).
const head = value.split('.')[0]!;
return objects.has(value) || objects.has(head) ? [path.join('.')] : [];
}
if (Array.isArray(value)) return value.flatMap((v, i) => placeholderObjectPaths(v, [...path, String(i)]));
if (value && typeof value === 'object') {
return Object.entries(value).flatMap(([k, v]) => placeholderObjectPaths(v, [...path, k]));
}
return [];
}

/** The draft a create form starts from, as the page builds it. */
function pristineDraft(type: string): Record<string, unknown> {
const config = resolveResourceConfig(type);
return { ...(config.createDefaults ?? {}), [config.identityField ?? 'name']: '' };
}

beforeAll(() => {
registerDefaultMetadataSchemas();
registerBuiltinAnchors();
});

describe('every spec create seed: placeholder identity is overridden or refused (objectui#12810)', () => {
it('the spec placeholder object is read from the seeds, and every seed type is listed with its placeholders', () => {
expect(specSeedPlaceholderObjects().size).toBe(1);
const types = listMetadataCreateSeedTypes();
expect(types.length).toBeGreaterThan(0);
const listed = Object.fromEntries(types.map((type) => [type, seedPlaceholderKeys(seedOf(type)).sort()]));
const expected = Object.fromEntries(
Object.entries(PLACEHOLDERS).map(([type, keys]) => [type, Object.keys(keys).sort()]),
);
expect(listed).toEqual(expected);
});

it('a placeholder object sits only where the create flow takes it out', () => {
for (const type of listMetadataCreateSeedTypes()) {
const seed = seedOf(type);
const config = resolveResourceConfig(type);
const topLevel = new Set(seedPlaceholderKeys(seed));
for (const path of placeholderObjectPaths(seed)) {
const handled = !!config.createBuildBody || topLevel.has(path);
expect({ type, path, handled }).toEqual({ type, path, handled: true });
}
}
});

it.each(Object.keys(PLACEHOLDERS))(
'%s: an author who sets nothing gets no seed placeholder in the body, and a required one is refused',
(type) => {
const seed = seedOf(type);
const config = resolveResourceConfig(type);
const identityField = config.identityField ?? 'name';
const draft = pristineDraft(type);
const body = buildCreateModeBody(config, draft, seed);

// No placeholder value survives, anywhere in the body.
expect(placeholderObjectPaths(body)).toEqual([]);
for (const key of Object.keys(PLACEHOLDERS[type]!)) {
if (typeof seed[key] === 'string' && seed[key] !== '') {
expect({ type, key, value: body[key] }).not.toEqual({ type, key, value: seed[key] });
}
}

const refused = unsetSeedPlaceholders(config, body, seed, requiredOf(type), identityField);
for (const [key, fate] of Object.entries(PLACEHOLDERS[type]!)) {
if (fate === 'refused') expect(refused).toContain(key);
else expect(refused).not.toContain(key);
if (fate === 'name') expect(body[key]).toBe('');
if (fate === 'omitted') expect(body).not.toHaveProperty(key);
}
},
);

it.each(Object.keys(PLACEHOLDERS))(
'%s (control): the author\'s values are carried, nothing is refused, and the seed\'s structural defaults are kept',
(type) => {
const seed = seedOf(type);
const config = resolveResourceConfig(type);
const identityField = config.identityField ?? 'name';
const authored: Record<string, unknown> = { ...pristineDraft(type) };
for (const key of Object.keys(PLACEHOLDERS[type]!)) {
authored[key] = key === 'label' ? 'Probe Item' : key === 'name' ? 'probe_item' : 'crm_account';
}
const body = buildCreateModeBody(config, authored, seed);
expect(unsetSeedPlaceholders(config, body, seed, requiredOf(type), identityField)).toEqual([]);
expect(placeholderObjectPaths(body)).toEqual([]);
if (config.createBuildBody) return;
for (const key of Object.keys(PLACEHOLDERS[type]!)) expect(body[key]).toBe(authored[key]);
// Every non-placeholder seed default the draft does not set is kept.
for (const [key, value] of Object.entries(seed)) {
if (key in PLACEHOLDERS[type]! || key in authored) continue;
expect({ type, key, value: body[key] }).toEqual({ type, key, value });
}
},
);

it('dataset: the seed\'s `count` measure is kept while its placeholder binding is not', () => {
const seed = seedOf('dataset');
const body = buildCreateModeBody(resolveResourceConfig('dataset'), { name: 'probe_ds', label: 'Probe Ds' }, seed);
expect(body.measures).toEqual(seed.measures);
expect(body.dimensions).toEqual(seed.dimensions);
expect(body).not.toHaveProperty('object');
});
});
Loading
Loading