diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.scope.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.scope.test.tsx index ea87d8e67..a14093758 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.scope.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.scope.test.tsx @@ -32,6 +32,9 @@ import { MemoryRouter } from 'react-router-dom'; interface Server { set: Record; packageObjects: Array<{ name: string; label?: string }>; + // Keyed by object name → the merged object definition returned by + // get('object', name). `fields` may be an array or a `{ [name]: def }` map. + objectFields: Record; saved: Array>; savedOpts: Array | undefined>; } @@ -54,8 +57,17 @@ function makeClient(server: Server) { void opts; return server.packageObjects.map((o) => ({ item: o })); } + // The field-level editor no longer lists the `field` type; it reads the + // merged object definition via get('object', name). A bare list('field') + // returning [] would reproduce the "no fields" bug this suite guards. return []; }, + get: async (type: string, name: string) => { + // Mirror `GET /api/v1/meta/object/` — the merged definition whose + // `fields` reflect the published object (inline + standalone fields). + if (type === 'object') return server.objectFields[name] ?? null; + return null; + }, save: async (_type: string, _name: string, payload: Record, opts?: Record) => { server.saved.push(payload); server.savedOpts.push(opts); @@ -86,6 +98,22 @@ afterEach(cleanup); function freshServer(): Server { return { packageObjects: [{ name: 'a_account' }, { name: 'a_contact' }], + // a_account carries its fields inline on the published object definition + // (array form); a_contact uses the keyed-map form. Both must surface. + objectFields: { + a_account: { + fields: [ + { name: 'name', label: 'Name' }, + { name: 'industry', label: 'Industry' }, + ], + }, + a_contact: { + fields: { + email: { label: 'Email' }, + phone: { label: 'Phone' }, + }, + }, + }, saved: [], savedOpts: [], set: { @@ -162,4 +190,30 @@ describe('PermissionMatrixEditPage — package scope + slice merge (ADR-0086 P0) viewAllRecords: true, }); }); + + // Regression: expanding an object row for field-level read/write must list + // the object's published fields (read via get('object', name)) — not fall + // back to "no fields" because list('field') was empty. + it('lists an object\'s published fields when its row is expanded', async () => { + const server = freshServer(); + clientImpl = makeClient(server); + renderMatrix(); + + // Expand a_account (fields provided as an array on the object def). + const accountToggle = await screen.findByRole('button', { name: /a_account/ }); + fireEvent.click(accountToggle); + + // Both inline fields surface with their labels — never "no fields". + await screen.findByLabelText('a_account.name readable'); + expect(screen.getByLabelText('a_account.name editable')).toBeInTheDocument(); + expect(screen.getByLabelText('a_account.industry readable')).toBeInTheDocument(); + expect( + screen.queryByText('No fields registered for this object.'), + ).not.toBeInTheDocument(); + + // Expand a_contact (fields provided as a keyed map) — same outcome. + fireEvent.click(screen.getByRole('button', { name: /a_contact/ })); + await screen.findByLabelText('a_contact.email readable'); + expect(screen.getByLabelText('a_contact.phone editable')).toBeInTheDocument(); + }); }); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 19e7a1d96..46dccba2e 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -27,8 +27,11 @@ * Wiring: registered from `builtinComponents.tsx` as * registerMetadataResource({ type: 'permission', EditPage: PermissionMatrixEditPage }) * - * The component reads `/api/v1/meta/object` + `/api/v1/meta/field` to - * enumerate available objects and their fields. Saves through the + * The component reads `/api/v1/meta/object` to enumerate available + * objects, and reads each object's merged definition (`GET + * /api/v1/meta/object/`, whose `fields` reflect the published + * object — inline + standalone) to enumerate that object's fields for + * field-level permission editing. Saves through the * standard metadata save flow (overlay-aware, OCC, destructive-change * dialog already provided by the generic engine — we go through * client.save() directly). @@ -219,14 +222,26 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, async function ensureFields(objectName: string) { if (fieldsByObject[objectName]) return; try { - // Fields are stored as `${object}__${field}` keys under the - // `field` metadata type. We resolve by listing then filtering - // on the `object` attribute of the item. - const items = await client.list('field'); - const list: FieldSummary[] = ((items as any[]) ?? []) - .map((row) => row?.item ?? row) - .filter((f) => String(f?.object ?? '') === objectName) - .map((f) => ({ name: String(f?.name ?? ''), label: f?.label })) + // Read the authoritative, merged object definition (same source the + // object settings tab uses for its nameField dropdown). The `field` + // LIST endpoint only surfaces standalone/code-package field metadata + // and misses fields carried inline on a published object, which left + // this editor showing "no fields" for objects that clearly have them. + // `fields` may come back as an array or as a `{ [name]: def }` map. + const obj = (await client.get('object', objectName)) as + | { fields?: Record | Array } + | null; + const raw = obj?.fields; + const list: FieldSummary[] = ( + Array.isArray(raw) + ? raw.map((f: any) => ({ name: String(f?.name ?? ''), label: f?.label })) + : raw && typeof raw === 'object' + ? Object.entries(raw).map(([name, f]: [string, any]) => ({ + name: String(f?.name ?? name), + label: f?.label, + })) + : [] + ) .filter((f) => !!f.name) .sort((a, b) => a.name.localeCompare(b.name)); setFieldsByObject((prev) => ({ ...prev, [objectName]: list }));