From c7a744342a977f0223233b44e0d45a10584b0bce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:09:05 +0000 Subject: [PATCH 1/2] fix(plugin-detail): the record page's inline edit reads the stored row's field locks record:details and record:highlights wrap their bodies in the record field projection scope, so a when-conditioned field lock disables a field only on the rows the server's write gate refuses. Claude-Session: https://claude.ai/code/session_01JQwxbZ6ALxKhRMeuJrvUeU Co-authored-by: Claude --- .../plugin-detail/src/recordFieldAccess.tsx | 99 +++++++++++++++++++ .../src/renderers/record-details.tsx | 26 +++-- .../src/renderers/record-highlights.tsx | 19 ++-- 3 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 ui/packages/plugin-detail/src/recordFieldAccess.tsx diff --git a/ui/packages/plugin-detail/src/recordFieldAccess.tsx b/ui/packages/plugin-detail/src/recordFieldAccess.tsx new file mode 100644 index 00000000000..2cbc16ec79d --- /dev/null +++ b/ui/packages/plugin-detail/src/recordFieldAccess.tsx @@ -0,0 +1,99 @@ +/** + * 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. + */ + +/** + * The record a detail body edits in place answers its own field locks + * (objectstack#27146, the residual of objectstack#26594 ruling B). + * + * `DetailSection` and `HeaderHighlight` ask a field's in-place edit of the + * `recordEdit` row (`resolveFieldAffordance`), which reads `usePermissions()`. + * Under the page's provider alone that is `/auth/me/permissions`' answer, which + * holds no row: a field permission whose `when` names only some rows is served + * there as its lock, and the field was disabled on every row, the draft the + * server lets the caller write included. Below `RecordFieldAccessScope` + * (`@object-ui/permissions`) the same call answers from the server's + * projection for THIS stored row (`DataSource.getRecordFieldAccess`, the + * `GET /data/{object}/{id}/field-access` route) — the one source the record + * form's edit dialog asks. Nothing here evaluates a `when`. + * + * Mounted by the two record blocks that edit the page's record in place, + * `record:details` and `record:highlights`, around their bodies only. They are + * sibling blocks of the page schema with no ancestor in this package, and a + * mount above them (the page host) would also cover the page's action dialogs + * and related lists, where a form editing ANOTHER row of the same object would + * read this row's answer until its own arrives. So each block asks for itself: + * a page drawing both asks twice. + * + * It asks only when the block's in-place edit is on: the page's inline-edit + * session may edit (`canEdit`), the block offers it (`enabled`), the record is + * stored (an id) and the data source carries the method. Otherwise the + * row-blind answer stands, as it does for a non-ObjectStack backend. + * + * The answer belongs to the stored row, so it is asked again whenever the row + * may have moved: a write the data source reports for this record (the inline + * save bar's included) and a change on the data-invalidation bus for it (the + * signal the page re-reads its record on). These are the triggers + * `RecordFieldAccessForForm` (`@object-ui/plugin-form`) holds; that wrapper is + * not exported and this package does not depend on that one, so the few lines + * are repeated here rather than published as a new entry of either package. + */ + +import React, { useEffect, useState } from 'react'; +import { RecordFieldAccessScope } from '@object-ui/permissions'; +import { useDataInvalidation, useInlineEdit } from '@object-ui/react'; +import type { DataSource } from '@object-ui/types'; + +export interface RecordFieldAccessForRecordProps { + objectName?: string | null; + recordId?: string | number | null; + dataSource?: DataSource | null; + /** Whether the block draws in-place editors at all. Defaults to `true`. */ + enabled?: boolean; + children: React.ReactNode; +} + +export function RecordFieldAccessForRecord({ + objectName, + recordId, + dataSource, + enabled = true, + children, +}: RecordFieldAccessForRecordProps) { + const inline = useInlineEdit(); + const load = dataSource?.getRecordFieldAccess; + const asks = enabled + && !!inline?.canEdit + && !!objectName + && recordId !== undefined && recordId !== null && recordId !== '' + && typeof load === 'function'; + const id = asks ? String(recordId) : undefined; + const busRevision = useDataInvalidation(asks ? (objectName as string) : undefined, id); + const [writeRevision, setWriteRevision] = useState(0); + useEffect(() => { + if (!asks || typeof dataSource?.onMutation !== 'function') return; + const object = (objectName as string).toLowerCase(); + return dataSource.onMutation((event) => { + if ((event?.resource ?? '').toLowerCase() !== object) return; + if (event.id != null && String(event.id) !== id) return; + setWriteRevision((n) => n + 1); + }); + }, [asks, dataSource, objectName, id]); + if (!asks) { + return <>{children}; + } + return ( + (load as NonNullable).call(dataSource, object, recordKey)} + revision={busRevision + writeRevision} + > + {children} + + ); +} diff --git a/ui/packages/plugin-detail/src/renderers/record-details.tsx b/ui/packages/plugin-detail/src/renderers/record-details.tsx index 3dd0370806e..36313de479e 100644 --- a/ui/packages/plugin-detail/src/renderers/record-details.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-details.tsx @@ -35,6 +35,7 @@ import { import { DetailView } from '../DetailView'; import { hasCellValue } from '../emptiness'; import { RecordPageProvenanceContext } from '../recordPageProvenance'; +import { RecordFieldAccessForRecord } from '../recordFieldAccess'; import { deriveFieldGroupDetailEntries, type FieldGroupDetailEntry } from '../synth/buildDefaultPageSchema'; import { useRecordAriaProps } from './recordComponentAria'; import { recordBindingBlock } from './recordBinding'; @@ -1187,15 +1188,24 @@ const RecordDetailsBody: React.FC = ({ return (
- {/* objectui#12701 — the record is the context host's (the record - page's read, or the binding's row above); this body never reads it - again, it waits for `ctx.data`. */} - + enabled={inlineEditDefault} + > + {/* objectui#12701 — the record is the context host's (the record + page's read, or the binding's row above); this body never reads it + again, it waits for `ctx.data`. */} + +
); diff --git a/ui/packages/plugin-detail/src/renderers/record-highlights.tsx b/ui/packages/plugin-detail/src/renderers/record-highlights.tsx index 7bda49ecfd0..09011510506 100644 --- a/ui/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -15,6 +15,7 @@ import { useRecordContext, useRegisterHighlightFields } from '@object-ui/react'; import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import type { RecordHighlightsComponentProps } from '@object-ui/types'; import { HeaderHighlight } from '../HeaderHighlight'; +import { RecordFieldAccessForRecord } from '../recordFieldAccess'; import { useRecordAriaProps } from './recordComponentAria'; import { recordBindingBlock } from './recordBinding'; @@ -180,13 +181,17 @@ const RecordHighlightsBody: React.FC = ({ return (
- + {/* objectstack#27146 — the in-place edit's field locks are this + stored row's, asked of the server (`../recordFieldAccess`). */} + + +
); }; From 7d0d12c86b171ea8d886b67d227ee7a69dfbdf41 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:13:23 +0000 Subject: [PATCH 2/2] test(plugin-detail): the record blocks' in-place edit follows the record field projection Claude-Session: https://claude.ai/code/session_01JQwxbZ6ALxKhRMeuJrvUeU Co-authored-by: Claude --- ...46-record-page-inline-edit-field-access.md | 8 + .../docs/permissions/field-level-security.mdx | 4 +- ...rd-blocks.recordFieldAccess-27146.test.tsx | 209 ++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 .changeset/27146-record-page-inline-edit-field-access.md create mode 100644 ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.recordFieldAccess-27146.test.tsx diff --git a/.changeset/27146-record-page-inline-edit-field-access.md b/.changeset/27146-record-page-inline-edit-field-access.md new file mode 100644 index 00000000000..819607a25d5 --- /dev/null +++ b/.changeset/27146-record-page-inline-edit-field-access.md @@ -0,0 +1,8 @@ +--- +'@object-ui/plugin-detail': patch +--- + +Clause-②: no +Metadata-protocol: none + +fix(plugin-detail): the record page's in-place edit reads the stored row's field locks. `record:details` and `record:highlights` now ask the server's record-scoped field projection (`DataSource.getRecordFieldAccess`, `GET /data/{object}/{id}/field-access`) when the page's inline-edit session may edit, so a field permission whose `when` locks a field only from some state onward disables it only on the rows it names, as the record form's edit dialog already does. The answer is asked again after a write to the record. A data source without the method keeps the row-blind `/auth/me/permissions` answer, and a list view's column-wide inline edit keeps it too. diff --git a/content/docs/permissions/field-level-security.mdx b/content/docs/permissions/field-level-security.mdx index e6eb2fb21ba..46d3b6210cc 100644 --- a/content/docs/permissions/field-level-security.mdx +++ b/content/docs/permissions/field-level-security.mdx @@ -63,7 +63,9 @@ fields: { **that** record, and its `editable` list is the write gate's own verdict on an update of the stored row. So a lock that applies from a stage onward disables the field only on the rows its condition names — this is the - answer a record's edit form reads. A row that is absent or that the caller + answer a record's edit form and the record page's in-place edit read. A + list view's inline edit locks a whole column, which cannot split per row, + so it keeps the row-blind answer. A row that is absent or that the caller cannot read answers the record read's `404`. The answer is field-level only: the object grant and the record's row-level write scope (sharing, row-level security, ownership) are not part of it, so on a row the caller diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.recordFieldAccess-27146.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.recordFieldAccess-27146.test.tsx new file mode 100644 index 00000000000..127b835b000 --- /dev/null +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.recordFieldAccess-27146.test.tsx @@ -0,0 +1,209 @@ +/** + * 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. + */ + +/** + * objectstack#27146 (the residual of objectstack#26594, ruling B) — the record + * page's in-place edit asks the server for THIS record's field projection, the + * answer the record form's edit dialog already reads. + * + * The persona is the contract app's finance role: `summary` is locked from + * `approved` onward (`FieldPermissionSchema.when`). `/auth/me/permissions` + * holds no row, so it serves that entry as its lock, and before this card the + * details body and the highlights strip disabled `summary` on the draft the + * server lets the caller save. `ref_no` is locked on every row: the control + * that a lock the server keeps stays locked. + * + * Rows, for `record:details` and for `record:highlights`, each under the record + * page's providers (record context, inline-edit session): + * - a draft the server unlocks → `summary` edits in place, `ref_no` stays a + * read display; + * - an approved row → both stay read displays; + * - a save after which the server judges the row approved → asked again, + * and `summary` locks (the answer follows the stored row, not the mount); + * and the controls: + * - a data source without the method keeps the row-blind lock; + * - a session that may not edit asks nothing. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { HighlightFieldsProvider, InlineEditProvider, RecordContextProvider, useInlineEdit } from '@object-ui/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { RecordDetailsRenderer } from '../record-details'; +import { RecordHighlightsRenderer } from '../record-highlights'; + +const OBJECT = 'rfa_contract'; +const FIELDS = ['region', 'summary', 'ref_no']; + +const OBJECT_SCHEMA = { + name: OBJECT, + label: 'Contract', + // The title field the details body draws in the header, not as a row. + nameField: 'name', + highlightFields: FIELDS, + fields: { + name: { type: 'text', label: 'Name' }, + region: { type: 'text', label: 'Region' }, + summary: { type: 'text', label: 'Summary' }, + ref_no: { type: 'text', label: 'Reference number' }, + }, +}; + +const PRINCIPAL: MePermissionsResponse = { + authenticated: true, + userId: 'u-1', + tenantId: null, + roles: ['finance'], + permissionSets: ['rfa_fixture_member'], + objects: { [OBJECT]: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false } }, + // As `/auth/me/permissions` serves them: the conditioned entry as its lock. + fields: { + [`${OBJECT}.summary`]: { readable: true, editable: false }, + [`${OBJECT}.ref_no`]: { readable: true, editable: false }, + }, +} as MePermissionsResponse; + +const RECORD = { id: 'c1', name: 'MSA', region: 'EMEA', summary: 'Draft terms', ref_no: 'R-1' }; + +const ACCESS = { + draft: { object: OBJECT, recordId: 'c1', readable: ['id', ...FIELDS], editable: ['region', 'summary'] }, + approved: { object: OBJECT, recordId: 'c1', readable: ['id', ...FIELDS], editable: ['region'] }, +}; + +function makeDS(access: (typeof ACCESS)[keyof typeof ACCESS], withMethod = true) { + const listeners = new Set<(e: any) => void>(); + return { + getObjectSchema: vi.fn(async () => OBJECT_SCHEMA), + findOne: vi.fn(async () => ({ ...RECORD })), + find: vi.fn(async () => ({ data: [] })), + update: vi.fn(async (resource: string, id: string, data: any) => { + const record = { ...RECORD, ...data }; + // As the adapter does: every successful write is reported to `onMutation` subscribers. + for (const listener of listeners) listener({ type: 'update', resource, id, record }); + return record; + }), + onMutation: (listener: (e: any) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ...(withMethod ? { getRecordFieldAccess: vi.fn(async () => access) } : {}), + }; +} + +/** Opens the page's inline-edit session, as the pencil does. */ +function EnterEdit() { + const inline = useInlineEdit(); + return ; +} + +const BLOCKS = [ + { block: 'record:details', mount: () => }, + { block: 'record:highlights', mount: () => }, +]; + +function renderPage(ds: ReturnType, block: React.ReactNode, canEdit = true) { + return render( + + + + + + {block} + + + + , + ); +} + +/** With the session open: which fields draw an input (edit in place) rather than their read display. */ +function editable(): Record { + return { + region: screen.queryByDisplayValue(RECORD.region) !== null, + summary: screen.queryByDisplayValue(RECORD.summary) !== null, + ref_no: screen.queryByDisplayValue(RECORD.ref_no) !== null, + }; +} + +async function openSession() { + await screen.findByText(RECORD.summary); + fireEvent.click(screen.getByText('enter-edit')); + await waitFor(() => expect(screen.getByDisplayValue(RECORD.region)).toBeInTheDocument()); +} + +beforeAll(() => Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })); + +beforeEach(() => { + // `useRecordEditable` probes `POST /api/v1/security/explain`; serve it from a + // double so no real socket opens. Its answer is orthogonal to the field locks. + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true }), + text: async () => '{"allowed":true}', + })) as never); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe.each(BLOCKS)('[#27146] $block', ({ mount }) => { + it('on a draft the server unlocks, the conditioned field edits in place and the unconditioned lock holds', async () => { + const ds = makeDS(ACCESS.draft); + renderPage(ds, mount()); + await openSession(); + await waitFor(() => expect(editable()).toEqual({ region: true, summary: true, ref_no: false })); + expect(ds.getRecordFieldAccess).toHaveBeenCalledWith(OBJECT, 'c1'); + }); + + it('on a row the condition names, the field stays locked', async () => { + const ds = makeDS(ACCESS.approved); + renderPage(ds, mount()); + await openSession(); + await waitFor(() => expect(ds.getRecordFieldAccess).toHaveBeenCalled()); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(editable()).toEqual({ region: true, summary: false, ref_no: false }); + }); + + it('a save that moves the row into the state the condition names locks the field again', async () => { + const ds = makeDS(ACCESS.draft); + renderPage(ds, mount()); + await openSession(); + await waitFor(() => expect(editable().summary).toBe(true)); + // The server now judges the stored row approved: the next answer locks `summary`. + ds.getRecordFieldAccess!.mockResolvedValue(ACCESS.approved); + await act(async () => { await ds.update(OBJECT, 'c1', { region: 'APAC' }); }); + await waitFor(() => expect(ds.getRecordFieldAccess).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(editable().summary).toBe(false)); + expect(editable().ref_no).toBe(false); + }); + + it('a data source without the method keeps the row-blind lock', async () => { + const ds = makeDS(ACCESS.draft, false); + renderPage(ds, mount()); + await openSession(); + expect(editable()).toEqual({ region: true, summary: false, ref_no: false }); + }); + + it('a session that may not edit asks nothing', async () => { + const ds = makeDS(ACCESS.draft); + renderPage(ds, mount(), false); + await screen.findByText(RECORD.summary); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(ds.getRecordFieldAccess).not.toHaveBeenCalled(); + }); +});