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
17 changes: 17 additions & 0 deletions .changeset/27138-edit-dialog-row-writable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/types': patch
'@object-ui/permissions': patch
'@object-ui/plugin-form': patch
'@object-ui/i18n': patch
---

fix(plugin-form): the record edit dialog draws a record the caller may read but not write as read-only, says why, and offers no Save

Clause-②: no
Metadata-protocol: none

On a record shared to the caller for reading only (sharing, ownership or row-level security), or held by a pending approval that locks it, the console's edit dialog used to open with every field live; the user filled it in and the server refused the save as a whole. The dialog now asks the server about the row itself:

- **The row's verdict.** `RecordFieldAccess` (`@object-ui/types`) carries the record projection's `writable`, which `DataSource.getRecordFieldAccess` already passes through. Below `RecordFieldAccessScope` (`@object-ui/permissions`), `writable: false` refuses `write` on every field of the record and `check(object, 'update')` with the reason `record-not-writable`; `read`, `create`, other objects and other records keep the enclosing answer, and an object grant the provider already refuses keeps its own reason. `writable: true` and an absent `writable` (an older server) change nothing.
- **The approval lock.** The projection leaves out the approvals record lock, which the server judges on the values a save sends. `RecordFieldAccessScope` takes `lockedBy: 'approval'` for a lock the host reads, answered the same way with the reason `record-locked-by-approval`, and `ModalForm` takes the same `lockedBy` prop. The console's edit dialog passes it from the approvals read and rule the record page uses.
- **What the form draws.** Every field gate (`gateFormFields`, the outbound write gate, the record page's inline edit) reads the scoped answer with no call-site change. `closedFormAffordance` reports `record` or `approval` for such a row, the form's notice says "You don't have permission to edit this record. The fields are read-only." (new key `form.noPermissionToEditRecord` in every pack) or the record page's approval-lock sentence, and `ModalForm` and `DrawerForm` offer no Save on it.
22 changes: 21 additions & 1 deletion ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { useAssistant } from '../assistant/assistantBus.js';
import { ModalForm } from '@object-ui/plugin-form';
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
import { toast } from 'sonner';
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider } from '@object-ui/react';
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock } from '@object-ui/react';
import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals.js';
import { getRecordDisplayName } from '@object-ui/core';
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack';
Expand Down Expand Up @@ -829,6 +830,24 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
: currentObjectDef;
const isChildFormTask = !!formObjectParam && formObjectParam !== currentObjectDef?.name;

// objectstack#27138 — the record the overlay edits may be held by a pending
// approval that locks it. The record's field projection leaves that lock out
// (the server judges it on the values a save sends), so the dialog reads it
// from the approvals read the record page reads, joined on the wire when the
// page already asked, and through the same rule (`openApprovalOf` +
// `resolveApprovalLock`): the dialog then draws the record read-only with
// the page's lock sentence and no Save. The row has no `approval_status`
// here (the overlay holds only its id), so an unanswered read locks nothing.
const formApprovals = useRecordApprovals(
editingRecord ? formObjectDef?.name : undefined,
editingRecord?.id,
);
const formApprovalLocked = resolveApprovalLock({
readAnswered: formApprovals.answered,
openApproval: openApprovalOf(formApprovals),
recordStatus: undefined,
}).locked;

// objectstack-ai/objectstack#26172 — a record save names the record it wrote
// (`ux-conventions.write-feedback`): `Task "Quarterly review" created`. The
// name is the saved record's display name from the ONE ADR-0079 resolver,
Expand Down Expand Up @@ -1491,6 +1510,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
{formObjectDef && (
<ModalForm
key={`${formObjectDef.name}:${editingRecord?.id || 'new'}`}
lockedBy={formApprovalLocked ? 'approval' : undefined}
schema={{
type: 'object-form',
formType: 'modal',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
Expand Down Expand Up @@ -155,6 +155,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({
useMutationInvalidationBridge: () => {},
}));

// objectstack#27138 — an edit dialog reads the approvals state of the record it
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
// routes answer empty from one double, installed for the whole file so no read
// can reach a socket after a test body returns, and handed back in `afterAll`.
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})));
afterAll(() => vi.unstubAllGlobals());

import { AppContent } from '../AppContent';

function LocationProbe() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* 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#27138 — the console's edit dialog reads the approvals record
* lock of the record it edits, through the read and the rule the record page
* uses (`useRecordApprovals`, `openApprovalOf`, `resolveApprovalLock`), and
* hands it to `ModalForm` as `lockedBy: 'approval'`. The record's field
* projection leaves that lock out, so without it the dialog offered fields
* a save then refused.
*
* Measured from a real `AppContent` mount (the harness of
* `AppContent.formViewModalSize-12803`), with the approvals routes served by a
* fetch double:
* - a reader outside the slate: the list answers empty and the record lock
* answers `locked: true` → `lockedBy: 'approval'`;
* - a participant: the pending request's node locks (`lock_record` absent)
* → `'approval'`; it declares `lockRecord: false` → no lock;
* - CONTROL no approval running → no lock;
* - CONTROL a create dialog asks nothing and is not locked.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { registerAllFields } from '@object-ui/fields';

registerAllFields();

const probe = vi.hoisted(() => ({
/** Every set of props the global record-form modal was rendered with. */
modalProps: [] as any[],
}));

vi.mock('@object-ui/plugin-form', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return {
...actual,
ModalForm: (props: any) => {
probe.modalProps.push(props);
return <div data-testid="modal-form" />;
},
};
});

vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
CreateAppPage: () => <div data-testid="create-app-page" />,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));

vi.mock('../../layout/ConsoleLayout', () => ({
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="console-layout">{children}</div>
),
}));
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
vi.mock('../../views/RecordFormPage', () => ({
RecordFormPage: () => <div data-testid="record-form-page" />,
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
getAuthConfig: async () => ({ features: {} }),
activeOrganization: null,
}),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

const CONTRACT = {
name: 'crm_contract',
label: 'Contract',
fields: { title: { type: 'text', label: 'Title' } },
form: { type: 'modal' },
};
const OBJECTS = [CONTRACT];

const dataSourceStub = {
onConnectionStateChange: () => () => {},
getConnectionState: () => 'connected',
getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name)),
findOne: vi.fn(async () => ({ id: 'r1' })),
find: vi.fn(async () => ({ data: [] })),
create: vi.fn(),
update: vi.fn(),
};
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => dataSourceStub,
}));

const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];

// One value for the whole run: effects key on these members.
const METADATA = {
apps: APPS,
objects: OBJECTS,
loading: false,
ensureType: undefined,
error: null,
refresh: async () => {},
};
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadata: () => METADATA,
}));

const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useActionRunner: () => ACTION_RUNNER,
useGlobalUndo: () => {},
useMutationInvalidationBridge: () => {},
}));

import { AppContent } from '../AppContent';
import { __resetRecordApprovalsStore } from '../../hooks/useRecordApprovals';

type Approvals = { requests: any[]; lock: boolean };

/** What the approvals routes answer; set per test. */
let served: Approvals = { requests: [], lock: false };

// Installed once for the whole file, so no read the dialog issues after a
// test body returns can reach a socket; handed back in `afterAll`.
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes('/approvals/records/')
? { success: true, data: { locked: served.lock } }
: url.includes('/approvals/requests?')
? { data: served.requests }
: url.includes('/approvals/requests/')
? { data: served.requests[0] }
: {};
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchSpy);
afterAll(() => vi.unstubAllGlobals());

function serveApprovals(approvals: Approvals) {
served = approvals;
return fetchSpy;
}

const approvalReads = (spy: typeof fetchSpy) =>
spy.mock.calls.map(([u]) => String(u)).filter((u) => u.includes('/approvals/'));

function openAt(url: string) {
render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>,
);
}

const lastModal = () => probe.modalProps[probe.modalProps.length - 1];

const PENDING = { id: 'req1', object_name: 'crm_contract', record_id: 'r1', status: 'pending' };

beforeEach(() => {
probe.modalProps.length = 0;
__resetRecordApprovalsStore();
vi.clearAllMocks();
});
afterEach(cleanup);

describe('[#27138] the console edit dialog reads the approvals record lock', () => {
it.each([
['a reader outside the slate (the record lock answers)', { requests: [], lock: true }],
['a participant whose pending node locks the record', { requests: [PENDING], lock: false }],
])('%s: the dialog is handed the approval lock', async (_label, approvals) => {
const fetchSpy = serveApprovals(approvals);
openAt('/apps/crm/crm_contract?form=r1');
await waitFor(() => expect(lastModal()?.schema?.mode).toBe('edit'));
await waitFor(() => expect(lastModal().lockedBy).toBe('approval'));
expect(approvalReads(fetchSpy).some((u) => u.includes('object=crm_contract') && u.includes('recordId=r1'))).toBe(true);
});

it.each([
['a pending node that declares lockRecord: false', { requests: [{ ...PENDING, lock_record: false }], lock: false }],
['CONTROL — no approval running', { requests: [], lock: false }],
])('%s: the dialog is not locked', async (_label, approvals) => {
const fetchSpy = serveApprovals(approvals);
openAt('/apps/crm/crm_contract?form=r1');
await waitFor(() => expect(lastModal()?.schema?.mode).toBe('edit'));
await waitFor(() => expect(approvalReads(fetchSpy).length).toBeGreaterThan(0));
await new Promise((r) => setTimeout(r, 20));
expect(lastModal().lockedBy).toBeUndefined();
});

it('CONTROL — a create dialog asks nothing and is not locked', async () => {
const fetchSpy = serveApprovals({ requests: [], lock: true });
openAt('/apps/crm/crm_contract?form=new');
await waitFor(() => expect(lastModal()?.schema?.mode).toBe('create'));
await new Promise((r) => setTimeout(r, 20));
expect(lastModal().lockedBy).toBeUndefined();
expect(approvalReads(fetchSpy)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
Expand Down Expand Up @@ -143,6 +143,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({
useMutationInvalidationBridge: () => {},
}));

// objectstack#27138 — an edit dialog reads the approvals state of the record it
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
// routes answer empty from one double, installed for the whole file so no read
// can reach a socket after a test body returns, and handed back in `afterAll`.
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})));
afterAll(() => vi.unstubAllGlobals());

import { AppContent } from '../AppContent';

const lastModal = () => probe.modalSchemas[probe.modalSchemas.length - 1];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
Expand Down Expand Up @@ -141,6 +141,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({
useMutationInvalidationBridge: () => {},
}));

// objectstack#27138 — an edit dialog reads the approvals state of the record it
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
// routes answer empty from one double, installed for the whole file so no read
// can reach a socket after a test body returns, and handed back in `afterAll`.
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})));
afterAll(() => vi.unstubAllGlobals());

import { AppContent } from '../AppContent';

/** Open the record dialog at `url` and return the schema it was handed. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
Expand Down Expand Up @@ -142,6 +142,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({
useMutationInvalidationBridge: () => {},
}));

// objectstack#27138 — an edit dialog reads the approvals state of the record it
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
// routes answer empty from one double, installed for the whole file so no read
// can reach a socket after a test body returns, and handed back in `afterAll`.
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})));
afterAll(() => vi.unstubAllGlobals());

import { AppContent } from '../AppContent';

/** The URL that opens each task flow on the heavy object. */
Expand Down
Loading
Loading