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
14 changes: 14 additions & 0 deletions .changeset/26681-display-record-open-preference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-list': patch
'@object-ui/plugin-grid': patch
'@object-ui/i18n': patch
---

feat(plugin-list,plugin-grid,i18n): the Display menu's Open records section — the reader's drawer | split preference, or the object's declared full page — and a host node beside each row's record link (#26681)

Clause-②: no
Metadata-protocol: none

- **`@object-ui/plugin-list`**: `ListView` takes a `recordOpen` host prop and hands it to the Display menu (`ViewSettingsPopover`), which draws an Open records section after Layout. For a drawer-mode object it offers two segments, Drawer | Split, writing the reader's own preference through the host's `setPreference`, with a note that the choice is the reader's and that both expand to the full page. For an object or view that declares `navigation.mode: 'page'` it draws one read-only line instead; for `new_window` and `none`, nothing. A host that passes no `recordOpen` draws no section, so every other host is unchanged. It is a host prop, not a view key: no metadata is added, and the section neither counts toward the Display trigger's changed-settings count nor returns under "Reset to view default".
- **`@object-ui/plugin-list`** and **`@object-ui/plugin-grid`**: `ListView` and `ObjectGrid` take a `recordLinkAdornment` host prop — a node drawn beside each row's record link, after the anchor and never inside it: beside the first column the view links (`link: true`), else beside the auto-linked primary field. `rowAdornment` is unchanged: it sits beside the row's title, which need not be where the record opens from. The console passes an "open as full page" arrow through it for a row that opens the drawer. Absent, the link cell is exactly the anchor it was.
- **`@object-ui/i18n`**: the section's five `list.*` strings (`recordOpen`, `recordOpenDrawer`, `recordOpenSplit`, `recordOpenNote`, `recordOpenDeclaredPage`) and `console.objectView.openAsPage`, the tooltip of the console list's open-as-full-page arrow, in all ten packs.
27 changes: 27 additions & 0 deletions ui/packages/app-shell/src/utils/recordOpenPreference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
DEFAULT_RECORD_OPEN_PREFERENCE,
RECORD_OPEN_PREFERENCE_KEY,
readRecordOpenPreference,
writeRecordOpenPreference,
} from './recordOpenPreference';

const storageWith = (value: string | null) => ({ getItem: (key: string) => (key === RECORD_OPEN_PREFERENCE_KEY ? value : null) });
Expand Down Expand Up @@ -55,3 +56,29 @@ describe('readRecordOpenPreference (objectstack#26677)', () => {
expect(readRecordOpenPreference()).toBe('drawer');
});
});

describe('writeRecordOpenPreference (objectstack#26681)', () => {
/** A storage double that keeps what it is handed. */
const memoryStorage = () => {
const kept = new Map<string, string>();
return {
getItem: (key: string) => kept.get(key) ?? null,
setItem: (key: string, value: string) => void kept.set(key, value),
};
};

it('keeps the choice under the key the read side reads', () => {
const storage = memoryStorage();
writeRecordOpenPreference('split', storage);
expect(storage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('split');
expect(readRecordOpenPreference(storage)).toBe('split');
writeRecordOpenPreference('drawer', storage);
expect(readRecordOpenPreference(storage)).toBe('drawer');
});

it('writes nothing and throws nothing with no storage, or with a storage that refuses the write', () => {
expect(() => writeRecordOpenPreference('split', null)).not.toThrow();
const refusing = { setItem: () => { throw new Error('QuotaExceededError'); } };
expect(() => writeRecordOpenPreference('split', refusing)).not.toThrow();
});
});
91 changes: 87 additions & 4 deletions ui/packages/app-shell/src/utils/recordOpenPreference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* LICENSE file in the root directory of this source tree.
*/

import { useCallback, useSyncExternalStore } from 'react';

/**
* How the console's object page opens a record a reader clicks, when the
* object opens its records in the drawer — a READER's own preference, not
Expand All @@ -19,9 +21,11 @@
* lives here instead: `drawer` draws the record in the drawer over the list,
* `split` draws the same record page beside the list.
*
* This is the READ side. The Display-menu control that writes it is
* objectstack#26681; until it lands nothing in the console writes the key, so
* every reader gets the default.
* Both sides live here: {@link readRecordOpenPreference} and
* {@link writeRecordOpenPreference}, and {@link useRecordOpenPreference}, the
* live pair the object page reads — the Display menu's 「打开记录」 control
* writes through it and the next row click opens the new way, with no reload
* (objectstack#26681).
*
* Read per browser: the value lives in `localStorage` under
* {@link RECORD_OPEN_PREFERENCE_KEY}, across objects. An absent value, an
Expand All @@ -42,7 +46,10 @@ export const DEFAULT_RECORD_OPEN_PREFERENCE: RecordOpenPreference = 'drawer';
/** The storage surface this module reads — `localStorage`, or a test double. */
export type RecordOpenPreferenceStorage = Pick<Storage, 'getItem'>;

function defaultStorage(): RecordOpenPreferenceStorage | null {
/** The storage surface this module writes — `localStorage`, or a test double. */
export type RecordOpenPreferenceWriteStorage = Pick<Storage, 'setItem'>;

function defaultStorage(): Storage | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage;
Expand All @@ -69,3 +76,79 @@ export function readRecordOpenPreference(
return DEFAULT_RECORD_OPEN_PREFERENCE;
}
}

/**
* A choice storage would not keep, held for this page: a browser whose
* storage refuses the write (private mode, a full quota, a blocked origin)
* would otherwise have the reader pick 分屏 and keep reading 抽屉 back.
* `undefined` while storage keeps the choice — storage is then the one answer
* — and cleared by the next kept write and by another tab's write (see
* `subscribe`).
*/
let pageChoice: RecordOpenPreference | undefined;

/** The live {@link useRecordOpenPreference} callers on this page. */
const listeners = new Set<() => void>();

/**
* Keep the reader's choice. `storage` defaults to the browser's
* `localStorage`; `null` says there is none. With no storage, or one that
* refuses the write, nothing is kept and nothing throws: the choice holds on
* this page, through {@link useRecordOpenPreference}, and the next page load
* reads the default.
*
* Every live {@link useRecordOpenPreference} on this page hears the write;
* another tab of the same origin hears it through the browser's `storage`
* event.
*/
export function writeRecordOpenPreference(
next: RecordOpenPreference,
storage: RecordOpenPreferenceWriteStorage | null = defaultStorage(),
): void {
pageChoice = next;
if (storage) {
try {
storage.setItem(RECORD_OPEN_PREFERENCE_KEY, next);
pageChoice = undefined;
} catch {
// Not kept past this page — see above.
}
}
for (const listener of Array.from(listeners)) listener();
}

function subscribe(onChange: () => void): () => void {
listeners.add(onChange);
const onStorage = (event: StorageEvent) => {
// `key === null` is a `clear()` in another tab.
if (event.key === RECORD_OPEN_PREFERENCE_KEY || event.key === null) {
pageChoice = undefined;
onChange();
}
};
if (typeof window !== 'undefined') window.addEventListener('storage', onStorage);
return () => {
listeners.delete(onChange);
if (typeof window !== 'undefined') window.removeEventListener('storage', onStorage);
};
}

function snapshot(): RecordOpenPreference {
return pageChoice ?? readRecordOpenPreference();
}

function serverSnapshot(): RecordOpenPreference {
return DEFAULT_RECORD_OPEN_PREFERENCE;
}

/**
* The reader's record-open preference as live state, and the setter the
* Display menu writes it with (objectstack#26681). A write here, in another
* component of this page, or in another tab re-renders every caller, so the
* object page decides its split face from the value the reader just chose.
*/
export function useRecordOpenPreference(): [RecordOpenPreference, (next: RecordOpenPreference) => void] {
const preference = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
const setPreference = useCallback((next: RecordOpenPreference) => writeRecordOpenPreference(next), []);
return [preference, setPreference];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* 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.
*/

/**
* objectstack#26681 — the console object page's Display menu writes the
* reader's record-open preference, and the name cell's ↗ opens the record as
* the full page.
*
* End to end through `ObjectView`, the host that resolves the mode and keeps
* the preference (`utils/recordOpenPreference.ts`):
*
* - a drawer object: the Open records section draws Drawer | Split; choosing
* Split keeps `split` under `os:record-open-mode`, and the next row click
* draws the record BESIDE the list (the split face: the list's record-count
* footer and the pane's close control) — no reload, no remount;
* - a view that declares `page`: the read-only line, no segments, and no ↗
* (its rows already open the page);
* - the ↗ beside a drawer row's record link is a real anchor to the record route,
* and a plain click on it lands there, leaving the preference as it was.
*
* The record body is not what this file reads, so `RecordDetailView` is a
* null sink.
*/

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

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: () => ({ 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 };
});

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: () => {} }),
}));
// The record body: not what this file reads.
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';
import { RECORD_OPEN_PREFERENCE_KEY } from '../utils/recordOpenPreference';

const OBJECT_NAME = 'duly_task';
const VIEW_ID = `${OBJECT_NAME}.all`;
const ROWS = [{ id: 'r1', name: 'Alpha' }];

const objectsWith = (navigation?: Record<string, unknown>) => [
{
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'],
...(navigation ? { navigation } : {}),
},
},
},
];

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

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
window.localStorage.removeItem(RECORD_OPEN_PREFERENCE_KEY);
window.history.replaceState(null, '', '/');
});

/** Mount the object list, its one row drawn. */
async function openList(objects: ReturnType<typeof objectsWith>) {
const dataSource = {
find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
findOne: vi.fn(async () => null),
create: vi.fn(async () => ({})),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: vi.fn(async (name: string) => objects.find((o) => o.name === name) ?? null),
} as never;
// The list's own record href is built from the DOCUMENT's path
// (`listRecordDetailUrl`), which a `MemoryRouter` does not move.
window.history.replaceState(null, '', `/apps/demo/${OBJECT_NAME}/view/${VIEW_ID}`);
render(
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })}>
<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} objects={objects} onEdit={() => {}} />}
/>
<Route
path="/apps/:appName/:objectName/record/:recordId"
element={<output data-testid="record-page-route">record page</output>}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>
</I18nProvider>,
);
await screen.findByTestId('primary-field-link', undefined, { timeout: 5000 });
}

function openDisplay() {
fireEvent.click(screen.getByTestId('toolbar-display-trigger'));
}

describe('objectstack#26681 — Open records in the Display menu', () => {
it('a drawer object: choosing Split keeps the preference, and the next row click opens the record beside the list', async () => {
await openList(objectsWith());
openDisplay();
const drawerSegment = await screen.findByRole('radio', { name: 'Drawer' });
expect(drawerSegment.getAttribute('aria-checked')).toBe('true');
fireEvent.click(screen.getByRole('radio', { name: 'Split' }));

expect(window.localStorage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('split');
// The section reads the live value back: no reload, no remount.
await waitFor(() =>
expect(screen.getByRole('radio', { name: 'Split' }).getAttribute('aria-checked')).toBe('true'),
);

fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
fireEvent.click(screen.getByTestId('primary-field-link'));
expect(await screen.findByTestId('record-count-footer', undefined, { timeout: 5000 })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Close panel' })).toBeTruthy();
expect(screen.queryByRole('dialog')).toBeNull();
});

it('a drawer object with no choice made: the row opens the drawer', async () => {
await openList(objectsWith());
fireEvent.click(screen.getByTestId('primary-field-link'));
expect(await screen.findByRole('dialog', undefined, { timeout: 5000 })).toBeTruthy();
expect(screen.queryByTestId('record-count-footer')).toBeNull();
});

it('a view that declares `page`: the read-only line, no segments, and no ↗', async () => {
window.localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'split');
await openList(objectsWith({ mode: 'page' }));
expect(screen.queryByTestId('record-open-as-page')).toBeNull();
openDisplay();
expect(await screen.findByTestId('display-record-open-declared')).toBeTruthy();
expect(within(screen.getByTestId('display-record-open')).queryByRole('radio')).toBeNull();
});

it('the ↗ beside a drawer row is an anchor to the record route, and a click lands there without changing the preference', async () => {
await openList(objectsWith());
const arrow = await screen.findByTestId('record-open-as-page');
expect(arrow.tagName).toBe('A');
expect(arrow.getAttribute('href')).toBe(`/apps/demo/${OBJECT_NAME}/record/r1`);
expect(arrow.getAttribute('aria-label')).toBe('Open as full page');
expect(arrow.getAttribute('title')).toBe('Open as full page');
// Beside the row's record link, never inside it.
const pair = arrow.closest('[data-testid="link-cell-trailing"]') as HTMLElement;
expect(pair.querySelector('[data-testid="primary-field-link"]')).not.toBeNull();
expect(arrow.parentElement?.closest('a')).toBeNull();

fireEvent.click(arrow);
expect(await screen.findByTestId('record-page-route')).toBeTruthy();
// Neither the drawer nor the pane opened on the way, and nothing was kept.
expect(screen.queryByRole('dialog')).toBeNull();
expect(window.localStorage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBeNull();
});
});
Loading
Loading