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
13 changes: 13 additions & 0 deletions .changeset/13089-page-list-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': minor
'@object-ui/plugin-list': minor
'@object-ui/react': patch
---

A list's `addRecord` Add button opens the create form on a console page and on the console's object list (objectui#13089).

- **Page `list-view` nodes.** The console's page host now serves the record-form create to a list that names no `relationshipField` and has no parent: a page `list-view` node. So `addRecord: { enabled: true }` draws its Add button, and a click opens the create form for the listed object. A related list whose parent did not resolve is still served nothing, so it draws no "+ New" that would open a child form linked to no parent. No Edit, Delete or row click is served to a list without a parent.
- **`list-view.addRecord` is a published input** of the `list-view` and `list` registrations. `enabled` and `position` are read; `mode` and `formView` are not.
- **The console's object list.** A saved view that declares `addRecord.enabled` used to draw an Add button whose click did nothing, also for a viewer who may not create. The button now opens the header New's create, and is withheld wherever that New is not offered or is greyed.
- **`editMode: 'page'`.** A create linked to no parent, from the record-actions bridge, now opens an `editMode: 'page'` object's full-page create route, as the object list does, instead of the overlay.
- **`ResolveRelatedRecordActionsInput`** (`@object-ui/react`) documents what an input without `relationshipField` asks for: an object-scoped list's handlers. No type or member changes.
11 changes: 8 additions & 3 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1652,14 +1652,14 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
* renderer does instead, and each leaves this map the day a reader lands
* and the key is published. objectui#13074 struck one of the five:
* `requiredPermissions`, which `ListViewBlock` now honours on a page node
* (`ListViewBlock.inertKeys-13074.test.tsx`).
* (`ListViewBlock.inertKeys-13074.test.tsx`). objectui#13089 struck
* `addRecord`, whose Add button the console's page host now serves a create
* for (`PageView.listViewAddRecord-13089.test.tsx`).
*/
'list-view.name':
'Unpublished by measurement (objectui#13047): read by nothing on a page `list-view` node. The node draws, queries and hands its view the same thing with and without it — the view\'s own `name` is its identity in metadata, which an embedded list has no use for.',
'list-view.sharing':
'Unpublished by measurement (objectui#13047): `sharing.type` draws a Share button with no click handler, and the sharing config is enforced nowhere in the renderer. Publishing it would advertise a dead control (objectui#13047\'s report).',
'list-view.addRecord':
'Unpublished by measurement (objectui#13047, objectui#13074): on a page node the Add button is withheld, because the page host serves no `onCreate`. The block routes `addRecord.enabled` to the `onCreate` a `RelatedRecordActionsContext` provider serves for the listed object, and the console\'s page host answers a parent-less list with no handlers, so a console page draws no Add button (`ListViewBlock.specInputs-13047.test.tsx`). Serving `onCreate` there is objectui#13089\'s; the key is published when it lands.',
'list-view.filterableFields':
'Unpublished by measurement (objectui#13047): the row declares it as the legacy shorthand of `userFilters.fields` (end-user quick filters), and the list reads it as something else — the field list of the Filter panel — while it draws no quick-filter bar. Publishing it would advertise the row\'s meaning, which nothing honours; which meaning stands is a contract question for the seat (objectui#13047\'s report).',
};
Expand Down Expand Up @@ -2838,6 +2838,7 @@ const actionLeafMemberPins = (type: 'action:button' | 'action:icon'): Record<str
*/
const LIST_VIEW_PIN_FILE = 'packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx';
const LIST_VIEW_13074_PIN_FILE = 'packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx';
const LIST_VIEW_13089_PIN_FILE = 'packages/app-shell/src/views/__tests__/PageView.listViewAddRecord-13089.test.tsx';

const MEMBER_PINS: Record<string, MemberPin> = {
...actionLeafMemberPins('action:button'),
Expand Down Expand Up @@ -3028,6 +3029,10 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: LIST_VIEW_PIN_FILE,
pins: 'The `pageSize` member caps the query (`$top`), and the same node with no `pagination` sends the default page (objectui#13047).',
},
'list-view.addRecord': {
file: LIST_VIEW_13089_PIN_FILE,
pins: 'Through the real `PageView`: `enabled: true` draws the Add button, and a click pushes `form=new` + `formObject` for the listed object (no `formLink`), or navigates to the full-page create route for an `editMode: \'page\'` object; no create grant, and a create the object greys, withhold it; a node without the key draws none. Controls: an unbound `record:related_list` on the same page draws no `+ New`, and a parent-less resolve is served `onCreate` alone (objectui#13089).',
},
'list-view.requiredPermissions': {
file: LIST_VIEW_13074_PIN_FILE,
pins: 'Members are CAPABILITY names, ALL required, read through `hasCapabilities` under a real stock `MePermissionsProvider`: an unheld capability puts the insufficient-permissions notice in place of the list and no `find` is sent; a held one draws and queries the list; a PARTIAL grant on a two-entry array is gated (the `.every` row, with the full grant as its control); an empty array and an absent key are no gate; and a provider that reports no `systemPermissions` fails open (objectui#13074).',
Expand Down
25 changes: 22 additions & 3 deletions content/docs/plugins/plugin-list.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,28 @@ with its keys in `properties`. `requiredPermissions` gates what the node draws:
missing one sees "Insufficient permissions to view list." in place of the list,
as the record blocks show, and no records are queried.

`addRecord` and `sharing` are not published inputs of a page node yet: the Add
button is drawn only under a host that serves the object's create handler, and
the Share button has no action the spec declares.
`addRecord` draws an Add button, and `position` places it (`"top"`, `"bottom"`
or `"both"`). The spec row declares `enabled` default `true` and `position`
default `"bottom"`, but the renderer does not apply those defaults: it draws the
button only for an explicit `enabled: true`, and places an absent `position` at
the top. objectui#13099 owns that gap; until it lands, write both keys. A click opens the
create form the object list's New opens: the record-form overlay, or the
full-page create for an object whose `editMode` is `page`. The button is not
drawn for a viewer who may not create, or under a host that serves no create.
`mode` and `formView` are not read.

```json
{
"type": "list-view",
"properties": {
"data": { "provider": "object", "object": "deal" },
"addRecord": { "enabled": true, "position": "top" }
}
}
```

`sharing` is not a published input of a page node yet: the Share button has no
action the spec declares.

## Status

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* 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.
*/


/**
* objectui#13089 item 2 — on the console's object list, a saved view that
* declares `addRecord: { enabled: true }` draws `ListView`'s Add button, and
* the button opens the object list's own create flow: the same `onEdit(null)`
* the header's New calls (`useObjectActions().create`), which `AppContent`
* turns into the record-form overlay or, for an `editMode: 'page'` object, the
* full-page create route.
*
* Before the fix `renderListView` forwarded `addRecord` and no `onAddRecord`,
* so the button drew and its click did nothing.
*
* The button follows the verdict the header's New follows: a viewer who may
* not create (or whose create the object greys) gets no Add button, rather
* than one that would open a form the server refuses.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

const { allowCreate } = vi.hoisted(() => ({ allowCreate: { value: true } }));

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
// Stable identities — `ListView` names `perms` in its fetch dependencies.
const perms = {
check: (_object: string, action: string) => ({ allowed: action === 'create' ? allowCreate.value : true }),
checkField: () => true,
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
isLoaded: false,
hasCapabilities: () => true,
can: (_object: string, action: string) => (action === 'create' ? allowCreate.value : true),
cannot: (_object: string, action: string) => (action === 'create' ? !allowCreate.value : false),
};
const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] };
return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms };
});

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }),
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
createAuthenticatedFetch: () => vi.fn(),
}));

vi.mock('@object-ui/collaboration', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useRealtimeSubscription: () => ({ lastMessage: null }),
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
}));

vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));

vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));
vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));

vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadataClient: () => ({ save: vi.fn(async () => ({})), get: vi.fn(async () => null) }),
}));

import { ObjectView } from './ObjectView';
import { ExpressionProvider } from '../providers/ExpressionProvider';

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

function objects(addRecord?: Record<string, unknown>) {
return [
{
name: OBJECT_NAME,
label: 'Task',
fields: {
id: { type: 'text', label: 'Id' },
name: { type: 'text', label: 'Name' },
},
listViews: {
[VIEW_ID]: {
name: VIEW_ID,
label: 'Everything',
type: 'grid',
columns: ['name'],
...(addRecord ? { addRecord } : {}),
},
},
},
];
}

beforeEach(() => {
allowCreate.value = true;
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }),
),
);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

function mount(addRecord?: Record<string, unknown>) {
const onEdit = vi.fn();
const dataSource = {
find: vi.fn(async () => ({ data: [{ id: 'r1', name: 'Alpha' }], total: 1 })),
findOne: vi.fn(async () => null),
create: vi.fn(async () => ({})),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
};
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'user' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}/view/${VIEW_ID}`]}>
<Routes>
<Route
path="/apps/:appName/:objectName/view/:viewId"
element={<ObjectView dataSource={dataSource as never} objects={objects(addRecord)} onEdit={onEdit} />}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
return { onEdit, dataSource };
}

describe('objectui#13089 — a saved view’s `addRecord` on the console object list opens the object list’s create', () => {
it('draws the Add button, and clicking it calls the same create the header’s New calls', async () => {
const { onEdit, dataSource } = mount({ enabled: true });
await screen.findByText('Alpha', undefined, { timeout: 5000 });
const add = await screen.findByTestId('add-record-button');
fireEvent.click(add);
await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1));
// A NEW record: no record handed over, as the header's New hands none.
expect(onEdit).toHaveBeenCalledWith(null);
expect(dataSource.create).not.toHaveBeenCalled();

// The header's New is the same create.
fireEvent.click(screen.getByTestId('object-view-new-button'));
await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(2));
expect(onEdit.mock.calls[1]).toEqual(onEdit.mock.calls[0]);
});

it('a viewer who may not create gets no Add button, as they get no New', async () => {
allowCreate.value = false;
mount({ enabled: true });
await screen.findByText('Alpha', undefined, { timeout: 5000 });
expect(screen.queryByTestId('object-view-new-button')).toBeNull();
expect(screen.queryByTestId('add-record-button')).toBeNull();
});

it('CONTROL: a view that declares no `addRecord` draws no Add button', async () => {
mount();
await screen.findByText('Alpha', undefined, { timeout: 5000 });
expect(screen.getByTestId('object-view-new-button')).toBeTruthy();
expect(screen.queryByTestId('add-record-button')).toBeNull();
});
});
21 changes: 19 additions & 2 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1829,6 +1829,15 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
* so `renderListView` depends on a primitive, not a memoised identity
* (AGENTS.md #10).
*/
/**
* objectui#13089 — the view's `addRecord` Add button opens this page's own
* create (`actions.create`, the header New's), so it is offered exactly
* where that New is offered and live: the object-level verdict, the
* toolbar-scope `visibleWhen`, and not greyed by `disabledWhen` (`ListView`
* draws no greyed Add button). A boolean, so `renderListView` depends on
* a primitive (AGENTS.md #10).
*/
const listAddRecordOpensCreate = objectCanCreate && createVisible && !createDisabled;
const mayInvokeToolbarAction = useCapabilityGate();
const toolbarPredicateScope = usePredicateScope();
const pageOffersCreate =
Expand Down Expand Up @@ -3487,7 +3496,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
bulkActionDefs: (viewDef as any).bulkActionDefs ?? (listSchema as any).bulkActionDefs,
sharing: viewDef.sharing ?? listSchema.sharing,
addRecord: viewDef.addRecord ?? listSchema.addRecord,
// objectui#13089 — the Add button `ListView` draws from
// `addRecord.enabled` opens the header New's create (`onAddRecord`
// below), so it follows the New's verdict: withheld, not drawn
// dead, for a viewer who may not create or whose create is greyed.
addRecord: listAddRecordOpensCreate ? viewDef.addRecord ?? listSchema.addRecord : undefined,
conditionalFormatting: viewDef.conditionalFormatting ?? listSchema.conditionalFormatting,
// ADR-0047 (amended, objectui #2338): this is the object default
// list = "views" mode. `dropdown` (value-chip) userFilters ARE
Expand Down Expand Up @@ -3661,6 +3674,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
viewDefaults={{ hiddenFields: viewHiddenFields, rowHeight: viewRowHeight }}
className={className}
onEdit={editHandler}
// objectui#13089 — the view's Add button is the header New:
// `onEdit(null)`, which the host routes to the record-form
// overlay or an `editMode: 'page'` object's create route.
onAddRecord={listAddRecordOpensCreate ? actions.create : undefined}
onDelete={(record: any) => {
if (record?.id != null) {
// useObjectActions.deleteRecord wraps execute() which
Expand Down Expand Up @@ -3724,7 +3741,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
dataSource={ds}
/>
);
}, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar]);
}, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar]);

// Memoize the merged views array so PluginObjectView doesn't get a new
// reference on every render (which would trigger unnecessary data refetches).
Expand Down
20 changes: 19 additions & 1 deletion packages/app-shell/src/views/RelatedRecordActionsBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import { usePermissions } from '@object-ui/permissions';
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RECORD_TRAIL_PARAM, appendRecordTrail } from '../urlParams.js';
import { documentHref } from './documentHref.js';
import { recordListPath } from './useOpenRecordList.js';
import { resolveRecordFormTarget } from '../utils/recordFormNavigation.js';

/**
* Notify open related lists for `objectName` to refetch.
Expand Down Expand Up @@ -398,16 +399,33 @@ export function RelatedRecordActionsBridge({
// form overlay, by URL params. Pushes ONE history entry (Back = close, the
// parent detail stays mounted underneath). The read side lives in
// `AppContent` (see its record-form URL contract).
//
// objectui#13089 — a create linked to NO parent is not a child task: it is
// the object's own create (a page `list-view` node's Add button), so it
// takes the object list's create semantics. For an `editMode: 'page'`
// object that is the full-page create route `AppContent`'s `handleEdit`
// navigates to (`resolveRecordFormTarget`), not the overlay.
const openChildForm = useCallback(
(opts: { objectName: string; recordId?: string; link?: { field: string; parentId: string | number } }) => {
if (opts.recordId == null && !opts.link) {
const target = resolveRecordFormTarget({
objectDef: objects.find((o: any) => o?.name === opts.objectName),
baseUrl: base,
record: null,
});
if (target.kind === 'page') {
navigate(target.url);
return;
}
}
const sp = new URLSearchParams(window.location.search);
sp.set(RECORD_FORM_PARAM, opts.recordId ?? 'new');
sp.set(RECORD_FORM_OBJECT_PARAM, opts.objectName);
if (opts.link) sp.set(RECORD_FORM_LINK_PARAM, `${opts.link.field}:${opts.link.parentId}`);
else sp.delete(RECORD_FORM_LINK_PARAM);
setSearchParams(sp); // push → Back closes the overlay
},
[setSearchParams],
[setSearchParams, objects, base, navigate],
);

// Execute a child object's row action against the clicked record. Reuses the
Expand Down
Loading
Loading