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
11 changes: 11 additions & 0 deletions .changeset/12741-bridge-view-all.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': minor
---

On a record page, a related list's "View all" opens the child object's list (objectui#12741). The destination is the ADR-0055 bare data surface, `/apps/APP/OBJECT/data?filter[FIELD]=PARENT`, which is the route a dashboard drill's "Open in list" already uses. It is not anchored to a saved view, so it shows every record related to this parent.

`RelatedRecordActionsBridge` offers the link only where that route can say "this parent's children". It withholds it for a list with no relationship or no parent value, and for a `multiple: true` relationship field, because the route's URL filter has no membership operator. It also withholds it for a relationship field the viewer cannot read, because the list page drops that condition and would open the whole table.

Clause-② line, the same as the pull request's:
Clause-②: yes
No export of this package changes: `recordListPath` lives beside `useOpenRecordList` and is not exported from the package entry. The Clause-② change is `@object-ui/react`'s new `RelatedRecordHandlers.onViewAll`, which this bridge supplies.
9 changes: 9 additions & 0 deletions .changeset/12741-related-handlers-view-all.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/react': minor
---

`RelatedRecordHandlers` gains an optional `onViewAll` (objectui#12741). A host returns it from `resolve` to open the child object's full list page, scoped to the parent on the relationship it was handed. `record:related_list` calls it from its "View all" link. A host that omits it draws no link, as for every other handler on this interface.

Clause-② line, the same as the pull request's:
Clause-②: yes
One optional member is added to the exported `RelatedRecordHandlers` interface. Nothing else on the package entry changes.
19 changes: 19 additions & 0 deletions .changeset/12741-related-list-view-all.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/plugin-detail': minor
---

`record:related_list` now draws the "View all" link its `showViewAll` key declares (objectui#12741, ENFORCE, the objectui half of objectstack-ai/objectstack#23941).

The contract declares `showViewAll: z.boolean().default(true)`, "Show 'View All' link to see all related records". Nothing read the key, and no host passed `RelatedList` an `onViewAll`, so the default drew nothing. The renderer reads the key now:

- **Absent or `true`**: the link draws when the host offers a destination (the new `RelatedRecordHandlers.onViewAll`) and the list has records. Clicking it opens the related object's records, filtered to this parent on the relationship the list already uses.
- **`false`**: no link.
- **An empty list, or no destination from the host**: no link.

`RelatedList` used to draw its "View all" link only when the records ran past one page. It now draws whenever it is handed `onViewAll` and has records, because the spec states no truncation condition. This renderer is the only caller that passes `onViewAll`.

⚠️ Behaviour change: on a console record page, every related list with records now shows "View all", unless it declares `showViewAll: false`.

Clause-② line, the same as the pull request's:
Clause-②: yes
`@object-ui/react`'s exported `RelatedRecordHandlers` gains one optional member, `onViewAll`. No input, key or accept set of this package changes.
38 changes: 36 additions & 2 deletions packages/app-shell/src/views/RelatedRecordActionsBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
* parent-linked.
* - 删 → `dataSource.delete(child, id)` (RelatedList shows the confirm
* dialog and refreshes afterwards)
* - 查看全部 → the child object's list page scoped to this parent
* (`onViewAll`, the related list's "View all" link,
* objectui#12741)
* - 子对象 action → the child object's `list_item` actions, and its
* `record_related` ones when a parent record is in scope,
* executed against the clicked row through the page's shared
Expand Down Expand Up @@ -81,11 +84,12 @@ import {
type RelatedRecordHandlers,
type RelatedRowActionDef,
} from '@object-ui/react';
import { evalRowPredicate, resolveAffordance, type ActionDef, type RowCrudPredicates } from '@object-ui/core';
import { evalRowPredicate, isMultiValueRelationship, resolveAffordance, type ActionDef, type RowCrudPredicates } from '@object-ui/core';
import { actionRendersAt, type ActionLocation } from '@object-ui/types';
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';

/**
* Notify open related lists for `objectName` to refetch.
Expand Down Expand Up @@ -490,6 +494,36 @@ export function RelatedRecordActionsBridge({
onView: (id) => openRecord(objectName, id),
};

/**
* "View all" (objectui#12741): the child object's list page, scoped to
* this parent on the list's own relationship. The destination is the
* ADR-0055 bare data surface (`recordListPath`, the route
* `useOpenRecordList` drills to). It is not anchored to a saved view,
* so the page shows every related record rather than the related
* records some default view also lets through.
*
* Offered only where that route can say "this parent's children". Its
* `filter[FIELD]=VALUE` arm is EQUALITY, so the handler is withheld:
* - without a relationship or a parent value: an unscoped list is
* every record of the object, not this parent's;
* - on a `multiple: true` relationship: membership has no URL
* spelling, and equality against an array of ids matches nothing
* (the reason `RelatedList`'s raw-URL fetch refuses it, objectui#7299);
* - on a relationship field the viewer cannot read: `ObjectDataPage`
* drops that condition and would open the whole table.
* A withheld handler draws no link: no link is better than a wrong list.
*/
if (
relationshipField &&
parentId != null &&
parentId !== '' &&
!isMultiValueRelationship(childDef.fields, relationshipField) &&
perms.checkField(objectName, relationshipField, 'read')
) {
const viewAllPath = recordListPath(appName, objectName, { [relationshipField]: parentId });
handlers.onViewAll = () => navigate(viewAllPath);
}

if (aff.create) {
// [#4646] Greyed, not gone: `onCreate` is still supplied so the list
// keeps offering "+ New" as a visible-but-inert affordance. The
Expand Down Expand Up @@ -551,7 +585,7 @@ export function RelatedRecordActionsBridge({
// the #4646 create predicates: a parent record that changes (a save, a
// status transition) must re-resolve "+ New" for every related list under
// it, or the toolbar keeps answering for the record's previous state.
[objects, base, dataSource, localizeActionTexts, runRowAction, openChildForm, recordHref, openRecord, perms, parentRecord, parentObjectFields, predicateScope, hasParentRecord],
[objects, base, appName, navigate, dataSource, localizeActionTexts, runRowAction, openChildForm, recordHref, openRecord, perms, parentRecord, parentObjectFields, predicateScope, hasParentRecord],
);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ vi.mock('@object-ui/permissions', async (importOriginal) => {
usePermissions: () => ({
getObjectApiOperations: () => undefined,
can: (_name: string, action: string) => principal[action] ?? true,
// Field-level read, open: the bridge reads it for "View all" (objectui#12741).
checkField: () => true,
}),
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ vi.mock('@object-ui/permissions', async (importOriginal) => {
usePermissions: () => ({
getObjectApiOperations: (name: string) => effectiveOps[name],
can: (_name: string, action: string) => principal[action] ?? true,
// Field-level read, open: the bridge reads it for "View all" (objectui#12741).
checkField: () => true,
}),
};
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12741 — a related list's "View all" opens the child object's list
* page, scoped to this parent on the list's own relationship (ENFORCE, the
* objectui half of objectstack-ai/objectstack#23941).
*
* The spec declares `record:related_list.showViewAll` as
* `z.boolean().default(true)`, "Show 'View All' link to see all related
* records". The destination is the console's ADR-0055 bare data surface,
* `/apps/:app/:object/data?filter[FIELD]=VALUE`, which is the route
* `useOpenRecordList` already drills to. It is not anchored to a saved view,
* so it shows every related record.
*
* Every case mounts what a record page mounts: the real
* `RelatedRecordActionsBridge` under a router, around the `{ type, properties }`
* node through the real `SchemaRenderer`, `@object-ui/plugin-detail`'s
* registration, the real `RelatedList` and the real data table. The oracle for
* the destination is the READ side of the same URL family
* (`parseUrlFilterTriples`, what `ObjectDataPage` parses), so the pin does not
* restate how a filter is spelled.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import '@testing-library/jest-dom';
import { MetadataCtx, RecordContextProvider, SchemaRenderer, type MetadataContextValue } from '@object-ui/react';
import { PermissionProvider } from '@object-ui/permissions';
// Registers `record:related_list` — the block a record page draws a related
// list with. Module scope on purpose (AGENTS.md, test discipline).
import '@object-ui/plugin-detail';

import { RelatedRecordActionsBridge } from '../RelatedRecordActionsBridge';
import { parseUrlFilterTriples } from '../drillUrlFilters';

/** Desktop, pinned (objectui#8399): under 768px a table related list is a card gallery. */
beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

afterEach(() => cleanup());

const APP = 'demo';
const PARENT_ROUTE = `/apps/${APP}/project/record/P-1`;

const childOf = (projectField: Record<string, unknown>) => ({
name: 'task',
label: 'Task',
fields: { name: { type: 'text', label: 'Name' }, project: projectField },
});
const SINGLE = childOf({ type: 'lookup', reference: 'project', label: 'Project' });
const MULTI = childOf({ type: 'lookup', reference: 'project', label: 'Projects', multiple: true });

/** Two rows, under the spec's default `limit` of 5: every row fits on the list. */
const ROWS = [
{ id: 't1', name: 'Write spec', project: 'P-1' },
{ id: 't2', name: 'Review spec', project: 'P-1' },
];

const metadataFor = (child: ReturnType<typeof childOf>): MetadataContextValue => ({
apps: [],
objects: [child],
dashboards: [],
reports: [],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: (async () => child) as unknown as MetadataContextValue['getItem'],
getItemsByType: () => [],
getTypeStatus: () => 'ready' as const,
});

/** The router's own location: where a click took us. */
function RouterLocation() {
const location = useLocation();
return <output data-testid="router-location">{`${location.pathname}${location.search}`}</output>;
}

/** The role-based provider, letting `viewer` read `task` with the named fields denied. */
const denying = (fields: string[]) => (node: React.ReactElement) => (
<PermissionProvider
roles={[]}
userRoles={['viewer']}
permissions={
[
{
object: 'task',
roles: { viewer: { actions: ['read'], fieldPermissions: fields.map((field) => ({ field, read: false })) } },
},
] as never
}
>
{node}
</PermissionProvider>
);

function mount({
child = SINGLE,
properties = {},
wrap = (node: React.ReactElement) => node,
}: {
child?: ReturnType<typeof childOf>;
properties?: Record<string, unknown>;
wrap?: (node: React.ReactElement) => React.ReactElement;
} = {}) {
const dataSource = {
find: vi.fn(async () => ROWS),
getObjectSchema: vi.fn(async () => child),
};
const doc = {
type: 'record:related_list',
properties: { objectName: 'task', relationshipField: 'project', columns: ['name'], ...properties },
};
return render(
<MetadataCtx.Provider value={metadataFor(child)}>
<MemoryRouter initialEntries={[PARENT_ROUTE]}>
{wrap(
<RelatedRecordActionsBridge
appName={APP}
objects={[child]}
dataSource={dataSource}
parentObjectName="project"
parentRecordId="P-1"
parentTitle="Launch"
parentRecord={{ id: 'P-1', name: 'Launch' }}
>
<RecordContextProvider objectName="project" recordId="P-1" dataSource={dataSource as never}>
<SchemaRenderer schema={doc as never} />
</RecordContextProvider>
</RelatedRecordActionsBridge>,
)}
<RouterLocation />
</MemoryRouter>
</MetadataCtx.Provider>,
);
}

const viewAll = () => screen.queryByRole('button', { name: /view all/i });
const location = () => screen.getByTestId('router-location').textContent ?? '';
/** Every row painted: each "no link" read below is taken on a list that drew its records. */
const waitForRows = async () => {
await screen.findByText('Write spec');
await screen.findByText('Review spec');
};

describe('objectui#12741 — "View all" opens this parent’s children on the list route', () => {
it('with `showViewAll` absent (the spec default) the link draws even when every row fits, and opens the scoped list', async () => {
mount();
await waitForRows();
const link = viewAll();
expect(link).toBeInTheDocument();
expect(location()).toBe(PARENT_ROUTE);
fireEvent.click(link!);
const [path, search] = location().split('?');
// The bare data surface — not the object route, which stacks the default
// view's own filter on top and could hide related records.
expect(path).toBe(`/apps/${APP}/task/data`);
// Read back the way `ObjectDataPage` reads it: exactly one condition, the
// list's relationship, equal to this parent.
expect(parseUrlFilterTriples(new URLSearchParams(search))).toEqual([['project', '=', 'P-1']]);
});

it('`showViewAll: false` draws no link', async () => {
mount({ properties: { showViewAll: false } });
await waitForRows();
expect(viewAll()).toBeNull();
});

it('a `multiple: true` relationship draws no link: the list route’s filter has no membership operator', async () => {
mount({ child: MULTI });
await waitForRows();
expect(viewAll()).toBeNull();
});

it('a relationship field the viewer cannot read draws no link: the list route would drop the condition', async () => {
mount({ wrap: denying(['project']) });
await waitForRows();
expect(viewAll()).toBeNull();
});

it('CONTROL: under the same provider, denying a field the relationship does not use, the link still draws', async () => {
mount({ wrap: denying(['name_secret']) });
await waitForRows();
expect(viewAll()).toBeInTheDocument();
});
});
38 changes: 28 additions & 10 deletions packages/app-shell/src/views/useOpenRecordList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ import { resolveFilterPlaceholders } from '@object-ui/core';
import { useFilterScope } from '@object-ui/react';
import { serializeDrillFilterParams } from './drillUrlFilters.js';

/**
* The router path of an object's ADR-0055 bare data surface, scoped by
* `filter` (`/apps/:appName/:object/data?filter[...]`). The one place that
* route shape is spelled: {@link useOpenRecordList} navigates to it, and so
* does a related list's "View all" (`RelatedRecordActionsBridge`,
* objectui#12741).
*
* Pure: `filter` is written as handed. A caller holding AUTHORED values, which
* may carry placeholders, resolves them first, as `useOpenRecordList` does.
*/
export function recordListPath(
appName: string | undefined,
objectName: string,
filter?: Record<string, unknown>,
): string {
// A date-bucket drill carries an ObjectQL range operator object
// (`{ $gte, $lt }`); the shared serializer emits it as `filter[field][gte|lt]`
// (never "[object Object]"). Equality dims stay `filter[field]=value` (objectstack-ai/objectstack#1752).
const qs = serializeDrillFilterParams(filter).toString();
const base = appName ? `/apps/${appName}` : '';
// ADR-0055 bare data surface (`/:object/data`): "the URL is the view" — no
// saved-view filter is baked in, so the scope is exactly these conditions.
// (The object route stacks URL filters ON TOP of the default view's own
// filter, which can silently over-narrow the list.)
return `${base}/${objectName}/data${qs ? `?${qs}` : ''}`;
}

/**
* `useOpenRecordList` — the console's implementation of the drill "escape
* hatch" (`DrillNavigationContext.openRecordList`).
Expand Down Expand Up @@ -76,16 +103,7 @@ export function useOpenRecordList(): (objectName: string, filter?: Record<string
// exists to prevent (objectstack-ai/objectstack#3574): `{today}` would work and
// `{current_user_id}` would silently not.
const runtimeFilter = resolveFilterPlaceholders(filter, filterScope);
// A date-bucket drill carries an ObjectQL range operator object
// (`{ $gte, $lt }`); the shared serializer emits it as `filter[field][gte|lt]`
// (never "[object Object]"). Equality dims stay `filter[field]=value` (objectstack-ai/objectstack#1752).
const qs = serializeDrillFilterParams(runtimeFilter).toString();
const base = appName ? `/apps/${appName}` : '';
// ADR-0055 bare data surface (`/:object/data`): "the URL is the view" — no
// saved-view filter is baked in, so the drill scope is exactly these
// conditions. (The object route stacks URL filters ON TOP of the default
// view's own filter, which can silently over-narrow a drill.)
navigate(`${base}/${objectName}/data${qs ? `?${qs}` : ''}`);
navigate(recordListPath(appName, objectName, runtimeFilter));
},
[navigate, appName, filterScope],
);
Expand Down
9 changes: 7 additions & 2 deletions packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2493,8 +2493,13 @@ export const RelatedList: React.FC<RelatedListProps> = ({
</div>
)}

{/* Footer "View all" link — only when records are truncated (more than displayed) */}
{onViewAll && !isEmpty && showPagination && (
{/* Footer "View all" link — whenever the host hands a destination and
there are records (objectui#12741). The spec's `showViewAll` is
"Show 'View All' link to see all related records", with no
truncation condition: the full list page is worth opening even when
every row fits here (sort, filter, export, a shareable URL). An
empty list has nothing to see in full. */}
{onViewAll && !isEmpty && (
<div className="mt-3 pt-3 border-t flex justify-center">
<button
type="button"
Expand Down
Loading
Loading