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
8 changes: 8 additions & 0 deletions .changeset/27146-record-page-inline-edit-field-access.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion content/docs/permissions/field-level-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions ui/packages/plugin-detail/src/recordFieldAccess.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<RecordFieldAccessScope
object={objectName as string}
recordId={id as string}
load={(object, recordKey) => (load as NonNullable<typeof load>).call(dataSource, object, recordKey)}
revision={busRevision + writeRevision}
>
{children}
</RecordFieldAccessScope>
);
}
Original file line number Diff line number Diff line change
@@ -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 <button type="button" onClick={() => inline?.enter()}>enter-edit</button>;
}

const BLOCKS = [
{ block: 'record:details', mount: () => <RecordDetailsRenderer schema={{} as never} /> },
{ block: 'record:highlights', mount: () => <RecordHighlightsRenderer schema={{ fields: FIELDS } as never} /> },
];

function renderPage(ds: ReturnType<typeof makeDS>, block: React.ReactNode, canEdit = true) {
return render(
<MePermissionsProvider initialPermissions={PRINCIPAL}>
<RecordContextProvider
objectName={OBJECT}
recordId={RECORD.id}
data={RECORD}
objectSchema={OBJECT_SCHEMA}
dataSource={ds as never}
>
<HighlightFieldsProvider>
<InlineEditProvider canEdit={canEdit}>
<EnterEdit />
{block}
</InlineEditProvider>
</HighlightFieldsProvider>
</RecordContextProvider>
</MePermissionsProvider>,
);
}

/** With the session open: which fields draw an input (edit in place) rather than their read display. */
function editable(): Record<string, boolean> {
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();
});
});
26 changes: 18 additions & 8 deletions ui/packages/plugin-detail/src/renderers/record-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1187,15 +1188,24 @@ const RecordDetailsBody: React.FC<RecordDetailsRendererProps> = ({
return (
<div className={className} {...designer} {...ariaProps}>
<RecordPageProvenanceContext.Provider value={readsPageRecord}>
{/* 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`. */}
<DetailView
schema={synthesized}
{/* objectstack#27146 — the in-place edit's field locks are this
stored row's, asked of the server (`../recordFieldAccess`). */}
<RecordFieldAccessForRecord
objectName={ctx.objectName}
recordId={ctx.recordId}
dataSource={ctx.dataSource}
inlineEdit={inlineEditDefault}
recordFromHost
/>
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`. */}
<DetailView
schema={synthesized}
dataSource={ctx.dataSource}
inlineEdit={inlineEditDefault}
recordFromHost
/>
</RecordFieldAccessForRecord>
</RecordPageProvenanceContext.Provider>
</div>
);
Expand Down
Loading
Loading