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/12703-view-all-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@object-ui/react": minor
"@object-ui/app-shell": minor
"@object-ui/plugin-list": minor
"@object-ui/i18n": minor
---

A `list-view` block drawn as a compact list in a narrow page region now shows a "View all" link under its rows (objectui#12703).

- `@object-ui/react`: `RelatedRecordActionsValue` gains two optional members, `listHref(objectName, viewName?)` and `openList(objectName, viewName?)`. They address an object's list page, or one of its saved views, the way `recordHref` / `openRecord` address a record. A host that omits them is unchanged.
- `@object-ui/app-shell`: a custom page supplies them. The routes are `/apps/APP/OBJECT/view/VIEW` for a block bound to a saved view (`dataSource.view`) and `/apps/APP/OBJECT` otherwise. The block's inline filter, columns and sort are never put in the URL.
- `@object-ui/plugin-list`: the compact list draws the link only when the host answers a destination. It is a real anchor with in-app navigation on a plain click.
- `@object-ui/i18n`: one new key, `list.viewAll`, in all ten packs.
13 changes: 11 additions & 2 deletions content/docs/guide/react-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,17 @@ list.

The posture is measured from the region's rendered width, never authored, and
no page or block key sets it. `kind:'html'` and `kind:'react'` pages have no
regions, so their list blocks always render the full list. A "View all" link from
the compact list to the object list is a planned follow-up.
regions, so their list blocks always render the full list.

Under its rows the compact list draws a "View all" link. A block bound to a
saved view (`dataSource.view`) links to that view's page in the console
(`/apps/APP/OBJECT/view/VIEW`); any other block links to the object's list
(`/apps/APP/OBJECT`). The block's own filter, columns and sort are not carried
into the link, so a block that narrows the list inline opens the object's whole
list. To link to exactly what the block shows, bind the block to a saved view.
The console's page supplies the destination through
`RelatedRecordActionsContext`'s `listHref`; a host that does not supply it draws
no link.

## Related

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* 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#12703, part 2 — a custom page supplies the object-list destination
* its compact list blocks link "View all" to.
*
* `PageView` publishes `pageRecordActionsValue` on `RelatedRecordActionsContext`
* (objectui#11293). It now carries `listHref` / `openList`: the app-scoped
* route of an object's list, `/apps/APP/OBJECT`, or of one of its saved views,
* `/apps/APP/OBJECT/view/VIEW`, the two routes the console mounts for them.
* As for `recordHref` (objectui#12233), the href carries the router's basename
* and the SPA open navigates by the bare route.
*
* The page, the router, the page renderer and the `list-view` block are real:
* `PageView` under a `MemoryRouter` with basename `/_console`, rendering a page
* whose narrow `aside` region holds two list blocks, one bound to a saved view
* and one with an inline filter only. Region widths come from a stand-in
* `ResizeObserver`, as in plugin-list's part-1 pin. The oracle for each href is
* react-router's own `Link` for the same route.
*
* The last block pins the builder's refusals: an object outside the app's
* metadata and a page outside an app route get no destination.
*/

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

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

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

const APP = 'crm';
const OBJECT = 'crm_event';

const FIELDS = {
id: { name: 'id', type: 'text', label: 'Id' },
subject: { name: 'subject', type: 'text', label: 'Subject' },
status: { name: 'status', type: 'text', label: 'Status' },
};

const OBJECTS = [
{
name: OBJECT,
label: 'Event',
fields: FIELDS,
listViews: { upcoming: { name: 'upcoming', label: 'Upcoming', type: 'grid', columns: ['subject', 'status'] } },
},
];

const PAGE = {
name: 'sales_home',
label: 'Sales Home',
type: 'home',
template: 'three-column',
regions: [
{ name: 'main', components: [] },
{
name: 'aside',
width: 'medium',
components: [
{ type: 'list-view', id: 'bound', label: 'Upcoming', dataSource: { object: OBJECT, view: 'upcoming' } },
{
type: 'list-view',
id: 'inline',
label: 'Planned',
properties: {
data: { provider: 'object', object: OBJECT },
columns: ['subject'],
filter: [['status', '=', 'planned']],
},
},
],
},
],
};

vi.mock('../../providers/MetadataProvider', () => ({
useMetadata: () => ({ pages: [PAGE], objects: OBJECTS, getTypeStatus: () => 'ready' }),
}));

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

import { AdapterCtx, SchemaRendererProvider } from '@object-ui/react';
// The real page renderer and `list-view` (module scope, AGENTS.md §测试纪律).
import '@object-ui/components';
import '@object-ui/plugin-list';
import { PageView } from '../PageView';
import { pageRecordActionsValue } from '../pageRecordActions';

const VIEW_ROUTE = `/apps/${APP}/${OBJECT}/view/upcoming`;
const LIST_ROUTE = `/apps/${APP}/${OBJECT}`;

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [{ id: 'e-1', subject: 'Renewal review', status: 'planned' }], total: 1 })),
findOne: vi.fn(async () => null),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(async () => 1),
getObjectSchema: vi.fn(async () => OBJECTS[0]),
getObjects: vi.fn(async () => OBJECTS),
onMutation: () => () => {},
};
}

/** Reports these widths for the page body and each region. */
function stubWidths(widths: { page: number; regions: Record<string, number> }) {
class WidthReportingObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
const region = target.getAttribute('data-region');
const width = region != null
? widths.regions[region]
: target.matches('[data-page-type] > div') ? widths.page : undefined;
if (width === undefined) return;
this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this as never);
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', WidthReportingObserver);
}

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

function mount() {
stubWidths({ page: 1200, regions: { main: 856, aside: 320 } });
const ds = makeDataSource();
return render(
<AdapterCtx.Provider value={ds as never}>
<SchemaRendererProvider dataSource={ds as never}>
<MemoryRouter basename="/_console" initialEntries={[`/_console/apps/${APP}/page/${PAGE.name}`]}>
<Routes>
<Route path="/apps/:appName/page/:pageName" element={<PageView />} />
<Route path="*" element={null} />
</Routes>
<Link to={VIEW_ROUTE} data-testid="oracle-view">view</Link>
<Link to={LIST_ROUTE} data-testid="oracle-list">list</Link>
<RouterLocation />
</MemoryRouter>
</SchemaRendererProvider>
</AdapterCtx.Provider>,
);
}

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

describe('a custom page supplies its compact lists\' "View all" destination (objectui#12703)', () => {
it('a block bound to a saved view links to that view\'s app route under the basename, and a left click routes there', async () => {
const { container } = mount();
const bound = await waitFor(() => {
const el = container.querySelector('[data-region="aside"]');
const links = el ? within(el as HTMLElement).queryAllByTestId('list-compact-view-all') : [];
expect(links).toHaveLength(2);
return links[0];
});

const href = bound.getAttribute('href');
expect(href).toBe(`/_console${VIEW_ROUTE}`);
expect(href).toBe(screen.getByTestId('oracle-view').getAttribute('href'));

fireEvent.click(bound, { button: 0 });
expect(screen.getByTestId('router-location').textContent).toBe(VIEW_ROUTE);
});

it('a block with an inline filter only links to the object\'s list route, with no query', async () => {
const { container } = mount();
const inline = await waitFor(() => {
const links = within(container.querySelector('[data-region="aside"]') as HTMLElement)
.queryAllByTestId('list-compact-view-all');
expect(links).toHaveLength(2);
return links[1];
});
expect(inline.getAttribute('href')).toBe(`/_console${LIST_ROUTE}`);
expect(inline.getAttribute('href')).toBe(screen.getByTestId('oracle-list').getAttribute('href'));
});
});

describe('the page\'s list destination builder refuses what it cannot route (objectui#12703)', () => {
const navigate = vi.fn();

it('an object outside the app\'s metadata has no destination, and opening it does nothing', () => {
const host = pageRecordActionsValue(APP, OBJECTS, navigate, '/');
expect(host.listHref?.('not_an_object')).toBeNull();
host.openList?.('not_an_object', 'upcoming');
expect(navigate).not.toHaveBeenCalled();
});

it('a page outside an app route has no destination', () => {
const host = pageRecordActionsValue(undefined, OBJECTS, navigate, '/');
expect(host.listHref?.(OBJECT)).toBeNull();
expect(host.listHref?.(OBJECT, 'upcoming')).toBeNull();
});

it('a view name is one encoded path segment', () => {
const host = pageRecordActionsValue(APP, OBJECTS, navigate, '/');
expect(host.listHref?.(OBJECT, 'a/b c')).toBe(`/apps/${APP}/${OBJECT}/view/a%2Fb%20c`);
});
});
25 changes: 25 additions & 0 deletions packages/app-shell/src/views/pageRecordActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const NO_RELATED_HANDLERS: RelatedRecordHandlers = Object.freeze({});
* tab or a copied link reaches the record; `navigate` applies that basename
* itself, so it is handed the bare route. The join is `documentHref`, the
* record page's own.
* - **`listHref` / `openList` (objectui#12703)** answer the same way for an
* object's list: the object's list route, or one saved view's route when
* the caller names it, and `null` for an object this console cannot route
* to. They take an object and a view name only, so no caller can hand them
* a filter to encode.
*
* ⚠️ The same seam feeds the grid's link column and the lookup cells, so on a
* page those now render as real anchors to the record they name, as they
Expand All @@ -66,6 +71,18 @@ export function pageRecordActionsValue(
appName && routable.has(objectName) && recordId != null && recordId !== ''
? `/apps/${appName}/${objectName}/record/${encodeURIComponent(String(recordId))}`
: null;
// objectui#12703 — the object's list route, or the route of one of its saved
// views: `/apps/:app/:object` and `/apps/:app/:object/view/:view`, the shapes
// the app navigation writes for an object and a view item. A view name is
// passed as the block binding states it; `ObjectView` resolves the short and
// the qualified spelling alike.
const listPath = (objectName: string, viewName?: string): string | null => {
if (!appName || !routable.has(objectName)) return null;
const base = `/apps/${appName}/${objectName}`;
return typeof viewName === 'string' && viewName !== ''
? `${base}/view/${encodeURIComponent(viewName)}`
: base;
};
return {
resolve: () => NO_RELATED_HANDLERS,
recordHref: (objectName, recordId) => {
Expand All @@ -76,5 +93,13 @@ export function pageRecordActionsValue(
const path = recordPath(objectName, recordId);
if (path) navigate(path);
},
listHref: (objectName, viewName) => {
const path = listPath(objectName, viewName);
return path === null ? null : documentHref(routerRootHref, path);
},
openList: (objectName, viewName) => {
const path = listPath(objectName, viewName);
if (path) navigate(path);
},
};
}
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,8 @@ const ar = {
recordCount_many: "{{count}} سجلًا",
recordCount_other: "{{count}} سجل",
addRecord: "إضافة سجل",
// objectui#12703 — the compact list's footer link to the object's list or the block's view.
viewAll: "عرض الكل",
tabs: "علامات التبويب",
allRecords: "جميع السجلات",
search: "بحث",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,8 @@ const de = {
recordCount_one: "{{count}} Datensatz",
recordCount_other: "{{count}} Datensätze",
addRecord: "Datensatz hinzufügen",
// objectui#12703 — the compact list's footer link to the object's list or the block's view.
viewAll: "Alle anzeigen",
tabs: "Tabs",
allRecords: "Alle Datensätze",
search: "Suche",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,8 @@ const en = {
recordCount_one: '{{count}} record',
recordCount_other: '{{count}} records',
addRecord: 'Add record',
// objectui#12703 — the compact list's footer link to the object's list or the block's view.
viewAll: 'View all',
tabs: 'Tabs',
allRecords: 'All Records',
search: 'Search',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,8 @@ const es = {
recordCount_many: "{{count}} de registros",
recordCount_other: "{{count}} registros",
addRecord: "Agregar registro",
// objectui#12703 — the compact list's footer link to the object's list or the block's view.
viewAll: "Ver todo",
tabs: "Pestañas",
allRecords: "Todos los registros",
search: "Buscar",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,8 @@ const fr = {
recordCount_many: "{{count}} d'enregistrements",
recordCount_other: "{{count}} enregistrements",
addRecord: "Ajouter un enregistrement",
// objectui#12703 — the compact list's footer link to the object's list or the block's view.
viewAll: "Tout afficher",
tabs: "Onglets",
allRecords: "Tous les enregistrements",
search: "Rechercher",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,8 @@ const ja = {
recordCount_one: "{{count}} 件のレコード",
recordCount_other: "{{count}} 件のレコード",
addRecord: "レコードを追加",
// objectui#12703 — the compact list's footer link to the object's list or the block's view.
viewAll: "すべて表示",
tabs: "タブ",
allRecords: "すべてのレコード",
search: "検索",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,8 @@ const ko = {
recordCount_one: "{{count}}개 레코드",
recordCount_other: "{{count}}개 레코드",
addRecord: "레코드 추가",
// objectui#12703 — the compact list's footer link to the object's list or the block's view.
viewAll: "모두 보기",
tabs: "탭",
allRecords: "전체 레코드",
search: "검색",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,8 @@ const pt = {
recordCount_many: "{{count}} de registros",
recordCount_other: "{{count}} registros",
addRecord: "Adicionar registro",
// objectui#12703 — the compact list's footer link to the object's list or the block's view.
viewAll: "Ver tudo",
tabs: "Abas",
allRecords: "Todos os registros",
search: "Pesquisar",
Expand Down
Loading
Loading