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/30075-default-page-stage-advance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/plugin-detail': patch
---

feat(plugin-detail): the default record page lets an editor move a record to another stage by pressing it on the stage path

Clause-②: no
Metadata-protocol: none

**Visible behaviour change on a released page.** The record page the console synthesizes for an object with a stage field (no authored page) used to draw its stage path as a read-only progress rail, so moving a deal to its next stage took 编辑 → change the stage → 更新. That page's `record:path` now carries `allowStageChange: true`, the existing opt-in (no new metadata key):

- **Who can press.** Unchanged from the opt-in: a reader the record page lets edit this record, on a stage field that is not read-only or computed, whose field edit grant holds for the stored row. Everyone else keeps the read-only path, and so does an editor while the record's inline edit holds unsaved changes.
- **Every press asks for confirmation**, then saves the stage through the record's ordinary update with the record's version, so the object's validation rules and `state_machine` decide. A refused move is shown under the path, naming any other field the move needs (for example a field made required by the new stage); nothing is written.
- **Only reachable stages are offered.** On every page with the opt-in, a stage the stage field's blocking `state_machine` cannot reach from the stored value is drawn as a readout, not a button, the same moves the record form, the grid's inline editor and the board offer. A field with no such rule offers every stage, and the server still judges each move.
- **Authored pages keep their own setting.** A page, or an authored `highlights` slot, that declares its own `record:path` keeps whatever it declares: `true`, `false`, or the key left out (a read-only rail).

To keep a default record page's path read-only, assign the object an authored page (or a `highlights` slot) whose `record:path` leaves `allowStageChange` out or sets it `false`.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@
* markup stops claiming to be a control; a real click-to-advance stage control
* is a separate card with its own appetite, NOT folded in here.
*
* That separate card landed as the `allowStageChange` opt-in
* (objectstack#28061), and a later ruling (objectstack#30075, option A) has
* the synthesized DEFAULT record page write the opt-in on its path, so for an
* editor that page's path is a real control (pinned in
* `record-path.stageChange-28199.test.tsx`). What this file pins is unchanged
* and still binds: a `record:path` node that does not carry the opt-in — an
* authored page that leaves the key out or sets it `false` — is the readout
* below, and must not look like a control. Every mount here is such a node.
*
* ── What each group is for ────────────────────────────────────────────────
*
* Group A is the CONTROL — what must not move. It also establishes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
* a server refusal is shown in place; a reader who may not edit the status
* field — and every path that does not opt in — keeps the readout.
*
* The last two groups pin objectstack#30075 (ruling: option A): the
* synthesized default record page's path carries the opt-in, mounted here as
* the JSON document `buildDefaultPageSchema` emits, through the real
* `SchemaRenderer`; an authored `highlights` slot keeps its declared value;
* and a stage the field's blocking `state_machine` cannot reach from the
* stored value is never offered as a press.
*
* The subject is imported relatively and `@object-ui/*` resolves to source
* through the root vitest alias table, so nothing here reads a `dist/`.
*/
Expand All @@ -19,7 +26,7 @@ import * as React from 'react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { InlineEditProvider, RecordContextProvider, useInlineEdit } from '@object-ui/react';
import { InlineEditProvider, RecordContextProvider, SchemaRenderer, useInlineEdit } from '@object-ui/react';

const { permsStub, perms } = vi.hoisted(() => {
const perms: { isLoaded: boolean; writeDenied: string[] } = { isLoaded: true, writeDenied: [] };
Expand Down Expand Up @@ -49,6 +56,9 @@ vi.mock('@object-ui/permissions', async (importOriginal) => {
});

import { RecordPathRenderer } from '../record-path';
import { buildDefaultPageSchema } from '../../synth/buildDefaultPageSchema';
// Registers `record:path` for the `SchemaRenderer` mounts below.
import '../../index';

const STAGES = [
{ value: 'draft', label: 'Draft' },
Expand Down Expand Up @@ -272,3 +282,151 @@ describe('record:path allowStageChange — who sees the readout', () => {
await waitFor(() => noButtons(container));
});
});

/** The object as a host binds it: a stage picklist the default page derives its path from. */
const QUOTE_DEF = {
name: 'crm_quote',
fields: {
name: { type: 'text', label: 'Name' },
status: {
type: 'select',
label: 'Status',
options: STAGES.map(({ value, label }) => ({ value, label })),
},
amount: { type: 'currency', label: 'Amount' },
},
stageField: 'status',
};

/** A blocking machine: from draft only in_review (or declined); never straight to accepted. */
const MACHINE = {
type: 'state_machine',
name: 'quote_flow',
field: 'status',
message: 'A quote cannot skip review.',
transitions: { draft: ['in_review', 'declined'], in_review: ['accepted', 'declined'] },
};

/** Every `record:path` node among a synthesized page's top-level components. */
const pathNodesOf = (page: any): any[] =>
page.regions[0].components.filter((c: any) => c?.type === 'record:path');

function mountNode(node: unknown, opts: Omit<MountOptions, 'allowStageChange'> = {}) {
const dataSource = opts.dataSource ?? makeDataSource();
const refresh = opts.refresh ?? vi.fn(async () => {});
const r = render(
<RecordContextProvider
objectName="crm_quote"
recordId="q1"
data={{ id: 'q1', status: 'draft', updated_at: VERSION }}
objectSchema={opts.objectSchema ?? QUOTE_DEF}
dataSource={dataSource as any}
refresh={refresh}
>
<InlineEditProvider canEdit={opts.canEdit ?? true}>
<SchemaRenderer schema={node as never} />
</InlineEditProvider>
</RecordContextProvider>,
);
return { ...r, dataSource, refresh };
}

const buttonNames = (row: HTMLElement): (string | null)[] =>
within(row).queryAllByRole('button').map((b) => b.getAttribute('aria-label'));

describe('#30075 the default record page turns click-to-advance on', () => {
it('the synthesized page writes allowStageChange: true on its path node', () => {
const [path] = pathNodesOf(buildDefaultPageSchema(QUOTE_DEF as never));
expect(path.properties).toMatchObject({ statusField: 'status', allowStageChange: true });
});

it('a reader who may edit the stage field gets a press, with a confirmation, through the record update', async () => {
const [path] = pathNodesOf(buildDefaultPageSchema(QUOTE_DEF as never));
const { container, dataSource, refresh } = mountNode(path);
expect(rows(container)).toHaveLength(2);
for (const row of rows(container)) {
expect(buttonNames(row)).toEqual(['Move to In review', 'Move to Accepted', 'Move to Declined']);
}
fireEvent.click(within(rows(container)[0]).getByRole('button', { name: 'Move to In review' }));
// Nothing is written before the reader confirms.
const dialog = await screen.findByRole('alertdialog');
expect(dataSource.update).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: 'Move' }));
await waitFor(() => expect(dataSource.update).toHaveBeenCalledTimes(1));
expect(dataSource.update).toHaveBeenCalledWith('crm_quote', 'q1', { status: 'in_review' }, { ifMatch: VERSION });
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
});

it('a reader the page may not let edit this record keeps the read-only path', () => {
const [path] = pathNodesOf(buildDefaultPageSchema(QUOTE_DEF as never));
const { container } = mountNode(path, { canEdit: false });
expect(rows(container)).toHaveLength(2);
for (const row of rows(container)) expect(row.querySelectorAll('button,[tabindex]')).toHaveLength(0);
});

it('a reader without the edit grant on the stage field keeps the read-only path', () => {
perms.writeDenied = ['status'];
const [path] = pathNodesOf(buildDefaultPageSchema(QUOTE_DEF as never));
const { container } = mountNode(path);
expect(rows(container)).toHaveLength(2);
for (const row of rows(container)) expect(row.querySelectorAll('button,[tabindex]')).toHaveLength(0);
});

it.each([true, false])('an authored highlights slot keeps its declared allowStageChange: %s', (declared) => {
const authored = { type: 'record:path', properties: { statusField: 'status', stages: STAGES, allowStageChange: declared } };
const page = buildDefaultPageSchema(QUOTE_DEF as never, { slots: { highlights: [authored] } } as never);
const [path] = pathNodesOf(page);
expect(path.properties.allowStageChange).toBe(declared);
const { container } = mountNode(path);
const names = buttonNames(rows(container)[0]);
expect(names.length > 0).toBe(declared);
});

it('an authored highlights slot that leaves the key out keeps the readout', () => {
const authored = { type: 'record:path', properties: { statusField: 'status', stages: STAGES } };
const page = buildDefaultPageSchema(QUOTE_DEF as never, { slots: { highlights: [authored] } } as never);
const [path] = pathNodesOf(page);
expect('allowStageChange' in path.properties).toBe(false);
for (const row of rows(mountNode(path).container)) expect(buttonNames(row)).toEqual([]);
});
});

describe('#30075 the press offers only the moves the field\'s state machine admits', () => {
const MACHINED = { ...QUOTE_DEF, validations: [MACHINE] };

it('a stage the machine cannot reach from the stored value is a readout, not a button', () => {
const [path] = pathNodesOf(buildDefaultPageSchema(MACHINED as never));
const { container } = mountNode(path, { objectSchema: MACHINED });
for (const row of rows(container)) {
expect(buttonNames(row)).toEqual(['Move to In review', 'Move to Declined']);
// The unreachable stage is still drawn, as a plain listitem.
expect(within(row).getByText('Accepted').closest('button')).toBeNull();
}
});

it('control: a warning-severity machine does not narrow the press (the server would not refuse)', () => {
const warned = { ...QUOTE_DEF, validations: [{ ...MACHINE, severity: 'warning' }] };
const [path] = pathNodesOf(buildDefaultPageSchema(warned as never));
const { container } = mountNode(path, { objectSchema: warned });
expect(buttonNames(rows(container)[0])).toEqual(['Move to In review', 'Move to Accepted', 'Move to Declined']);
});

it('a move the server still refuses (here a required field elsewhere) is shown in place, nothing else changes', async () => {
const refusal = Object.assign(new Error('VALIDATION_FAILED'), {
code: 'VALIDATION_FAILED',
fields: [{ field: 'amount', code: 'required', message: 'is required to send a quote for review' }],
});
const [path] = pathNodesOf(buildDefaultPageSchema(MACHINED as never));
const { container, refresh } = mountNode(path, {
objectSchema: MACHINED,
dataSource: makeDataSource(async () => { throw refusal; }),
});
await pressAndConfirm(container, 'In review');
const alert = await within(container).findByRole('alert');
expect(alert).toHaveTextContent('Amount: is required to send a quote for review');
expect(refresh).not.toHaveBeenCalled();
for (const row of rows(container)) {
expect(row.querySelector('[aria-current="step"]')).toHaveAttribute('aria-label', 'Draft, current stage');
}
});
});
23 changes: 13 additions & 10 deletions ui/packages/plugin-detail/src/renderers/record-path.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@
* mouseup → click) left the record's status untouched. Users spent clicks
* on it before concluding it was decoration.
*
* Unless the author opts in with `allowStageChange: true`, nothing on the
* path writes, so the pixels must not promise a press. Click-to-advance is
* that opt-in (objectstack#28061, `./recordPathStageChange`): only then, and
* only for a reader who may edit `statusField` on this record, does each stage
* other than the current one carry a real `<button>` that asks to move the
* record there. Everyone else — and every page that does not opt in — gets the
* readout below, unchanged.
* Unless the node carries `allowStageChange: true`, nothing on the path
* writes, so the pixels must not promise a press. Click-to-advance is that
* opt-in (objectstack#28061, `./recordPathStageChange`): only then, and only
* for a reader who may edit `statusField` on this record, does each stage
* other than the current one — and that the field's state machine can reach —
* carry a real `<button>` that asks to move the record there. The synthesized
* default record page writes the opt-in on its path (objectstack#30075); an
* authored page keeps what it declared. Everyone else — and every node that
* does not opt in — gets the readout below, unchanged.
*
* So the presentation below is a PROGRESS RAIL: a thin decorative indicator
* per stage with the label as plain text beneath it, which is the same
Expand Down Expand Up @@ -594,9 +596,10 @@ const RecordPathBody: React.FC<RecordPathRendererProps> = ({
/** The label's full text as a `title`, for a row whose labels can be cut. */
labelTitle?: string;
}) => {
// A stage is a press only behind the opt-in, and never the stage the
// record already stands on: there is nowhere to move to.
const press = stageChange.pressable && o.state !== 'current';
// A stage is a press only behind the opt-in, never the stage the record
// already stands on (there is nowhere to move to), and never a stage the
// field's blocking state machine cannot reach from the stored value.
const press = stageChange.pressable && o.state !== 'current' && stageChange.admits(o.stage.value);
const face = (
<>
<span aria-hidden="true" data-stage-rail="" className={railClass(o.state, o.terminal)} />
Expand Down
31 changes: 25 additions & 6 deletions ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,18 @@
* `updated_at` as the `ifMatch` concurrency token, and re-reads the record.
*
* Which moves are legal is the SERVER's call: the object's `state_machine`
* rule (ADR-0020), required fields and edit rights are judged on the write, and
* nothing here evaluates them. A refusal is shown in place, under the rail —
* the server's own sentences first, a fixed line only when it sent none.
* rule (ADR-0020), required fields and edit rights are judged on the write. A
* refusal is shown in place, under the rail — the server's own sentences
* first, a fixed line only when it sent none.
*
* What the path OFFERS follows the same machine the record form, the grid's
* inline editor and the board read (`stateMachineNextValues`): a stage the
* field's blocking `state_machine` rule cannot reach from the STORED value is
* drawn as a readout, not a button, so the press never promises a move the
* server is bound to refuse. The default record page turns this block's press
* on (objectstack#30075), which is why an always-refused button would reach
* every app with a machine on its stage field. A field without such a rule
* offers every stage, and the server still judges each write.
*/

import React from 'react';
Expand All @@ -41,7 +50,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@object-ui/components';
import { resolveFieldAffordance } from '@object-ui/core';
import { resolveFieldAffordance, stateMachineNextValues } from '@object-ui/core';
import { usePermissions } from '@object-ui/permissions';
import {
declaredUserMessage,
Expand Down Expand Up @@ -113,6 +122,12 @@ const canUpdate = (ds: unknown): boolean => {
export interface StageChange {
/** Whether this reader may press a stage of this record's path. */
pressable: boolean;
/**
* Whether the field's blocking `state_machine` admits a move from the
* stored value to `value` — `true` for every value when no such rule
* governs the field. Only meaningful while `pressable`.
*/
admits: (value: unknown) => boolean;
/** A write is in flight. */
busy: boolean;
/** The last refusal's lines, until the next press. */
Expand Down Expand Up @@ -152,6 +167,10 @@ export function useStageChange(allowStageChange: boolean, statusField: string |
&& !NON_EDITABLE_SYSTEM_FIELDS.has(statusField)
&& resolveFieldAffordance('recordEdit', perms, objectName, statusField);

const stored = statusField ? (ctx?.data as Record<string, unknown> | undefined)?.[statusField] : undefined;
const reachable = statusField ? stateMachineNextValues(ctx?.objectSchema, statusField, stored) : null;
const admits = (value: unknown): boolean => reachable === null || reachable.has(String(value));

const request = React.useCallback((stage: { value: unknown; label: string }) => {
setRefusal(null);
setTarget(stage);
Expand All @@ -166,7 +185,7 @@ export function useStageChange(allowStageChange: boolean, statusField: string |
const confirm = async () => {
const stage = target;
setTarget(null);
if (!stage || !pressable) return;
if (!stage || !pressable || !admits(stage.value)) return;
setBusy(true);
const updatedAt = (ctx?.data as Record<string, unknown> | undefined)?.updated_at;
const ifMatch = typeof updatedAt === 'string' ? updatedAt : undefined;
Expand Down Expand Up @@ -202,5 +221,5 @@ export function useStageChange(allowStageChange: boolean, statusField: string |
</AlertDialog>
);

return { pressable, busy, refusal, request, dialog };
return { pressable, admits, busy, refusal, request, dialog };
}
14 changes: 13 additions & 1 deletion ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,18 @@ export function buildDefaultActions(
*
* Returns an array because the strip is conceptually one region with
* two adjacent nodes.
*
* The default page's path turns click-to-advance on (`allowStageChange:
* true`, maintainer ruling recorded on objectstack#30075, option A): moving a
* record to its next stage is the record page's most frequent edit, and most
* apps never author a page to opt in on. Nothing about WHO may press moves
* here — `record:path`'s `useStageChange` still asks the page's edit session,
* the field's read-only/computed markers and the caller's field edit grant,
* offers only the moves the field's blocking `state_machine` admits, confirms
* every press, and saves through the ordinary record update, so the server's
* validation decides. A reader who may not edit the field keeps the readout.
* An authored page — or an authored `highlights` slot — writes its own
* `record:path` node and keeps whatever it declared; no new key is involved.
*/
export function buildDefaultHighlights(
def: ObjectDefLike | undefined,
Expand All @@ -550,7 +562,7 @@ export function buildDefaultHighlights(
out.push(componentNode('record:highlights', { fields: highlightFields }));
}
if (statusField && stages && stages.length > 0) {
out.push(componentNode('record:path', { statusField, stages }));
out.push(componentNode('record:path', { statusField, stages, allowStageChange: true }));
}
return out;
}
Expand Down
Loading