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
12 changes: 12 additions & 0 deletions .changeset/27177-detail-host-object-schema.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// 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, waitFor, within, 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<typeof import('@object-ui/permissions')>();
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',
// 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',
label: 'Sales Region',
options: [
{ value: 'amer', label: 'Americas' },
{ value: 'emea', label: 'Europe' },
],
},
headquarters: { type: 'location', label: 'Headquarters' },
},
};
const RECORD = {
id: 'acc_1',
name: 'Northwind',
website: 'northwind.example',
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<string, unknown> {
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(
<PagePreview
type="page"
name="account_page"
draft={fullCreateSeed()}
editing
selection={null}
onSelectionChange={() => {}}
onPatch={vi.fn() as any}
locale="en-US"
/>,
);

// 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]');
});
});
16 changes: 14 additions & 2 deletions ui/packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -330,7 +330,19 @@ export const DetailView: React.FC<DetailViewProps> = ({
// 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<string | null>(null);
const [objectSchema, setObjectSchema] = React.useState<any>(null);
const [dataSourceObjectSchema, setObjectSchema] = React.useState<any>(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
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => (
<RecordContextProvider objectName="account" recordId="A1" data={RECORD} objectSchema={objectSchema}>
{node}
</RecordContextProvider>
);

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(<DetailView schema={schema} recordFromHost />));
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(<DetailView schema={schema} dataSource={ds as never} recordFromHost />));
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(<DetailView schema={schema} recordFromHost />);
await settle();
expect(bare.getByText('amer')).toBeInTheDocument();
expect(bare.queryByText('Americas')).toBeNull();
bare.unmount();

const notFromHost = render(withHost(<DetailView schema={schema} />));
await settle();
expect(notFromHost.getByText('amer')).toBeInTheDocument();
expect(notFromHost.queryByText('Americas')).toBeNull();
});
});
Loading