Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/5144-editinline-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@object-ui/core': minor
'@object-ui/plugin-list': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-view': patch
---

List views read `userActions.editInline` with the spec's default, off. A view's `inlineEdit` folds into it, and the console's inline-edit toggle no longer writes to the view (objectui#5144).

**Breaking for a view that relied on the old default.** The spec declares `userActions.editInline` with `.default(false)`: the list is read-only unless the author opts in. The interface page already read it that way. The object-list toolbar did not: it read an absent `editInline` as "defer to the host", so every console grid offered the inline-edit toggle unless a view said `editInline: false`. The toolbar now reads it as the spec does. A grid view that declares neither `userActions.editInline` nor `inlineEdit` no longer offers the inline-edit toggle, on the wide toolbar or in the compact settings popover, and never opens in edit mode.

**The fold.** `normalizeListViewSchema` (`@object-ui/core`) now folds a boolean `inlineEdit` into `userActions.editInline`, the way it already folds the `show*` flags into the other toggles:

- `inlineEdit: true` reads as `editInline: true`: the toggle is offered and the grid opens in edit mode;
- `inlineEdit: false` reads as `editInline: false`: no toggle;
- an explicit `userActions.editInline` wins over `inlineEdit`, in both directions;
- a view with neither key reads off.

`inlineEdit` stays on the folded view, because `ListView` opens the grid in edit mode from it. Nothing migrates stored views.

**The console toggle is session-only (`@object-ui/app-shell`).** Both keys are the author's permission. The console's toolbar toggle used to store a user's edit mode in the view's `inlineEdit`; after the fold, switching it off would have taken the toggle away for good. It now writes nothing. The toggle switches edit mode for the session, and each load starts from the view's own `inlineEdit`. `ListView` still reports the toggle through `onInlineEditChange`.

**A named view's `inlineEdit` keeps its precedence (`@object-ui/plugin-view`).** On a host's `renderListView`, `ObjectView` merges `userActions` from the node, the host's `views` entry and the active named view, the named view last. The named view's `userActions` now go through the same fold as the other two. So a named view's `inlineEdit` decides whether inline editing is offered ahead of a host entry's, as it already decided the edit mode.

**What to do.** A view that should offer inline editing declares `userActions.editInline: true`. The toggle then stays offered whatever the user does with it. Two costs come with the session-only toggle:

- the edit mode is not remembered across loads;
- a view, or a personalization overlay, where the old toggle stored `inlineEdit: false` still reads off. That is existing data, and it is not migrated. Declaring `userActions.editInline: true` on the view brings the toggle back.

Nothing is added to a package entry: no export, prop, type member or language-pack key.
23 changes: 23 additions & 0 deletions content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,29 @@ node takes the value from the active named view first, ahead of the host's
`views` entry (objectui#10758).
`src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx` pins each family.

**Inline editing on that node is opt-in.** Both keys below are the author's
permission. `ListView` reads the node's `userActions.editInline` with the
spec's default, off, so a view that declares neither key offers no inline
editing (objectui#5144). When the node carries no `editInline` of its own, its
`inlineEdit` stands in for it. The named view's keys win over the host
`views` entry's, as for every member above:

| The view declares | Inline-edit toggle | Grid opens in edit mode |
| --- | --- | --- |
| neither key | not offered | no |
| `inlineEdit: true` | offered | yes |
| `inlineEdit: false` | not offered | no |
| `userActions.editInline: true` | offered | only with `inlineEdit: true` |
| `userActions.editInline: false` | not offered | no, whatever `inlineEdit` says |

The toggle switches the edit mode for the session and reports it through
`ListView`'s `onInlineEditChange`. The console stores nothing for it: each load
opens in the mode the view's `inlineEdit` declares, so a user's choice is not
remembered across loads. A view where an earlier console stored
`inlineEdit: false` still reads off; that data is not migrated. To offer inline
editing, declare `userActions.editInline: true`. Neither key opens editing past
the object's own editability or the user's permission to update the object.

### Update

Editing is reached from a row's edit action, and `operations.update` is what
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5144, the interface-page half.
*
* An ADR-0047 interface page reads the page config's `userActions.editInline`
* as `=== true` and hands it to `ListView` as the view's `inlineEdit`. It does
* not forward `editInline` itself, so the `userActions` block it builds carries
* no `editInline` key. Before objectui#5144, `ListView` read that absent key as
* "defer to the host channel" and offered inline editing on this page's compact
* toolbar. The page itself read the absent case as off.
*
* After the B-fold, `ListView` reads what this page composes through
* `normalizeListViewSchema`, which folds the `inlineEdit` the page sets into
* `userActions.editInline`. So the two readings agree. This file pins it by
* capturing the schema the page hands `ListView` and running it through the
* real fold, which is the input `ListView`'s `inlineEditOffered` reads. What
* `ListView` does with `editInline` is pinned in `@object-ui/plugin-list`'s
* `ListView.permissions.test.tsx`.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';

vi.mock('react-router-dom', () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useNavigate: () => vi.fn(),
}));

vi.mock('@object-ui/i18n', async (importOriginal) => {
const actual = await (importOriginal as any)();
return {
...actual,
useObjectTranslation: () => ({ t: (_k: string, o?: any) => o?.defaultValue ?? _k }),
};
});

vi.mock('@object-ui/auth', async (importOriginal) => {
const actual = await (importOriginal as any)();
return { ...actual, useAuth: () => ({}) };
});

// Only the schema this page hands `ListView` is under test.
vi.mock('@object-ui/plugin-list', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-list')>()),
ListView: (props: any) => (
<div data-testid="list-view-schema" data-schema={JSON.stringify(props?.schema ?? null)} />
),
}));

let testDataSource: any;
let testObjects: any[];

vi.mock('@object-ui/react', async (importOriginal) => {
const actual = await (importOriginal as any)();
return {
...actual,
useAdapter: () => testDataSource,
useMetadata: () => ({ objects: testObjects }),
};
});

import { normalizeListViewSchema } from '@object-ui/core';
import { InterfaceListPage } from './InterfaceListPage';

const OBJECT_NAME = 'showcase_task';
const VIEW_ID = `${OBJECT_NAME}.all`;

const objectDef = {
name: OBJECT_NAME,
fields: { title: { type: 'text' }, status: { type: 'text' } },
listViews: {
[VIEW_ID]: { name: VIEW_ID, type: 'grid', columns: ['title', 'status'] },
},
};

/** Renders the page and returns the schema it hands `ListView`. */
async function composedSchema(pageUserActions: Record<string, unknown> | undefined) {
testDataSource = {};
testObjects = [objectDef];
const page = {
name: 'showcase_task_list',
label: 'Tasks',
interfaceConfig: {
source: OBJECT_NAME,
sourceView: 'all',
recordAction: 'none',
...(pageUserActions ? { userActions: pageUserActions } : {}),
},
};
render(<InterfaceListPage page={page as any} />);
await waitFor(() => expect(screen.queryByTestId('list-view-schema')).not.toBeNull());
return JSON.parse(screen.getByTestId('list-view-schema').getAttribute('data-schema') || 'null');
}

/** The `editInline` `ListView` reads: the composed schema, through the fold. */
const editInlineRead = (schema: Record<string, unknown>) =>
(normalizeListViewSchema(schema).userActions as Record<string, unknown> | undefined)?.editInline;

describe('InterfaceListPage: editInline reads the same as ListView after the fold (objectui#5144)', () => {
beforeEach(() => {
testDataSource = undefined;
testObjects = [];
});

it('an ABSENT page editInline reads off on both: no edit mode, and `editInline` folds to false', async () => {
const schema = await composedSchema({ search: true });
// The page's own reading (unchanged): `=== true`.
expect(schema.inlineEdit).toBe(false);
// The page forwards no `editInline` of its own…
expect(schema.userActions).not.toHaveProperty('editInline');
// …so `ListView` reads the folded one, which is off.
expect(editInlineRead(schema)).toBe(false);
});

it('a page with no `userActions` block at all reads off the same way', async () => {
const schema = await composedSchema(undefined);
expect(schema.inlineEdit).toBe(false);
expect(editInlineRead(schema)).toBe(false);
});

it('a page that opts in reads on, on both', async () => {
const schema = await composedSchema({ editInline: true });
expect(schema.inlineEdit).toBe(true);
expect(editInlineRead(schema)).toBe(true);
});

it('an explicit page `editInline: false` stays off', async () => {
const schema = await composedSchema({ editInline: false });
expect(schema.inlineEdit).toBe(false);
expect(editInlineRead(schema)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,9 @@ describe('objectui#11643 — the console-made tab of a view-less object keeps it
expect(listSchema.label).toBe(FALLBACK_TAB_LABEL);

// Every control that reaches `persistViewPatch`, through both the schema's
// and the list's own spelling of it.
// and the list's own spelling of it. `onInlineEditChange` no longer reaches
// it at all (objectui#5144, ruling E: the toggle is session-only); it stays
// here because it must still write nothing.
act(() => {
listSchema.onDensityChange('comfortable');
listSchema.onSortChange(SORT);
Expand Down
Loading
Loading