Skip to content
Open
13 changes: 13 additions & 0 deletions .changeset/33317-row-opens-nothing-plain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-grid': patch
'@object-ui/plugin-list': patch
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

fix(plugin-grid, plugin-list, app-shell): a list row whose click opens nothing no longer looks clickable

Clause-②: yes (widening) — `ObjectGridComponentProps` (published by `@object-ui/plugin-grid`) and `ListViewProps` (published by `@object-ui/plugin-list`) each gain one optional prop, `rowOpensRecord?: boolean`. Absent, every row renders as before. Nothing is removed or narrowed.
Metadata-protocol: none

**Rows that open nothing.** A list row opens nothing when its view is authored `navigation: { mode: 'none' }`, or when its object's data API serves no single-record read (a list-only object, such as the SCIM connection credentials in Setup). The grid still drew such a row's first column as a link, and its right-click menu still offered Open and Open in new tab, so a click that did nothing looked like a broken page. The Console's object list, object data page and interface list pages now tell the grid that these rows open nothing (`rowOpensRecord={false}`, handed through `ListView`): the first column, and any column authored `link: true`, show the value as plain text, and the right-click menu offers no Open items. The row's actions (`list_item`) stay in the row menu and the right-click menu. The row click itself still reaches the host, so a Cmd/Ctrl-click on a row of a view authored `none` still opens the record in a new tab, as before. Rows that open a drawer or a page are unchanged.
13 changes: 13 additions & 0 deletions content/docs/frontend/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,19 @@ export const UserList = ({ needsAccess }: { needsAccess: (id: string) => boolean
The console's Setup → Users list on a cloud-managed environment uses it to mark
a user who holds no permission set beyond the baseline (objectui#13330).

### Rows that open nothing

A host that owns the row click (`onRowClick`) and knows that it opens nothing
for these rows passes `rowOpensRecord={false}`. The grid then draws its
record-link cells (the first column, and any column authored `link: true`) as
plain text, with no anchor and no ARIA link semantics, and the row's right-click menu
offers no Open or Open in new tab; the row's actions stay. The row click still
reaches `onRowClick`, so anything else the host does with it is unchanged.
Absent (or `true`), every row is drawn as before. It is a host prop, not a
schema key: list metadata cannot set it. The console passes `false` for a view
authored `navigation: { mode: 'none' }` and for an object whose data API serves
no single-record read (objectstack#33317).

### Inline Editing

Enable inline cell editing for quick data updates:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* objectstack#33317 — an interface list page whose records open nothing
* (`recordAction: 'none'`) draws its grid's record-link column as plain text.
*
* The page resolves `none` for an authored `recordAction: 'none'` and for a
* list-only source object, and hands the real `ListView` grid
* `rowOpensRecord={false}`. Rendered through the real page, `ListView` and
* `ObjectGrid`; the record-link cell is read off the table. CONTROL: the
* default `drawer` page keeps the link.
*/

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

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

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

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 { InterfaceListPage } from './InterfaceListPage';

const OBJECT_NAME = 'pkg_package';
const VIEW_KEY = 'source';
const ROWS = [{ id: 'p1', name: 'billing-kit', status: 'live' }];

function mountPage(recordAction: 'none' | 'drawer') {
testDataSource = {
find: vi.fn().mockResolvedValue(ROWS),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: OBJECT_NAME,
fields: { name: { type: 'text' }, status: { type: 'text' } },
}),
};
testObjects = [
{
name: OBJECT_NAME,
label: 'Package',
fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } },
listViews: {
[`${OBJECT_NAME}.${VIEW_KEY}`]: {
name: `${OBJECT_NAME}.${VIEW_KEY}`,
label: 'Source',
type: 'grid',
columns: ['name', 'status'],
},
},
},
];
const page = {
name: 'my_packages',
label: 'My Packages',
interfaceConfig: { source: OBJECT_NAME, sourceView: VIEW_KEY, recordAction },
};
return render(
<ActionProvider>
<InterfaceListPage page={page as any} />
</ActionProvider>,
);
}

/** Whether the row's record-link cell wears the link face: an anchor, or a `role="link"` span. */
async function nameCellLooksLikeALink(): Promise<boolean> {
await waitFor(() => expect(screen.getByText('billing-kit')).toBeTruthy(), { timeout: 5000 });
const cell = screen.getByText('billing-kit').closest('td') as HTMLElement;
expect(cell).not.toBeNull();
return cell.querySelector('a[href], [role="link"]') !== null;
}

beforeEach(() => {
cleanup();
// The grid's permission reads go to the network; answer them empty.
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe('objectstack#33317 — an interface list page whose records open nothing', () => {
it('`recordAction: none`: the name cell is plain text', async () => {
mountPage('none');
expect(await nameCellLooksLikeALink()).toBe(false);
expect(screen.queryAllByTestId('primary-field-link')).toHaveLength(0);
});

it('CONTROL — the default drawer: the name cell is the record link', async () => {
mountPage('drawer');
expect(await nameCellLooksLikeALink()).toBe(true);
});
});
3 changes: 3 additions & 0 deletions ui/packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,9 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
onSortChange={onConfigChange ? (sort: any) => onConfigChange({ sort }) : undefined}
onColumnStateChange={onConfigChange ? (st: { order?: string[] }) => { if (st?.order?.length) onConfigChange({ columns: st.order }); } : undefined}
onRowClick={recordAction === 'none' ? undefined : handleRecordClick}
// objectstack#33317 — `none` (authored, or a list-only source)
// opens nothing, so the grid draws no record link and no Open.
rowOpensRecord={recordAction !== 'none'}
/>
</div>
{navOverlay.isOverlay && (
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/app-shell/src/views/ObjectDataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,9 @@ export function ObjectDataPage({ dataSource, objects }: any) {
userFilterSelections={initialUfSelections}
onUserFilterSelectionsChange={handleUserFilterSelectionsChange}
onRowClick={handleRecordClick}
// objectstack#33317 — a list-only object's rows open nothing, so
// the grid draws them no record link and no Open.
rowOpensRecord={recordServed}
/>
)}
</div>
Expand Down
Loading
Loading