diff --git a/.changeset/12866-create-draft-seed.md b/.changeset/12866-create-draft-seed.md new file mode 100644 index 0000000000..ca6053cbd9 --- /dev/null +++ b/.changeset/12866-create-draft-seed.md @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.createDraftSeed-12866.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.createDraftSeed-12866.test.tsx new file mode 100644 index 0000000000..9c46d87b75 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.createDraftSeed-12866.test.tsx @@ -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 }>, +}; + +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 { + 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 }); + 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(); + 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; +/** Read from the seed, never restated. */ +const SEED = getMetadataCreateSeed('dataset') as { label: string; object: string; measures: unknown[] }; + +let entry: Record | undefined; +function datasetEntry(): Record { + 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( + + + } /> + } /> + + , + ); + 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> { + 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(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.seedPlaceholder-12810.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.seedPlaceholder-12810.test.tsx index 6ecaf6a000..c884eb94a3 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.seedPlaceholder-12810.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.seedPlaceholder-12810.test.tsx @@ -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)', () => { diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 5a513af3bf..8f81c0af56 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -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'; @@ -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 } | 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); diff --git a/packages/app-shell/src/views/metadata-admin/createBody.ts b/packages/app-shell/src/views/metadata-admin/createBody.ts index 3384b667c0..5dfa6b0f67 100644 --- a/packages/app-shell/src/views/metadata-admin/createBody.ts +++ b/packages/app-shell/src/views/metadata-admin/createBody.ts @@ -58,6 +58,22 @@ export function seedPlaceholderKeys(seed: Record | 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 | undefined): Record { + 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() === ''); } @@ -95,8 +111,7 @@ export function buildCreateModeBody( if (config.createBuildBody) { return config.createBuildBody(draft) as Record; } - const base = { ...(specCreateSeed ?? config.createDefaults ?? {}) }; - for (const key of seedPlaceholderKeys(specCreateSeed)) delete base[key]; + const base = specCreateSeed ? seedStructuralDefaults(specCreateSeed) : { ...(config.createDefaults ?? {}) }; return { ...base, ...draft }; } diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.firstPickKeepsCount-12848.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.firstPickKeepsCount-12848.test.tsx index 2f892d0395..56c951e92c 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.firstPickKeepsCount-12848.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.firstPickKeepsCount-12848.test.tsx @@ -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) {