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
19 changes: 19 additions & 0 deletions .changeset/27524-record-page-tab-row-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/i18n': patch
---

feat(i18n)!: the record page's tab row in all ten packs; the enlarged face's Back label leaves

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (runtime-interface-only ui/packages/i18n/src/locales/en.ts#TranslationKeys) the key type of the console's own translation catalogue: no Zod schema, no spec declaration and no stored metadata reads it, so the compiler is the channel that reaches every caller -->

The record page's tab row (record page v4) arrives in all ten packs:

- `detail.recordTabOverview`: the row's first tab, 「概览」 · Overview · 概要 · 개요 · Übersicht · Aperçu · Resumen · Visão geral · Обзор · نظرة عامة.
- `detail.recordTabs`: the row's accessible name.
- `detail.recordTabsMore`, with its plural forms: the 「+N ▾」 trigger's accessible name.
- `detail.recordTabsHidden`: the dropdown's heading over the hidden panels' tabs.

**BREAKING** (`@object-ui/i18n`, TypeScript only). The exported `TranslationKeys` (`typeof en`) loses `detail.relatedEnlargedBack`, the ‹ back line of the enlarged face that the active tab replaces. The key leaves every pack. A `t()` call that names it still compiles, because its key is a `string`, and reads the key back. Nothing replaces it: 「概览」 and Esc return.
22 changes: 22 additions & 0 deletions .changeset/27524-record-page-tab-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/app-shell': patch
---

feat(plugin-detail)!: a tab row closes the record header — 概览, 动态, then each related panel with its count, as many as the row's width fits; an open panel face is the active tab

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (runtime-export-retired @object-ui/plugin-detail#ArrangedRelatedTab) the retired operand is a component in a published ui package's entry that only the console's own record view imported, for the tabbed tier the record page no longer has; no packages/spec schema, no registered node type, no ADR-0087 conversion surface and no stored shape names it, so there is nothing for `objectstack migrate meta` to rewrite -->

The synthesized record page (record page v4) gets a persistent tab row at the bottom edge of its record header, on every face: the full page, the split pane, the overlay drawer, the maximized pane and a phone.

- **The tabs.** 「概览」 (Overview) first, then 「动态」 (Activity), then one tab per related panel in the reader's arrangement (`record.related.arrangement`), each with its count. 「动态」 shows the stream's size and a related tab its list's count. The counts come from the same request set the right column's panels read, so no relationship is counted twice.
- **Width.** The row shows as many tabs as fit its own width, measured on every resize (the window, the sidebar collapsing, a pane dragged wider). The rest fold into 「+N ▾」, which lists them with their counts, and the hidden panels' tabs come at its end. 「概览」 and at least one more tab are always on the row. So is the active tab: when it would fold, it takes the row's last slot instead.
- **The open face is the active tab.** Choosing a tab, a panel's header or 「查看全部 N 条」 draws that panel's whole face below the tab row, at the page's full width: the related list with every column, paging, sort and inline edit, or the 动态 stream with its chips. The top of the page and the record header stay as they are. The URL keeps `?related=<object>.<field>` and `?related=timeline`. 「概览」 or Esc returns. The face no longer draws a ‹ back line or a title of its own, because the tab names it. Moving from one tab to another replaces the history entry, so one Back, Esc or 「概览」 always returns to the page. Inside the pane the face fills the pane body, and maximizing widens it in place.
- A body table that is open in place (its ⤢ in the details) takes a tab of its own while it is open.
- **Reads.** The page now counts every related panel, including the hidden ones and those behind a closed ⊟, because their tabs show the counts.
- A synthesized page whose tab strip cannot be laid out is drawn as written. Its 关联 tab no longer reorders its lists to the reader's arrangement, which the right column and the tab row now carry.

**BREAKING** (`@object-ui/plugin-detail`, TypeScript only). The only importer was the console's own record view. `ArrangedRelatedTab` leaves the package entry. It laid the reader's arrangement over the 关联 tab of the tabbed tier, which the record page no longer has. FROM `ArrangedRelatedTab` TO no wrapper: draw the page with `RecordPageColumns`, whose right column and tab row read the arrangement, or draw an unlaid page as written. A caller still naming it gets a compile error that names it.
4 changes: 4 additions & 0 deletions content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ its own. **动态** is one stream: the discussion, the activity and, with
Its panel shows a one-line composer and the newest entries, and its header opens the whole
stream (`?related=timeline`), filtered by All / Activity / Discussion / Changes. A related
panel's header opens that list's whole table the same way (`?related=<object>.<field>`).
Each of those faces is also a tab: a row of tabs closes the record header, **Overview** first,
then **动态**, then one tab per related panel in the reader's order, each with its count. The
row shows as many tabs as its width fits and folds the rest into a **+N** menu; the open tab
is always on the row, and **Overview** or Esc returns to the page.
Moving a child table into the record's body is therefore a one-word change on the
relationship field, not a page-authoring task, and `relatedList: false` leaves a child off the
page.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
* three columns.
* - ≥ 760 px: details | the right column at 320 px, the details grid at two.
* - < 760 px (the split pane at 720, a phone at 390): ONE column, the details
* then the right column's panels in their order. No tab strip, no rail.
* then the right column's panels in their order. No rail, and no tab
* strip but the record's own tab row under the header (Target 8), the
* same on every tier: 「概览」, 动态, then the related panels.
* - The embedded record (drawer / split pane) follows the same rule.
* - The right column opens with the 动态 panel, so History is read at open in
* every tier. `?tab=` addresses no strip; `?related=timeline` is the 动态
* tab, the full stream.
* every tier. `?tab=` addresses no strip (「概览」 stays the active tab);
* `?related=timeline` is the 动态 tab, the full stream.
*/

import * as React from 'react';
Expand Down Expand Up @@ -218,6 +220,24 @@ const rightPanels = (container: HTMLElement) =>
(el) => (el.hasAttribute('data-record-timeline-panel') ? 'timeline' : el.getAttribute('data-related-card-id')),
);

/**
* The page's only tablist is its tab row (objectstack#27524, Target 8): the
* same tabs on every tier, the active one as `active`.
*/
const expectTabRowOnly = (active = 'overview') => {
const lists = screen.getAllByRole('tablist');
expect(lists).toHaveLength(1);
expect(lists[0].closest('[data-record-tab-row]')).not.toBeNull();
const tabs = Array.from(lists[0].querySelectorAll('[role="tab"]'));
expect(tabs.map((tab) => tab.getAttribute('data-record-tab'))).toEqual([
'overview',
'timeline',
'showcase_task.project',
'showcase_note.project',
]);
expect(lists[0].querySelector('[aria-selected="true"]')?.getAttribute('data-record-tab')).toBe(active);
};

describe('RecordDetailView — one record page, laid out by its width (objectstack#26678, objectstack#27524)', () => {
it('1440 px: details beside the right column at 380 px, the details grid at three columns; 动态 leads; History is read at open', async () => {
const { container } = renderPage({ width: 1440 });
Expand All @@ -226,7 +246,7 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta
expect(shown(container)).toBe('details right');
expect(columnsGrid(container)).toContain('23.75rem');
expect(detailsGrid(container)).toContain('@lg:grid-cols-3');
expect(screen.queryByRole('tablist')).toBeNull();
expectTabRowOnly();
expect(rightPanels(container)).toEqual(['timeline', 'showcase_task.project', 'showcase_note.project']);
// No resident timeline column and no rail: the two columns are all.
expect(column(container, 'timeline')).toBeNull();
Expand All @@ -253,19 +273,19 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta
// The same panels as the wide tier — no rail to open first.
expect(rightPanels(container)).toEqual(['timeline', 'showcase_task.project', 'showcase_note.project']);
expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull();
expect(screen.queryByRole('tablist')).toBeNull();
expectTabRowOnly();
});

it.each([
['the split pane', 720],
['a phone', 390],
])('%s (%i px): one column — the details, then the 动态 panel and the related panels; no tabs; History is read at open', async (_name, width) => {
])('%s (%i px): one column — the details, then the 动态 panel and the related panels; the tab row; History is read at open', async (_name, width) => {
const { container } = renderPage({ width });
await screen.findAllByText('Tasks', undefined, { timeout: 5000 });
expect(tierOf(container)).toBe('stack');
expect(shown(container)).toBe('details');
expect(columnsGrid(container)).toContain('grid-cols-1');
expect(screen.queryByRole('tablist')).toBeNull();
expectTabRowOnly();
// The panels follow the details, in the right column's order.
const details = column(container, 'details')!;
const right = column(container, 'right')!;
Expand All @@ -284,14 +304,14 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta
const { container } = renderPage({ width: 720, embedded: true });
await screen.findAllByText('Tasks', undefined, { timeout: 5000 });
expect(tierOf(container)).toBe('stack');
expect(screen.queryByRole('tablist')).toBeNull();
expectTabRowOnly();
});

it('`?tab=` addresses no strip on a laid-out page; `?related=timeline` is the 动态 tab: the full stream with its chips', async () => {
const tabbed = renderPage({ width: 720, search: '?tab=related' });
await screen.findAllByText('Tasks', undefined, { timeout: 5000 });
expect(tierOf(tabbed.container)).toBe('stack');
expect(screen.queryByRole('tablist')).toBeNull();
expectTabRowOnly();
expect(column(tabbed.container, 'details')).not.toBeNull();
tabbed.unmount();

Expand All @@ -307,6 +327,7 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta
expect(face.getAttribute('aria-label')).toBe('Activity');
expect(face.querySelector('[data-stream-filters]')).not.toBeNull();
expect(face.querySelector('[data-record-timeline-peek]')).toBeNull();
expectTabRowOnly('timeline');
// The face takes the page: no columns under it.
expect(container.querySelector('[data-record-columns]')).toBeNull();
await waitFor(() => expect(auditReads()).toBeGreaterThan(0));
Expand Down
Loading
Loading