From f408d892b3efd9c28fc543ad44ef6b01fd417a9a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:53:44 +0000 Subject: [PATCH 1/2] fix(ui): record:details takes field types from the host's object schema when no dataSource answers The page designer canvas mounts the runtime record:details under a record context that holds the bound object's schema but no dataSource, so DetailView never learned the field types: an authored bare-name section drew a select's stored code and a location as [Object]. Under recordFromHost the host's schema now stands in until a dataSource answers; nothing new is fetched. Claude-Session: https://claude.ai/code/session_01JQwxbYUSW5btC4i3GbWXGe Co-authored-by: Claude --- ...eview.authoredDetailsValues-27177.test.tsx | 146 ++++++++++++++++++ ui/packages/plugin-detail/src/DetailView.tsx | 16 +- 2 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.authoredDetailsValues-27177.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.authoredDetailsValues-27177.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.authoredDetailsValues-27177.test.tsx new file mode 100644 index 00000000000..64047fa8beb --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.authoredDetailsValues-27177.test.tsx @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The page designer's Design canvas draws an authored `record:details` body's + * values the way the record page does. + * + * A `full` record page names its detail fields (the full-create seed writes + * `sections[].fields` as bare names). The canvas mounts the runtime + * `record:details` renderer under a record context that holds the bound + * object's schema but no dataSource, as every Studio preview does. The field + * types come from that schema: without it a `select` drew its stored code and a + * `location` drew `[Object]`. + * + * Everything here is real: the canvas, the record context, `record:details`, + * `DetailView` and the `@object-ui/fields` cell renderers. Only the two + * endpoints `PagePreview` reads (the object schema and the sample records) are + * answered by a double. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +// Registers `record:details`, which the canvas resolves through the registry. +import { buildDefaultPageSchema } from '@object-ui/plugin-detail'; + +/** Stable stub identity: `PagePreview` carries `perms` in an effect dep list. */ +const { permsStub } = vi.hoisted(() => ({ + permsStub: { + isLoaded: true, + checkField: () => true, + check: () => ({ allowed: true }), + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + userId: null, + systemPermissions: undefined, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }, +})); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, usePermissions: () => permsStub as any }; +}); + +import { PagePreview } from './PagePreview'; +import { + deriveStudioDetailSections, + toAuthoredDetailSections, + MORE_DETAILS_DEFAULT_LABEL, +} from '../../recordDetailSections'; + +const OBJECT = 'showcase_account'; +const OBJECT_DEF = { + name: OBJECT, + label: 'Account', + fields: { + name: { type: 'text', label: 'Account Name' }, + account_code: { type: 'text', label: 'Account Code' }, + sales_region: { + type: 'select', + label: 'Sales Region', + options: [ + { value: 'amer', label: 'Americas' }, + { value: 'emea', label: 'Europe' }, + ], + }, + headquarters: { type: 'location', label: 'Headquarters' }, + }, +}; +const RECORD = { + id: 'acc_1', + name: 'Northwind', + account_code: 'NW-001', + sales_region: 'amer', + headquarters: { latitude: 37.7749, longitude: -122.4194 }, +}; + +/** The page the full-create seed writes for this object: authored, named fields. */ +function fullCreateSeed(): Record { + const sections = toAuthoredDetailSections( + deriveStudioDetailSections(OBJECT_DEF as any, { moreDetails: MORE_DETAILS_DEFAULT_LABEL }), + ); + const synth = buildDefaultPageSchema(OBJECT_DEF as any, sections ? { sections: sections as any } : undefined) as { + regions: unknown[]; + template?: unknown; + }; + return { + name: 'account_page', + label: 'Account page', + type: 'record', + object: OBJECT, + kind: 'full', + regions: synth.regions, + ...(synth.template !== undefined ? { template: synth.template } : {}), + }; +} + +function stubFetch() { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.startsWith('/api/v1/meta/object/')) return { ok: true, json: async () => ({ item: OBJECT_DEF }) } as any; + if (url.startsWith(`/api/v1/data/${OBJECT}`)) return { ok: true, json: async () => ({ records: [RECORD] }) } as any; + // The record-edit probe and anything else the real blocks ask for. + return { ok: true, json: async () => ({}) } as any; + })); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('PagePreview Design canvas: an authored record:details draws typed values', () => { + it('names its detail fields as bare names (the premise this file pins)', () => { + const seed = JSON.stringify(fullCreateSeed()); + expect(seed).toContain('"record:details"'); + expect(seed).toContain('"sales_region"'); + expect(seed).not.toContain('"type":"select"'); + }); + + it('draws a select by its option label and a location as coordinates, never the raw value', async () => { + stubFetch(); + render( + {}} + onPatch={vi.fn() as any} + locale="en-US" + />, + ); + + // Control: a plain text field reads as stored, before and after the fix. + await waitFor(() => expect(screen.getByText('NW-001')).toBeTruthy()); + await waitFor(() => expect(screen.getByText('Americas')).toBeTruthy()); + expect(screen.getByText(/37\.7749, -122\.4194/)).toBeTruthy(); + expect(screen.queryByText('amer')).toBeNull(); + expect(document.body.textContent).not.toContain('[Object]'); + }); +}); diff --git a/ui/packages/plugin-detail/src/DetailView.tsx b/ui/packages/plugin-detail/src/DetailView.tsx index 7f2ff303aaf..b9030ab7af6 100644 --- a/ui/packages/plugin-detail/src/DetailView.tsx +++ b/ui/packages/plugin-detail/src/DetailView.tsx @@ -45,7 +45,7 @@ import { ActivityTimeline } from './ActivityTimeline'; import { HistoryTimeline } from './HistoryTimeline'; import { RecordMetaFooter } from './RecordMetaFooter'; import { RecordPageProvenanceContext } from './recordPageProvenance'; -import { SchemaRenderer, SchemaErrorBoundary, toRenderableSchema, useSafeFieldLabel, useDataInvalidation, useInlineEdit, useRowPredicate, resolveApprovalLock, usePrintPage } from '@object-ui/react'; +import { SchemaRenderer, SchemaErrorBoundary, toRenderableSchema, useSafeFieldLabel, useDataInvalidation, useInlineEdit, useRowPredicate, resolveApprovalLock, usePrintPage, useRecordContext } from '@object-ui/react'; import { buildExpandFields, declaredNameField, getRecordDisplayName, formatTitleTemplate, recordDisplayValueAt, resolveAffordance, toDisplayDate, userActionPredicates, withoutDeniedFields } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; @@ -330,7 +330,19 @@ export const DetailView: React.FC = ({ // Retained local error state for the approval-cancel flow ONLY — the // inline-edit draft now surfaces its own errors through the save bar. const [saveError, setSaveError] = React.useState(null); - const [objectSchema, setObjectSchema] = React.useState(null); + const [dataSourceObjectSchema, setObjectSchema] = React.useState(null); + // The host's object definition, for a host that supplies the record + // (`recordFromHost`). Field types come from the object schema: without it a + // bare-name section entry has no type, so `DetailSection` draws the raw + // stored value (a `select` code, a `location` object as `[Object]`). The + // dataSource answer below is what supplies it on the record page; a host + // that renders with no dataSource bound (the Studio page designer) never + // gets that answer, though its record context already holds the schema it + // read. So the host's schema stands in until a dataSource answers, and + // nothing is fetched for it. A host with no record context, or one that + // does not supply the record, is unchanged. + const hostObjectSchema = useRecordContext()?.objectSchema; + const objectSchema = dataSourceObjectSchema ?? (recordFromHost ? hostObjectSchema ?? null : null); const [idCopied, setIdCopied] = React.useState(false); const [reloadTick, setReloadTick] = React.useState(0); // #2269 — refetch in place when this record (or its object) is invalidated From 17200c3f0a7e71b44674fa73d744fc8518990b3f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:06:21 +0000 Subject: [PATCH 2/2] test(ui): pin host object schema field types on the designer canvas and in DetailView Adds the canvas test (full-create seed, real record:details), the DetailView controls (a dataSource answer wins; no context or no recordFromHost is unchanged) and the plugin-detail patch changeset. Claude-Session: https://claude.ai/code/session_01JQwxbYUSW5btC4i3GbWXGe Co-authored-by: Claude --- .changeset/27177-detail-host-object-schema.md | 12 ++ ...eview.authoredDetailsValues-27177.test.tsx | 27 +++-- ...DetailView.hostObjectSchema-27177.test.tsx | 113 ++++++++++++++++++ 3 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 .changeset/27177-detail-host-object-schema.md create mode 100644 ui/packages/plugin-detail/src/__tests__/DetailView.hostObjectSchema-27177.test.tsx diff --git a/.changeset/27177-detail-host-object-schema.md b/.changeset/27177-detail-host-object-schema.md new file mode 100644 index 00000000000..875d4c2018e --- /dev/null +++ b/.changeset/27177-detail-host-object-schema.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-detail': patch +--- + +fix(ui): the page designer canvas draws an authored `record:details` body's `select` and `location` values the way the record page does (#27177) + +Clause-②: no +Metadata-protocol: none + +On the page designer's Design canvas, a `full` record page whose `record:details` block names its fields drew a `select` value as its stored code (`amer` instead of `Americas`) and a `location` value as the literal `[Object]`. The record page itself was right. + +`DetailView` learned field types only from its dataSource (`getObjectSchema`). The canvas mounts the record page's own `record:details` renderer under a record context that holds the bound object's schema but no dataSource, so a field named by bare name had no type and drew raw. Now, when the host supplies the record (`recordFromHost`, which `record:details` sets), `DetailView` takes field types from the record context's object schema until a dataSource answers. A dataSource answer still wins, nothing new is fetched, and a `DetailView` with no record context, or one that reads its own record, draws exactly as before. diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.authoredDetailsValues-27177.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.authoredDetailsValues-27177.test.tsx index 64047fa8beb..27048c923f5 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.authoredDetailsValues-27177.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.authoredDetailsValues-27177.test.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { render, waitFor, within, cleanup } from '@testing-library/react'; // Registers `record:details`, which the canvas resolves through the registry. import { buildDefaultPageSchema } from '@object-ui/plugin-detail'; @@ -57,8 +57,12 @@ const OBJECT = 'showcase_account'; const OBJECT_DEF = { name: OBJECT, label: 'Account', + // The highlights strip takes this field, so the three below stay in the + // details body: the strip reads the same record context and draws its own. + highlightFields: ['website'], fields: { name: { type: 'text', label: 'Account Name' }, + website: { type: 'text', label: 'Website' }, account_code: { type: 'text', label: 'Account Code' }, sales_region: { type: 'select', @@ -74,6 +78,7 @@ const OBJECT_DEF = { const RECORD = { id: 'acc_1', name: 'Northwind', + website: 'northwind.example', account_code: 'NW-001', sales_region: 'amer', headquarters: { latitude: 37.7749, longitude: -122.4194 }, @@ -136,11 +141,19 @@ describe('PagePreview Design canvas: an authored record:details draws typed valu />, ); - // Control: a plain text field reads as stored, before and after the fix. - await waitFor(() => expect(screen.getByText('NW-001')).toBeTruthy()); - await waitFor(() => expect(screen.getByText('Americas')).toBeTruthy()); - expect(screen.getByText(/37\.7749, -122\.4194/)).toBeTruthy(); - expect(screen.queryByText('amer')).toBeNull(); - expect(document.body.textContent).not.toContain('[Object]'); + // The block draws its "bind a record" placeholder until the sample record + // is bound, so wait for the bound body. + const details = await waitFor(() => { + const el = document.querySelector('[data-obj-type="record:details"]:not([data-record-details-placeholder])'); + expect(el).not.toBeNull(); + // Control: a plain text field reads as stored, before and after the fix. + within(el as HTMLElement).getByText('NW-001'); + return el as HTMLElement; + }); + const body = within(details); + expect(body.getByText('Americas')).toBeTruthy(); + expect(body.getByText(/37\.7749, -122\.4194/)).toBeTruthy(); + expect(body.queryByText('amer')).toBeNull(); + expect(details.textContent).not.toContain('[Object]'); }); }); diff --git a/ui/packages/plugin-detail/src/__tests__/DetailView.hostObjectSchema-27177.test.tsx b/ui/packages/plugin-detail/src/__tests__/DetailView.hostObjectSchema-27177.test.tsx new file mode 100644 index 00000000000..6c5ca9a4875 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/DetailView.hostObjectSchema-27177.test.tsx @@ -0,0 +1,113 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { RecordContextProvider } from '@object-ui/react'; +import { DetailView } from '../DetailView'; +import { __clearRecordEditableCache } from '../useRecordEditable'; +import type { DetailViewSchema } from '@object-ui/types'; + +/** + * Under `recordFromHost`, field types come from the host's object schema until + * a dataSource answers. + * + * A host that renders a record with no dataSource bound (the Studio page + * designer) holds the object schema in its record context, and `DetailView` + * only ever learned field types from `dataSource.getObjectSchema`. A bare-name + * section entry then had no type: a `select` drew its stored code and a + * `location` drew `[Object]`. The canvas end of this is + * `PagePreview.authoredDetailsValues-27177.test.tsx` in app-shell. + */ + +const fieldsWith = (amerLabel: string) => ({ + name: { type: 'text', label: 'Name' }, + sales_region: { + type: 'select', + label: 'Sales Region', + options: [{ value: 'amer', label: amerLabel }, { value: 'emea', label: 'Europe' }], + }, + headquarters: { type: 'location', label: 'Headquarters' }, +}); +const hostSchema = { name: 'account', fields: fieldsWith('Americas') }; + +const RECORD = { + id: 'A1', + name: 'Northwind', + sales_region: 'amer', + headquarters: { latitude: 37.7749, longitude: -122.4194 }, +}; + +// Bare-name entries, as `record:details` hands an authored section over. +const schema: DetailViewSchema = { + type: 'detail-view', + objectName: 'account', + resourceId: 'A1', + showHeader: false, + data: RECORD, + sections: [{ title: 'Details', fields: [{ name: 'sales_region' }, { name: 'headquarters' }] }], +}; + +function makeDataSource(objectSchema: unknown) { + return { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async () => RECORD), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: vi.fn(async () => objectSchema), + }; +} + +const withHost = (node: React.ReactNode, objectSchema: unknown = hostSchema) => ( + + {node} + +); + +const settle = () => new Promise((r) => setTimeout(r, 20)); + +describe('DetailView: recordFromHost takes field types from the host object schema', () => { + // The edit probe (`POST /security/explain`) rides the global fetch. + beforeEach(() => { + __clearRecordEditableCache(); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ record: { visible: true } }) }))); + }); + afterEach(() => { vi.unstubAllGlobals(); }); + + it('with no dataSource, draws a select by its option label and a location as coordinates', async () => { + const { container } = render(withHost()); + await waitFor(() => expect(screen.getByText('Americas')).toBeInTheDocument()); + expect(screen.getByText(/37\.7749, -122\.4194/)).toBeInTheDocument(); + expect(screen.queryByText('amer')).toBeNull(); + expect(container.textContent).not.toContain('[Object]'); + }); + + it('CONTROL: a dataSource answer still wins over the host schema, and it is the one read', async () => { + const ds = makeDataSource({ name: 'account', fields: fieldsWith('Americas (from the dataSource)') }); + render(withHost()); + await waitFor(() => expect(screen.getByText('Americas (from the dataSource)')).toBeInTheDocument()); + expect(screen.queryByText('Americas')).toBeNull(); + expect(ds.getObjectSchema).toHaveBeenCalledTimes(1); + expect(ds.findOne).not.toHaveBeenCalled(); + }); + + it('CONTROL: with no record context, or without recordFromHost, it draws what it drew before', async () => { + const bare = render(); + await settle(); + expect(bare.getByText('amer')).toBeInTheDocument(); + expect(bare.queryByText('Americas')).toBeNull(); + bare.unmount(); + + const notFromHost = render(withHost()); + await settle(); + expect(notFromHost.getByText('amer')).toBeInTheDocument(); + expect(notFromHost.queryByText('Americas')).toBeNull(); + }); +});