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/12866-create-draft-seed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': patch
---

Metadata designer create flows now show the spec create seed's structural defaults before the first save. A new dataset's Measures section reads 1 (the seed's `count` measure) as soon as the type registry answers, which is the measure the save already wrote. Only keys the draft does not yet hold are filled, so nothing the author set first is overwritten, and the seed's placeholder identity (its `label` and placeholder object binding) still never reaches the draft or the saved body.
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12866 — a new dataset's designer shows the spec create seed's
* structural defaults before the first save, and they are what the save writes.
*
* 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. The live `useMetadataTypes` reads that registry
* AFTER the page's first render, so the entry here can be held back and
* released, as the live one arrives.
*
* (a) Measures reads 1 (`count`) before the first save, and the saved body
* carries the same measures — with the entry served at mount, and with
* it arriving after the first render;
* (b) CONTROL: none of the seed's placeholder identity (its `label`, its
* placeholder `object`) reaches the draft;
* (c) a measure the author added before the entry arrived is not
* overwritten when it does.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, 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 }));
/** Whether the type registry has answered; `release()` answers it later. */
const registry = vi.hoisted(() => ({
servedAtMount: true,
release: null as null | (() => void),
}));

vi.mock('./useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('./useMetadata')>();
const { useState } = await import('react');
return {
...mod,
useMetadataClient: () => client.current,
useMetadataTypes: () => {
const [served, setServed] = useState(registry.servedAtMount);
registry.release = () => setServed(true);
return { loading: !served, error: null, entries: served ? [datasetEntry()] : [] };
},
};
});

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>;
/** Read from the seed, never restated. */
const SEED = getMetadataCreateSeed('dataset') as { label: string; object: string; measures: unknown[] };

let entry: Record<string, unknown> | undefined;
function datasetEntry(): Record<string, unknown> {
entry ??= {
type: 'dataset',
label: 'Dataset',
domain: 'analytics',
allowEnvironmentOverlay: true,
allowRuntimeCreate: true,
schema: DATASET_JSON_SCHEMA,
createSeed: getMetadataCreateSeed('dataset'),
};
return entry;
}

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

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

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

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

/** The count badge beside the inspector's "Measures" section title. */
function measuresBadge(): string | null | undefined {
const title = screen.getAllByText('Measures').find((el) => el.nextElementSibling);
return title?.nextElementSibling?.textContent;
}

async function openNewDataset() {
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>,
);
return screen.findByRole('textbox', { name: 'Label' }, { timeout: 8000 });
}

async function nameAndPickObject(labelBox: HTMLElement) {
fireEvent.change(labelBox, { target: { value: 'Probe Ds' } });
fireEvent.blur(labelBox);
// The dataset's own Name comes first; each measure row has a Name too.
await waitFor(() => expect(screen.getAllByRole('textbox', { name: 'Name' })[0]).toHaveValue('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'));
}

async function saveBody(): Promise<Record<string, unknown>> {
fireEvent.click(saveButton());
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 });
return server.puts[0]!.body;
}

describe('a new dataset shows the create seed\'s measures before the first save (objectui#12866)', () => {
it('(a) served at mount: Measures reads 1 (`count`) before save, and the save writes the same', async () => {
expect(SEED.measures).toEqual([{ name: 'count', aggregate: 'count' }]);
const labelBox = await openNewDataset();
await waitFor(() => expect(measuresBadge()).toBe('1'));
expect(screen.getByDisplayValue('count')).toBeInTheDocument();
await nameAndPickObject(labelBox);
expect(measuresBadge()).toBe('1');
const body = await saveBody();
expect(body.measures).toEqual(SEED.measures);
});

it('(a) arriving after the first render: Measures reads 0, then 1 (`count`) once the registry answers', async () => {
registry.servedAtMount = false;
const labelBox = await openNewDataset();
expect(measuresBadge()).toBe('0');
act(() => registry.release!());
await waitFor(() => expect(measuresBadge()).toBe('1'));
await nameAndPickObject(labelBox);
const body = await saveBody();
expect(body.measures).toEqual(SEED.measures);
});

it('(b) CONTROL: the seed\'s placeholder identity never reaches the draft', async () => {
const labelBox = await openNewDataset();
await waitFor(() => expect(measuresBadge()).toBe('1'));
expect(labelBox).toHaveValue('');
expect(screen.queryByDisplayValue(SEED.label)).toBeNull();
expect(screen.getByRole('combobox', { name: 'Base object' })).not.toHaveTextContent(SEED.object);
expect(document.body.textContent).not.toContain(SEED.object);
});

it('(c) a measure the author added before the registry answered is kept when it does', async () => {
registry.servedAtMount = false;
await openNewDataset();
fireEvent.click(screen.getByRole('button', { name: /Add measure/ }));
await waitFor(() => expect(measuresBadge()).toBe('1'));
// Releasing the entry runs the page's effects inside `act`, so a seeding
// that overwrote the author's measure would already show here.
act(() => registry.release!());
await new Promise((r) => setTimeout(r, 50));
expect(measuresBadge()).toBe('1');
expect(screen.queryByDisplayValue('count')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ async function openNewDataset(label: string) {
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'));
// The dataset's own Name comes first; the seed's `count` measure row, in the
// draft from the start (objectui#12866), has a Name too.
await waitFor(() => expect(screen.getAllByRole('textbox', { name: 'Name' })[0]).toHaveValue('probe_ds'));
}

describe('dataset create with no base object (objectui#12810)', () => {
Expand Down
28 changes: 27 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, unsetSeedPlaceholders } from './createBody.js';
import { buildCreateModeBody, seedStructuralDefaults, 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 @@ -552,6 +552,32 @@ function MetadataResourceEditPageImpl({
},
[],
);
// objectui#12866 — a create draft carries the spec create seed's STRUCTURAL
// defaults (a dataset's `count` measure), so the designer shows before the
// first save what the save writes. The seed rides the `/meta/types` entry,
// which `useMetadataTypes` reads AFTER the first render, so the draft is
// built without it and the defaults are folded in once, when it arrives.
// Only keys the draft does not hold yet are added: anything the author (or
// `createDefaults`, which `buildCreateModeBody` lets the draft win over)
// already set is left alone, and an absent key is exactly the one the save
// fills from the seed. The seed's placeholder identity stays out, as it
// stays out of the save body (`seedStructuralDefaults`, objectui#12810). A
// type with `createBuildBody` builds its own body and is not seeded.
const specCreateSeedForDraft = (entry as { createSeed?: Record<string, unknown> } | undefined)?.createSeed;
const draftSeededRef = React.useRef(false);
React.useEffect(() => {
if (!createMode || draftSeededRef.current || config.createBuildBody || !specCreateSeedForDraft) return;
draftSeededRef.current = true;
const defaults = seedStructuralDefaults(specCreateSeedForDraft);
setDraft((prev) => {
const missing = Object.keys(defaults).filter((key) => !(key in prev));
if (missing.length === 0) return prev;
const next = { ...prev };
// A copy: the designer must never hold (and patch) the registry entry's own arrays.
for (const key of missing) next[key] = structuredClone(defaults[key]);
return next;
});
}, [createMode, config.createBuildBody, specCreateSeedForDraft]);
const [destructiveIssues, setDestructiveIssues] = React.useState<
null | Array<{ kind?: string; path?: string; message?: string }>
>(null);
Expand Down
19 changes: 17 additions & 2 deletions packages/app-shell/src/views/metadata-admin/createBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ export function seedPlaceholderKeys(seed: Record<string, unknown> | undefined):
});
}

/**
* The STRUCTURAL defaults a create seed carries: the seed without its
* placeholder identity ({@link seedPlaceholderKeys}). A dataset's seed gives
* `{ dimensions: [], measures: [{ name: 'count', aggregate: 'count' }] }`.
*
* One derivation, two readers: {@link buildCreateModeBody} spreads it under the
* draft at save, and the create page puts the same keys into its initial draft
* (objectui#12866), so the designer shows before the first save what the save
* writes.
*/
export function seedStructuralDefaults(seed: Record<string, unknown> | undefined): Record<string, unknown> {
const base = { ...(seed ?? {}) };
for (const key of seedPlaceholderKeys(seed)) delete base[key];
return base;
}

function isBlank(value: unknown): boolean {
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
}
Expand Down Expand Up @@ -95,8 +111,7 @@ export function buildCreateModeBody(
if (config.createBuildBody) {
return config.createBuildBody(draft) as Record<string, unknown>;
}
const base = { ...(specCreateSeed ?? config.createDefaults ?? {}) };
for (const key of seedPlaceholderKeys(specCreateSeed)) delete base[key];
const base = specCreateSeed ? seedStructuralDefaults(specCreateSeed) : { ...(config.createDefaults ?? {}) };
return { ...base, ...draft };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ async function openNewDataset(label: string) {
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'));
// The dataset's own Name comes first; the seed's `count` measure row, in the
// draft from the start (objectui#12866), has a Name too.
await waitFor(() => expect(screen.getAllByRole('textbox', { name: 'Name' })[0]).toHaveValue('probe_ds'));
}

async function pickBaseObject(objectName: string) {
Expand Down
Loading