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
25 changes: 25 additions & 0 deletions .changeset/27241-record-page-toggles-and-threshold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/components': patch
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

fix(plugin-detail,app-shell,components): the record page's related switch decides whether the column exists, its panel switches are one segmented control in the header's action row, a 1440 px window with the sidebar is three columns, and the related order and body-table placement read the object's `relatedLists` and field groups

Clause-②: no
Metadata-protocol: none

The synthesized default record page (an object with no assigned page). Nothing an author writes changes meaning: the two read points below are keys the spec already declares.

- **The related switch.** With the related column switched off there is no related column and no 「Related」 rail toggle, in every column tier. With it on and no room for it (the two-column tier), the rail toggle opens and closes it. Before, the switch did nothing in the two-column tier and the rail still opened the column.
- **One segmented control in the header row.** The two panel switches are now one control with an icon and a label per segment (Timeline, Related column). It is drawn at the right end of the record header's action row, after `⋯`, behind a vertical hairline, in both column tiers. A segment that is on is filled with the accent tint; one that is off is muted text, so the state reads in the dark theme too. The crumb row keeps only the ways back and is not drawn when there is none. The shortcuts are unchanged (`⌘⇧.` / `⌘⇧,`); they now also match a key event that carries the typed character (`.` / `>`, `,` / `<`) but no physical key code.
- **Thresholds.** Three columns from a 1120 px container, two from 760 px, the tabs below (were 1280 / 1024). Measured on the console: the expanded sidebar is 256 px, so a 1440 px window is 1184 px of record page (three columns), and a 1024 px window is 768 px (two columns). `RECORD_PAGE_THREE_COLUMN_MIN_WIDTH` and `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` carry the new values and their derivation.
- **Related order.** The default order of the related cards, the Related tab and the body tables is the object's `relatedLists` first, then the derived order. That order already reached the page through `deriveRelatedLists`; the stale comment that said it did not is gone, and a mounted pin now holds it.
- **Body-table placement.** A field group whose `relatedList` names a body table's relationship (a child object name, or `object.field`) places that table at the group's position among the field groups. Other body tables stay after the first field section.
- **Restore default order.** Once a reader has moved or hidden a related card, the foot of the related column (and of the Related tab) offers 「Restore default order」 on the same line as 「N hidden · Show」. It lays the default order back, shows every card, and records the current default as the one the arrangement is based on. The reader's stored arrangement keeps its shape. The hint shown when the author's default changed stays as it was.
- **`@object-ui/components`.** `PanelToggles` is the segmented control. `PageHeaderTrailingContext` (new export) is the host slot at the end of `page:header`'s action row, and `PageHeaderActions` takes a `trailing` node for it. No metadata key reaches it.
- **`@object-ui/plugin-detail`.** `relatedListEntryNames` (new export) is the one reading of a related-list entry; app-shell's related-list order and the Studio editors import it from here.
- **`@object-ui/i18n`.** New `detail.relatedRestoreDefaultOrder` in the ten packs.

What to do: nothing.
9 changes: 5 additions & 4 deletions ui/e2e/live/record-history-display.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@ async function seededUsers(request: APIRequestContext): Promise<{ actor: SysUser
* The synthesized record page lays itself out by the width of the view it
* occupies (objectstack#26678): the tab strip this spec opens exists only
* below the two-column floor (`RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` in
* `@object-ui/plugin-detail`). The Desktop Chrome viewport leaves the record
* view right at that floor once the console sidebar takes its share, so the
* spec pins a viewport that keeps the page in its tabbed tier.
* `@object-ui/plugin-detail`). A 1024 px window with the console sidebar
* expanded is already two columns (objectstack#27241), so the spec pins a
* viewport whose record view stays under that floor once the 256 px sidebar
* takes its share — the page's tabbed tier.
*/
test.use({ viewport: { width: 1200, height: 800 } });
test.use({ viewport: { width: 1000, height: 800 } });

test('History tab shows display values (localized dates, option labels, resolved lookups, real actor) instead of raw audit payloads', async ({ page, request }) => {
const { actor, other } = await seededUsers(request);
Expand Down
17 changes: 6 additions & 11 deletions ui/packages/app-shell/src/utils/deriveRelatedLists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
*/

import { convertSortToQueryParams } from '@object-ui/core';
import { relatedListEntryNames } from '@object-ui/plugin-detail';

/** Audit/ownership FKs that exist on nearly every object — never related lists. */
const AUDIT_FK_FIELDS = new Set(['created_by', 'updated_by', 'owner_id']);
Expand Down Expand Up @@ -286,18 +287,12 @@ export function resolveRelatedListOrder<T extends { childObject: string; referen

/**
* Does one `relatedLists` entry (or a field group's `relatedList`) name this
* list? A bare child object name names every list of that child; `object.field`
* names the one FK. The ONE reading of an entry, shared by
* {@link resolveRelatedListOrder} and the Studio editors that author entries.
* list? The ONE reading of an entry lives in `@object-ui/plugin-detail`, where
* the synthesized record page places a body table by a field group's entry
* (objectstack#27069); re-exported here for {@link resolveRelatedListOrder}
* and the Studio editors that author entries.
*/
export function relatedListEntryNames(
entry: string,
list: { childObject: string; referenceField: string },
): boolean {
const dot = entry.indexOf('.');
if (dot === -1) return list.childObject === entry;
return list.childObject === entry.slice(0, dot) && list.referenceField === entry.slice(dot + 1);
}
export { relatedListEntryNames };

/** Normalize an object's `fields` (record or array) into `[name, def]` pairs. */
function fieldEntries(fields: MergedObjectLike['fields']): Array<[string, any]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@
* - an object that is not worked opens with the timeline off — including
* one whose only claim is being another object's `master_detail` parent;
* - `⌘⇧.` / `⌘⇧,` flip the switches, matched by the physical key (Shift
* types `>` / `<` there), and do nothing while a text field has focus;
* the browser's own `⌘⇧T` / `⌘⇧R` are not theirs;
* - the toggles are drawn only in a column tier: not in the tabbed tier, not
* in the drawer / split pane.
* types `>` / `<` there) or, for an event that carries no `code`, by the
* character it typed (objectstack#27241), and do nothing while a text
* field has focus; the browser's own `⌘⇧T` / `⌘⇧R` are not theirs;
* - the toggles are ONE segmented control at the end of the record header's
* action row (objectstack#27241), in both column tiers — not on the crumb
* row, which keeps only the ways back and has no child without one;
* - they are drawn only in a column tier: not in the tabbed tier, not in the
* drawer / split pane;
* - the related switch decides whether the related column exists: OFF in the
* two-column tier leaves no rail toggle (objectstack#27241).
*/

import * as React from 'react';
Expand Down Expand Up @@ -203,7 +209,16 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => {
it('a worked object opens with three columns; timeline off draws details | related with a folded 「动态」 card; on restores', async () => {
const { container } = renderPage();
const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
expect(group.closest('[data-record-crumb-row]')).not.toBeNull();
// objectstack#27241 — the end of the header's action row, after ⋯ and a
// hairline; the crumb row keeps only the ways back (none on a direct open).
expect(group.closest('[data-record-crumb-row]')).toBeNull();
const toolbar = group.closest('[role="toolbar"]')!;
expect(toolbar).not.toBeNull();
expect(toolbar.closest('[data-record-column]')).toBeNull();
const trailing = group.closest('[data-page-header-trailing]')!;
expect(trailing.querySelector('[role="separator"][aria-orientation="vertical"]')).not.toBeNull();
expect(toolbar.lastElementChild).toBe(trailing);
expect(container.querySelector('[data-record-crumb-row]')?.childElementCount ?? 0).toBe(0);
expect(shown(container)).toBe('details timeline related');
expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('true');
expect(toggle(/^Related column$/).getAttribute('aria-pressed')).toBe('true');
Expand Down Expand Up @@ -280,6 +295,35 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => {
await waitFor(() => expect(shown(container)).toBe('details timeline'));
});

it('an event that carries no `code` still flips the switches by the character it typed (objectstack#27241)', async () => {
const { container } = renderPage();
await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
press({ key: '>', code: '' });
await waitFor(() => expect(shown(container)).toBe('details related'));
press({ key: '.', code: '' });
await waitFor(() => expect(shown(container)).toBe('details timeline related'));
press({ key: '<', code: '' });
await waitFor(() => expect(shown(container)).toBe('details timeline'));
press({ key: ',', code: '' });
await waitFor(() => expect(shown(container)).toBe('details timeline related'));
});

it('the two-column tier draws the same control in the header row; related OFF leaves no rail toggle (objectstack#27241)', async () => {
const { container } = renderPage({ width: 900 });
const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
expect(container.querySelector('[data-record-page-tier]')?.getAttribute('data-record-page-tier')).toBe('two-column');
expect(group.closest('[role="toolbar"]')).not.toBeNull();
expect(shown(container)).toBe('details timeline');
// Related ON with no room: the rail toggle, and it opens the column.
fireEvent.click(screen.getByTestId('record-related-rail-toggle'));
await waitFor(() => expect(container.querySelector('[data-record-column="related"]')).not.toBeNull());
// Related OFF: no column, no rail toggle.
fireEvent.click(toggle(/^Related column$/));
await waitFor(() => expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull());
expect(container.querySelector('[data-record-column="related"]')).toBeNull();
expect(shown(container)).toBe('details timeline');
});

it('the tooltip names each shortcut, and the shortcut is announced', async () => {
renderPage();
await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
Expand All @@ -288,7 +332,7 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => {
});

it('not drawn in the tabbed tier, nor in the drawer / split pane; the shortcut does nothing there', async () => {
const tabbed = renderPage({ width: 900 });
const tabbed = renderPage({ width: 700 });
expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy();
expect(screen.queryByRole('group', { name: 'Panels' })).toBeNull();
press('timeline');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
* the related column, and History is resident, so its read goes out at open.
* - 1100 px: two columns and the related toggle; the related lists are not
* mounted until the rail opens.
* - 900 px, and an embedded view at any width: the tab strip as before.
* - 700 px (under the 760 px two-column floor, objectstack#27241), and an
* embedded view at any width: the tab strip as before.
* - `?tab=` addresses the strip, so a column tier ignores it.
*/

Expand Down Expand Up @@ -218,8 +219,8 @@ describe('RecordDetailView — one record page at two widths (objectstack#26678)
expect(column(container, 'related')!.textContent).toContain('Notes');
});

it('900 px: the tab strip, as before; History waits for its tab', async () => {
const { container } = renderPage({ width: 900 });
it('700 px: the tab strip, as before; History waits for its tab', async () => {
const { container } = renderPage({ width: 700 });
expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy();
expect(screen.getByRole('tab', { name: /^Details/ })).toBeTruthy();
expect(screen.getByRole('tab', { name: /^Related/ })).toBeTruthy();
Expand All @@ -234,7 +235,7 @@ describe('RecordDetailView — one record page at two widths (objectstack#26678)
});

it('`?tab=` keeps working in the tabbed tier and is ignored in the column tiers', async () => {
const tabbed = renderPage({ width: 900, search: '?tab=related' });
const tabbed = renderPage({ width: 700, search: '?tab=related' });
const relatedTab = await screen.findByRole('tab', { name: /^Related/ }, { timeout: 5000 });
expect(relatedTab.getAttribute('aria-selected')).toBe('true');
tabbed.unmount();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* 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#27069 / objectstack#27241 — the record page's default related
* order reads the object's declared `relatedLists`: a reader who never
* arranged the related column sees the cards in that order, the rest after
* them in the derived order (owned `master_detail` children first, then
* lookups). Measured from a real `RecordDetailView` mount at three columns
* (the `recordPageTiers-26678` harness: the root's measured width is the one
* input varied).
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';
import { RelatedCountStore } from '@object-ui/components';

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

vi.mock('@object-ui/collaboration', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useRecordPresence: () => [],
PresenceAvatars: () => null,
}));

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(),
}),
}));

// Orthogonal chrome — stubbed so the only asynchrony here is the page's own.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const PARENT = 'showcase_project';
const RECORD_ID = 'p-1';
const BASE_FIELDS = { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } };

const objectsWith = (relatedLists?: string[]) => [
{
name: PARENT,
label: 'Project',
pluralLabel: 'Projects',
managedBy: 'platform',
...(relatedLists ? { relatedLists } : {}),
fields: BASE_FIELDS,
},
{
name: 'showcase_task',
label: 'Task',
pluralLabel: 'Tasks',
managedBy: 'platform',
fields: { ...BASE_FIELDS, project: { type: 'master_detail', reference: PARENT, label: 'Project' } },
},
{
name: 'showcase_note',
label: 'Note',
pluralLabel: 'Notes',
managedBy: 'platform',
fields: { ...BASE_FIELDS, project: { type: 'lookup', reference: PARENT, label: 'Project' } },
},
{
name: 'showcase_call',
label: 'Call',
pluralLabel: 'Calls',
managedBy: 'platform',
fields: { ...BASE_FIELDS, project: { type: 'lookup', reference: PARENT, label: 'Project' } },
},
];

function renderPage(objects: ReturnType<typeof objectsWith>) {
const metadata = {
objects,
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: async () => null,
getItemsByType: () => [],
} as never;
const dataSource = {
find: vi.fn(async () => ({ data: [], total: 0 })),
create: vi.fn(async (_o: string, row: unknown) => row),
findOne: vi.fn(async (_o: string, recordId: string) => ({ id: recordId, name: `Project ${recordId}` })),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
};
return render(
<MemoryRouter initialEntries={[`/app/demo/${PARENT}/${RECORD_ID}`]}>
<MetadataCtx.Provider value={metadata}>
<RecordDetailView
dataSource={dataSource as never}
objects={objects}
onEdit={() => {}}
objectNameOverride={PARENT}
recordIdOverride={RECORD_ID}
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

const cardOrder = (container: HTMLElement) =>
Array.from(container.querySelectorAll('[data-record-column="related"] [data-related-card-id]')).map((el) =>
el.getAttribute('data-related-card-id'),
);

beforeEach(() => {
cleanup();
window.localStorage.clear();
RelatedCountStore._reset();
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }),
),
);
const measure = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (!this.hasAttribute('data-record-page-root')) return measure.call(this);
return { x: 0, y: 0, top: 0, left: 0, right: 1440, bottom: 800, width: 1440, height: 800, toJSON: () => ({}) } as DOMRect;
});
});

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

describe('RecordDetailView — the related cards follow the object\'s relatedLists (objectstack#27069)', () => {
it('without relatedLists: the derived order — the owned child first, then the lookups in declaration order', async () => {
const { container } = renderPage(objectsWith());
await waitFor(() => expect(cardOrder(container)).toHaveLength(3), { timeout: 5000 });
expect(cardOrder(container)).toEqual(['showcase_task.project', 'showcase_note.project', 'showcase_call.project']);
});

it('with relatedLists: the declared lists first in that order, the rest after them in the derived order', async () => {
const { container } = renderPage(objectsWith(['showcase_call', 'showcase_note.project']));
await waitFor(() => expect(cardOrder(container)).toHaveLength(3), { timeout: 5000 });
expect(cardOrder(container)).toEqual(['showcase_call.project', 'showcase_note.project', 'showcase_task.project']);
});
});
Loading
Loading