Skip to content

Commit 46e2400

Browse files
fix(ui): the list flow at phone width: 44 px targets, inset panels, a card pager, the active sort on the bar, and Back that pops cleanly (#29273)
domain:ui#1 · zhuangjianguo · session_01QsR2jUJ19MstFvFhjgiYTu <!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbHJ81B5Bkycibopsotb7xnx)_ Fixes #29074 Fixes #29057 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:手机宽度(`sm` 以下)下,列表流程的每个控件都至少 44 px:工具栏的视图切换、筛选、排序、显示、搜索(含搜索框和清除),视图切换菜单,筛选面板,排序构建器,分组编辑器,以及显示面板的行、开关和底部按钮。筛选、排序、显示三个面板左右各留 16 px。排序层级和筛选条件的字段选择框占满整行,标签不再被截成「Accou…」。服务端分页时,手机卡片列表末尾多了一个翻页条(首页、上一页、第 N/M 页、下一页、末页),复用表格已有的文案。下拉刷新只在列表滚到顶部时触发。有排序时,工具栏保留排序按钮并显示层级数。手机上从列表打开的记录页,「‹」改为在历史里后退,之后再按返回会离开列表,不会重新打开记录;「‹」仍显示对象复数名。桌面尺寸和行为不变。 - **为什么改**:移动端 App 直接嵌入 Console。375 / 390 px 实测:工具栏按钮 24–28 px;筛选面板贴右边、显示面板贴左边;403 条记录的列表在手机上只显示前 50 条,没有任何翻页入口;滚到列表中间往下拖会弹出「Pull to refresh」并重新拉数据;排序移进显示面板后,排序生效时工具栏上看不到任何提示(#29057);从列表打开记录后点「‹」再按返回,会重新打开刚才的记录(#29210 的发现)。 - **风险与回滚**:低。大部分是 `max-sm:` 的 `className`,桌面不受影响。三处行为变化:手机列表推入记录页时带上 `RECORD_PAGE_PUSHED_STATE` 标记(替换式跳转不带);`usePullToRefresh` 在任何宽度都只在触点与绑定元素之间的滚动框都在顶部时才开始下拉;触屏设备上视图行的 ☆ 和 📌 一直显示(鼠标仍是悬停显示)。不加键、不加 prop、不加导出、不加文案。回滚即回退本 PR。 - **先看**:`ui/packages/plugin-grid/src/ObjectGrid.tsx` 卡片分支里的 `cardPager`(只在 `manualPaginationOn` 且总数超过一页时绘制,翻页走表格同一个 `manualOnPageChange`);`ui/packages/mobile/src/usePullToRefresh.ts` 的 `scrolledBetween`;`ui/packages/plugin-list/src/ListView.tsx` 排序按钮的隐藏条件(`sortInDisplay && (currentSort.length === 0 || phoneSearchOpen)`);`ui/packages/app-shell/src/views/objectViewRecordNavigation.ts` 的 `phone && !replace` 标记,以及 `RecordDetailView.tsx` 里「‹」标签条件对列表来源保留复数名。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-list/src/__tests__/ListView.phoneWidth-29074.test.tsx packages/plugin-grid/src/__tests__/phoneCardPager-29074.test.tsx packages/mobile/src/__tests__/usePullToRefresh.innerScroller-29074.test.tsx packages/components/src/__tests__/list-builders-phone-width-29074.test.tsx packages/app-shell/src/views/objectViewTitleSwitcher.phoneWidth-29074.test.tsx packages/app-shell/src/views/objectViewRecordNavigation.phonePush-29074.test.tsx`,预期 6 个文件全部通过。 - **需要你做的**:无,席位落地。 Part of #29040. Claim: issuecomment-6572134252, surface amended in issuecomment-6572419569, issuecomment-6573926280 and issuecomment-6573994363. ## What changes The mobile app hosts the Console in a WebView, so below `sm` the Console is the phone UI. This PR covers the Lists flow: the list toolbar, the view switcher, Filter, Sort, Display, phone search, the card list and its end, and pull-to-refresh. Every change applies below `sm` (`max-sm:` utilities), so wider screens are unchanged, except where a bullet says otherwise. - **`ListView`** (plugin-list): the view switcher, Filter, Sort, Display and phone search triggers are 44 px, as are the open search field and its Clear. The Filter and Sort popovers keep 16 px from both screen edges (`collisionPadding`, phone only). Below `sm` the Sort builder lives in the Display panel, so an active sort used to show nowhere on the bar (#29057). While a sort is active, the bar now keeps the Sort trigger with its level count. It steps aside while the phone search field is open, so the field keeps its room. - **`ViewSettingsPopover`** (plugin-list): section headers, switch rows (the whole row is the label), the color select, hide-fields rows, Density, Reset, Refresh and Export are 44 px. The panel keeps 16 px from both edges. It exports `PHONE_TOUCH_TARGET`, `PHONE_POPOVER_GUTTER` and `useBelowSm` to `ListView` inside the package; the package's public entry does not re-export them. The record-open section is untouched, since #29108 rewrote it. - **`objectViewTitleSwitcher`** (app-shell): trigger, search, rows, ☆, 📌, New view and Manage views are 44 px. On a touch screen (`pointer-coarse`) a row's ☆ and 📌 always show, because there is no hover to reveal them. This holds at every width on a touch screen; a mouse still reveals them on hover. - **`SortBuilder`, `FilterBuilder`, `GroupingEditor`** (components): every control is 44 px. A sort level is laid out on three lines (its words; the field beside Remove; the direction), and a filter condition gives its field the whole row, so a label such as "Last Modified At" or "Account Name" shows whole. The filter value field uses 16 px text, so iOS Safari does not zoom in on focus. - **`ObjectGrid`** (plugin-grid): under server pagination the phone's card list held one page (50 of 403) with no way to the rest except search or filter. The card list now ends in a pager inside its scroller: First, Previous, "Page N of M", Next, Last, each 44 px, with the table pager's existing `table.*` strings. It turns the same page the table's pager turns, and returns the scroller to the top. - **Back from a record a list opened** (app-shell, from #29210's phone run): on a phone a list row pushes the record's page with the list as its `from`, but without the pushed marker a related row's push carries. The page's 「‹」 therefore navigated forward to a fresh copy of the list, and Back after it opened the record again. #29210 (landed first) makes that link replace the record's entry, so Back no longer reopens the record but lands on a second copy of the list. `objectViewRecordNavigation` now adds `RECORD_PAGE_PUSHED_STATE` on a phone push (not on the replace a phone answers a drawer link with, which leaves no list below), and `objectViewPluginSchema` does so for plugin views. The record page then goes back in the history, so one Back after 「‹」 leaves the list. `RecordDetailView`'s 「‹」 label condition keeps the object's plural for a page whose origin is the object's own list route (Target 3 of #27058); a page pushed from a record, of this object or another, still names that record. Desktop state is unchanged. - **`usePullToRefresh`** (mobile): the list binds the gesture to its root, which clips its body and never scrolls, so the hook read `scrollTop` 0 even half-way down the list. A pull now starts only when every scroll box between the touched node and the bound element is at its top. This applies at every width, wherever the hook is used. No key, prop, package export, locale string or spec surface is added. The 22 px link of an embedded grid's first column is not touched; it is #29219. ## Why Measured on `main` (a4015ab) at 375 and 390 px on the CRM and showcase examples, every list control was 24–36 px; Filter ran flush right and Display flush left; records past the first page could not be reached on a phone; a downward drag mid-list refreshed; an active sort was invisible on the phone bar (#29057); and on a phone, Back after a list-opened record's 「‹」 opened that record again (#29210's run). ## Tests - `ListView.phoneWidth-29074.test.tsx` (new): toolbar tokens; the Sort trigger is hidden below `sm` when unsorted, shown with its count when sorted, and hidden while phone search is open; the Display panel still carries the sort block; `useBelowSm` follows a `matchMedia` stub. - `phoneCardPager-29074.test.tsx` (new): 12 records at page size 5 and 390 px: the pager reads Page 1 of 3, Next shows records 6–10, Last shows 11–12 and disables Next; every button is `h-11 w-11`. Control: 3 records draw no pager. - `usePullToRefresh.innerScroller-29074.test.tsx` (new): control, a drag on a row with the inner box at its top pulls and refreshes; with the inner box scrolled the same drag shows no indicator and no refresh; a touch outside the scrolled box still pulls. - `list-builders-phone-width-29074.test.tsx` (new): SortBuilder, FilterBuilder and GroupingEditor tokens, desktop tokens kept beside them. - `objectViewTitleSwitcher.phoneWidth-29074.test.tsx` (new): trigger and menu tokens; ☆ and 📌 show on `pointer-coarse` and stay hover-only otherwise. - `objectViewRecordNavigation.phonePush-29074.test.tsx` (new): a phone row click carries the marker; a desktop row click and a phone's drawer-link replace do not; a plugin view's push does. On the real `RecordDetailView` at 390 px, home → list → row → 「‹ Leads」 (a button) returns to the list, and Back then lands on home. Control: an unmarked push makes 「‹」 a link, which since #29210 replaces the record's entry with a second copy of the list, so Back stays on the list and only a second Back reaches home. A lead pushed from another lead's page still reads 「‹ Mira Costa」. Ablating each of the three source edits on its own fails its cases (the list hook: 2; the plugin hook: 1; the label condition: 1), and the rest pass. - Ablation (one time, not kept): with `origin/main`'s eight source files copied in (`ListView.tsx`, `ViewSettingsPopover.tsx`, `objectViewTitleSwitcher.tsx`, `sort-builder.tsx`, `filter-builder.tsx`, `grouping-editor.tsx`, `usePullToRefresh.ts`, `ObjectGrid.tsx`), the first five new files went 15 failed and 5 passed. The five that passed are the controls (unsorted list hides Sort, Display keeps the sort block, a pull at the top, a touch outside the scrolled box, a one-page list draws no pager). The restore left `git status` clean. ## Acceptance notes The card's run record, measured in a real browser. The Console is a production build of this branch (`pnpm console:build`), served at `/_console` by `objectstack dev --seed-admin --fresh`. Examples: CRM (`crm_account`) and showcase (`showcase_account`), each seeded with 400 extra accounts (403 rows). Chromium with `isMobile` and `hasTouch` at 375×812 and 390×844, DPR 2. The before run is on `main` a4015ab, the after run on this branch at 9f27c26. Header chrome (AppHeader) is #29075's and is left out of the touch census. | Check | 375 CRM | 375 showcase | 390 CRM | 390 showcase | | --- | --- | --- | --- | --- | | No horizontal page scroll (list, menus, panels, search, list end, page 2) | pass / pass | pass / pass | pass / pass | pass / pass | | Touch targets ≥ 44 px (toolbar, switcher menu, Filter, Display, Sort, search, list end) | **fail** / pass | **fail** / pass | **fail** / pass | **fail** / pass | | Dialogs and panels fit the viewport | pass (flush to an edge) / pass (16 px each side) | same | same | same | | Field labels shown whole (Filter, Sort) | **fail** / pass | **fail** / pass | **fail** / pass | **fail** / pass | | Virtual keyboard does not cover the focused field (filter value, phone search) | pass / pass | pass / pass | pass / pass | pass / pass | | Records past page 1 reachable from the card list | **fail** / pass | **fail** / pass | **fail** / pass | **fail** / pass | | Pull-to-refresh only at the top | **fail** / pass | **fail** / pass | **fail** / pass | **fail** / pass | | Active sort shown on the bar (#29057) | **fail** / pass | **fail** / pass | **fail** / pass | **fail** / pass | | Long lists responsive at 4× CPU | pass / pass | pass / pass | pass / pass | pass / pass | | Search, card tap, FAB clears the last card | pass / pass | pass / pass | pass / pass | pass / pass | | Row actions reachable from a phone card | fail / fail | fail / fail | fail / fail | fail / fail | Each cell reads before / after. The Back fix came in after this run (from #29210's findings). It was checked live on the rebuilt production console at 375 and 390 (CRM, home → lead list → first card → 「‹」 → browser Back): 「‹」 is a button reading 「Leads」, it lands on the list, and Back then lands on home rather than the record. - **Toolbar at 375 (CRM), before → after:** view switcher 114×24 → 114×44; Filter 28×28 → 44×44; Display 28×28 → 44×44; Search 28×28 → 44×44; open search field 144×28 → 144×44; Clear 28×28 → 44×44; Sort (with an active sort) not shown → 48×44. - **Panels at 375:** Filter left 32 / right 375 → 16 / 359; Display left 0 → 16 / 359; Sort 16 / 359. - **Long list:** Next on the card pager shows "Page 2 of 9" with the scroller back at the top, on all four runs. - **Pull-to-refresh:** scrolled 600 px down, a downward drag showed "Pull to refresh" and refetched on `main`; on this branch it shows nothing and makes no request. At the top it refetches once, as before. - **4× CPU** (CDP `Emulation.setCPUThrottlingRate`, production build): first card 3.2–4.4 s, 40 scroll frames p95 17–20 ms (max 25) with no long task, search typed to result 352–509 ms. On `main`: 3.2–3.8 s, p95 18–20 ms, 450–680 ms. The CRM and showcase runs of one width shared the box. - **Full inbox page** (`sys_inbox_message?view=mine`, 60 rows, 4× CPU; #29076 recorded a 284–495 ms first-scroll frame there): the first visit in a fresh browser context reaches network idle (about 2 s) before the card list has mounted, and the next frames carry the mount, 279–341 ms with 245–299 ms long tasks. Once the list is there, every scroll frame is 18–36 ms with no long task, scrolled at once or after 1.5 s (11 runs). The long frame is the page's first render, not scrolling; this PR does not change that render. - **Out of scope, measured by #29210's phone run:** opening a record's 151-row related tab takes 176–392 ms at 4× CPU, on `main` and on this branch alike. It is a render cost of the embedded grid (plugin-detail `RelatedList` tab activation, then plugin-grid `ObjectGrid`), not a phone-layout fault, and needs a profile to place. - **Still failing, carried by another card:** a phone card has no row-action control, and a long press opens the record. Fixing it changes the phone card layout of #28484, so seat 1 filed it as its own card. - Before and after screenshots of each step at both widths are in the project folder, as the card asks. The seat links them. ## Verification Readings on head `cc0e7090` (merged with main at `eff2d2a9`, which brought #29210's replacing 「‹」 link and #29252's Display footer; the footer conflict kept main's layout plus this PR's phone sizes). At `cc0e7090`: the five packages' type-check 69 of 69; `plugin-list` and `mobile` whole plus every new file, the census, the components dialog and builder files and the record-page Back suites, 245 files, 2402 of 2403 tests passed, the one failure being the Back file's stale control, since corrected; the corrected Back file, shuffled with its neighbours under a concurrent load, 20 of 20 runs green, and ablating the list hook's marker still fails its 2 cases. Earlier: The package suites ran at `6641c3be` (main with #29108 merged), before the Back commit; that commit's app-shell files were then checked with their own suites, and the derived sweep re-ran at `ea6d9657`. Merging main again (`5692ba8d`, #29189 edits `ListView`'s imports; a one-line import resolution) was followed by the five packages' type-check (69 of 69) and a re-run of `plugin-list` and `mobile` whole plus every new file and the record-page Back suites: 204 files, 1931 tests passed. <details><summary>Tests</summary> - `plugin-list`, `plugin-grid` and `mobile` whole, every `components` test file that names the sort, filter or group builder (29 files, the new one among them) and the three `app-shell` files that name the view switcher, under the verify lock with `--maxWorkers=2`: 496 files and 4664 tests passed. This is a narrowing for `components` and `app-shell`; CI's Test UI runs the rest. - The Back commit: the new `objectViewRecordNavigation.phonePush-29074.test.tsx` with `RecordDetailView.phoneFace-27058`, `RecordDetailView.topBar-27524`, `recordPagePush-27058`, `objectViewRecordNavigation.listRows-27524` and `recordPaneGroupScope-28461`: 6 files, 45 tests passed. </details> <details><summary>Type-check and lint</summary> - `turbo run type-check` for `@object-ui/plugin-list`, `plugin-grid`, `components`, `app-shell` and `mobile` (both `tsc` legs, src and tests) on the merged head: 69 of 69 tasks passed; `@object-ui/app-shell` again at `ea6d9657`: exit 0. - eslint (from `ui/`) on the 17 changed source and test files: 0 errors. With `--no-inline-config` it reports one error, `react-hooks/static-components` at `ObjectGrid.tsx` `DataEmptyState`, which `main` already carries with an `eslint-disable-next-line`; no warning is new apart from `react-refresh/only-export-components` on the three `ViewSettingsPopover` exports. - `pnpm check:new-line-citations` under `ui/`: 0 new citations. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --ran`: 35 derived families accounted for, 35 run, 0 NOT MEASURED, every one exit 0, the whole sweep re-run at `ea6d9657`. That includes `node scripts/check-sdui-manifest.mjs` and its `--self-test`, `pnpm --dir ui exec vitest run scripts/` (169 files passed, 2 skipped; 4900 tests passed) and `pnpm check:type-check-debt` under the verify lock. The PR-body gates (`check-changeset-no-major`, `check-closing-keyword-parity`, `check-adr-0087-registration`, `check-empty-changeset`, `check-partof-closing-keyword`) ran on this body. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; lint passes on the changed files. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (#29057 is folded in by the card's claim amendment.) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 section is the maintainer summary the lane asks for). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jUwDX3ZR6eBujakKoT --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 606c32d commit 46e2400

19 files changed

Lines changed: 1053 additions & 58 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
'@object-ui/plugin-grid': patch
4+
'@object-ui/components': patch
5+
'@object-ui/app-shell': patch
6+
'@object-ui/mobile': patch
7+
---
8+
9+
fix(ui): the list flow works at phone width — 44 px touch targets, panels inset from the screen edge, a pager on the card list, pull-to-refresh only at the top, and an active sort shown on the bar
10+
11+
Clause-②: no
12+
Metadata-protocol: none
13+
14+
Measured in a real browser at 375 and 390 px on the CRM and showcase examples. Every change below applies below the `sm` breakpoint only, so wider screens are unchanged, with two exceptions said where they occur.
15+
16+
- **Touch targets.** The list bar's view switcher, Filter, Sort, Display and phone search (its field and Clear too) were 24 to 32 px. They are 44 px now. The same goes for the view switcher's menu (search, rows, star, pin, New view, Manage views), the Filter panel (field, operator, value, Remove, Add filter, Clear all, And/Or), the Sort builder, the Group editor and the Display panel's rows, switches and footer buttons. On a touch screen a view row's star and pin always show, because there is no hover to reveal them. This holds at every width on a touch screen, and a mouse still reveals them on hover.
17+
- **Panels.** The Filter, Sort and Display panels keep 16 px from both screen edges. They used to run flush against one edge.
18+
- **Whole labels.** A sort level and a filter condition give the field select its row's whole width, so a label such as "Last Modified At" is no longer cut to "Creat…" or "Accou…". The filter value field uses 16 px text, so iOS Safari does not zoom in when it is focused.
19+
- **Long lists.** Under server pagination, the phone's card list showed only the first page (50 of 403 records), with no way to reach the rest except search or filter. It now ends in a pager (First, Previous, page N of M, Next, Last) that turns the same page the table's pager does. It uses the table's existing words.
20+
- **Pull-to-refresh.** The list bound the gesture to its root, which never scrolls, so a downward drag halfway down the list (a reader scrolling back up) showed "Pull to refresh" and refetched. `usePullToRefresh` now starts a pull only when every scroll box between the touched node and the bound element is at its top. This applies at every width, wherever the hook is used.
21+
- **Back from a record a list opened.** On a phone a list row pushes the record's page and now marks the push as a related row does, so the page's 「‹」 goes back in the history to the list. Back after it then leaves the list instead of opening the record again. The 「‹」 still reads the object's plural. A phone answering a drawer link replaces the list and stays unmarked; desktop is unchanged.
22+
- **Active sort.** Below `sm` the Sort builder lives in the Display panel, so the bar used to show nothing once a list was sorted. While a sort is active, the bar now keeps the Sort trigger with its level count, which opens the builder. It steps aside while the phone search field is open.
23+
24+
Nothing a view declares changes, and no public prop, package export or translation key is added.

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4072,7 +4072,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
40724072
const parent = pageTrail.length > 0 ? pageTrail[pageTrail.length - 1] : undefined;
40734073
const label = parent
40744074
? parent.t || shortRecordId(parent.i)
4075-
: phonePushed || (originFromState?.pathname && !originIsObjectList)
4075+
// A page a list pushed (objectstack#29074) still reads the object's
4076+
// plural, as Target 3 says; a page pushed from a record, of this
4077+
// object or another, reads that record's label.
4078+
: (phonePushed && !(originIsObjectList && !originPathOnly!.startsWith(`${objectListPath}/record/`)))
4079+
|| (originFromState?.pathname && !originIsObjectList)
40764080
? originFromState?.label ?? plural
40774081
: plural;
40784082
const face = (

‎ui/packages/app-shell/src/views/objectViewPluginSchema.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { normalizeListViewSchema } from '@object-ui/core';
1212
import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
1313
import type { useServedViewItems } from '../hooks/useServedViewItems.js';
1414
import { viewTabLabel } from './viewTabLabel.js';
15+
import { RECORD_PAGE_PUSHED_STATE } from './recordPagePush.js';
1516
import type { buildViewTabs } from './objectViewTabs.js';
1617
import type { ConsoleObjectViewProps } from './ObjectView.js';
1718

@@ -104,6 +105,10 @@ export function useObjectViewPluginSchema({
104105
pathname: pathname + (search || ''),
105106
label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language),
106107
},
108+
// The page is pushed over this list, so on a phone its 「‹」
109+
// goes back in the history to it (objectstack#29074). The
110+
// record page reads the marker on a phone only.
111+
[RECORD_PAGE_PUSHED_STATE]: true,
107112
};
108113
if (viewId) {
109114
navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState });
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29074 — on a phone a list pushes a record page over itself, and
5+
* marks the push as a related row does (`recordPagePushState`). The record
6+
* page reads that marker on a phone (`isRecordPagePushed`) and its 「‹」 goes
7+
* back in the history, so Back after it pops cleanly instead of leaving a
8+
* second copy of the list on the stack.
9+
*
10+
* The controls: a desktop list's state is unchanged, and a phone's answer to
11+
* a drawer link (`?recordId=`), which replaces the list's entry, is not
12+
* marked, since there is no list below it to go back to.
13+
*/
14+
15+
import * as React from 'react';
16+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
17+
import { act, renderHook, render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
18+
import { MemoryRouter, Routes, Route, useLocation, useNavigate, type Location } from 'react-router-dom';
19+
import { MetadataCtx } from '@object-ui/react';
20+
import { RelatedCountStore } from '@object-ui/components';
21+
22+
vi.mock('@object-ui/auth', async (importOriginal) => ({
23+
...(await importOriginal<Record<string, unknown>>()),
24+
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
25+
createAuthenticatedFetch: () => vi.fn(),
26+
}));
27+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
28+
...(await importOriginal<Record<string, unknown>>()),
29+
useRecordPresence: () => [],
30+
PresenceAvatars: () => null,
31+
}));
32+
vi.mock('sonner', () => ({
33+
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }),
34+
}));
35+
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
36+
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
37+
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
38+
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
39+
vi.mock('./MetadataInspector', () => ({
40+
MetadataPanel: () => null,
41+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
42+
}));
43+
44+
import { useObjectViewRecordNavigation } from './objectViewRecordNavigation';
45+
import { useObjectViewPluginSchema } from './objectViewPluginSchema';
46+
import { isRecordPagePushed, recordPagePushState, RECORD_PAGE_PUSHED_STATE } from './recordPagePush';
47+
import { RecordDetailView } from './RecordDetailView';
48+
49+
const ROWS = [{ id: 'r-1' }, { id: 'r-2' }];
50+
const LIST = { pathname: '/apps/crm/crm_lead', search: '?view=all', state: null, hash: '', key: 'k' } as Location;
51+
52+
function hookInputs(navigate: ReturnType<typeof useNavigate>, phone: boolean, search = 'view=all', appName = 'crm') {
53+
return {
54+
activeView: { id: 'all', name: 'all', label: 'All leads' } as never,
55+
objectDef: { name: 'crm_lead', label: 'Lead', fields: {}, navigation: { mode: 'page' } } as never,
56+
objects: [{ name: 'crm_lead', label: 'Lead' }] as never,
57+
appName,
58+
viewId: undefined,
59+
searchParams: new URLSearchParams(search),
60+
setSearchParams: vi.fn(),
61+
location: { ...LIST, search: `?${search}` } as Location,
62+
navigate,
63+
routerRootHref: '/',
64+
servedViews: { views: [], objects: [] } as never,
65+
viewLabel: (_object: string, _view: string, fallback: string) => fallback,
66+
unlabelledViewText: 'Untitled view',
67+
language: 'en',
68+
recordOpenPreference: 'object' as never,
69+
phone,
70+
};
71+
}
72+
73+
const wrapper = ({ children }: { children: React.ReactNode }) => (
74+
<MemoryRouter initialEntries={['/apps/crm/crm_lead?view=all']}>{children}</MemoryRouter>
75+
);
76+
77+
const lastState = (navigate: ReturnType<typeof vi.fn>) => navigate.mock.calls.at(-1)![1].state;
78+
79+
describe('a list marks the record page it pushes on a phone (objectstack#29074)', () => {
80+
it('a phone row click pushes the page with the list as its origin and the pushed marker', () => {
81+
const navigate = vi.fn();
82+
const { result } = renderHook(() => useObjectViewRecordNavigation(hookInputs(navigate, true)), { wrapper });
83+
act(() => result.current.handleListRowsChange(ROWS));
84+
act(() => result.current.handleRowClick({ id: 'r-2' }));
85+
expect(navigate.mock.calls[0][0]).toBe('record/r-2');
86+
expect(navigate.mock.calls[0][1].replace).toBeFalsy();
87+
const state = lastState(navigate);
88+
expect(state.from).toEqual({ pathname: '/apps/crm/crm_lead?view=all', label: 'All leads', siblings: ['r-1', 'r-2'] });
89+
expect(isRecordPagePushed(state)).toBe(true);
90+
});
91+
92+
it('control: a desktop row click carries no marker', () => {
93+
const navigate = vi.fn();
94+
const { result } = renderHook(() => useObjectViewRecordNavigation(hookInputs(navigate, false)), { wrapper });
95+
act(() => result.current.handleRowClick({ id: 'r-2' }));
96+
expect(lastState(navigate)).not.toHaveProperty(RECORD_PAGE_PUSHED_STATE);
97+
});
98+
99+
it('control: a phone answering a drawer link replaces the list entry and carries no marker', () => {
100+
const navigate = vi.fn();
101+
renderHook(() => useObjectViewRecordNavigation(hookInputs(navigate, true, 'view=all&recordId=r-1')), { wrapper });
102+
expect(navigate).toHaveBeenCalledTimes(1);
103+
expect(navigate.mock.calls[0][1].replace).toBe(true);
104+
expect(isRecordPagePushed(lastState(navigate))).toBe(false);
105+
});
106+
107+
it('a plugin view (kanban, calendar, …) marks its row push the same way', () => {
108+
const navigate = vi.fn();
109+
const { result } = renderHook(() =>
110+
useObjectViewPluginSchema({
111+
views: [{ id: 'all', name: 'all', label: 'All leads', type: 'kanban' }] as never,
112+
activeViewId: 'all',
113+
viewDraft: null,
114+
activeView: { id: 'all', name: 'all', label: 'All leads', type: 'kanban' } as never,
115+
objectDef: { name: 'crm_lead', label: 'Lead', fields: {} } as never,
116+
onEdit: vi.fn(),
117+
isAdmin: false,
118+
location: LIST,
119+
navigate,
120+
viewId: undefined,
121+
servedViews: { views: [], objects: [] } as never,
122+
viewLabel: (_object: string, _view: string, fallback: string) => fallback,
123+
unlabelledViewText: 'Untitled view',
124+
language: 'en',
125+
}),
126+
{ wrapper },
127+
);
128+
act(() => (result.current.objectViewSchema as { onNavigate: (id: string, mode: 'view') => void }).onNavigate('r-1', 'view'));
129+
const state = lastState(navigate);
130+
expect(state.from.pathname).toBe('/apps/crm/crm_lead?view=all');
131+
expect(isRecordPagePushed(state)).toBe(true);
132+
});
133+
});
134+
135+
/**
136+
* The history the marker buys, on the real record page at 390 px. Home → the
137+
* lead list → a row → the page's 「‹」 → Back: 「‹」 returns to the list entry
138+
* and Back leaves the list for home; the record is never reopened. The page
139+
* still names its way back by the object's plural (Target 3, objectstack#27058).
140+
*/
141+
const APP = 'demo';
142+
const LEAD = 'crm_lead';
143+
const OBJECTS = [
144+
{
145+
name: LEAD,
146+
label: 'Lead',
147+
pluralLabel: 'Leads',
148+
fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } },
149+
navigation: { mode: 'page' },
150+
},
151+
];
152+
153+
function ListRoute({ phone }: { phone: boolean }) {
154+
const navigate = useNavigate();
155+
const location = useLocation();
156+
const nav = useObjectViewRecordNavigation({ ...hookInputs(navigate, phone, 'view=all', APP), objectDef: OBJECTS[0] as never, location });
157+
return <button type="button" data-testid="row" onClick={() => nav.handleRowClick({ id: 'r-1' })}>row</button>;
158+
}
159+
160+
function Where() {
161+
const location = useLocation();
162+
const navigate = useNavigate();
163+
return (
164+
<>
165+
<output data-testid="where">{`${location.pathname}${location.search}`}</output>
166+
<button type="button" data-testid="browser-back" onClick={() => navigate(-1)}>browser back</button>
167+
</>
168+
);
169+
}
170+
171+
function mountHistory(entries: Array<string | { pathname: string; search?: string; state?: unknown }>, phone = true) {
172+
const dataSource = {
173+
find: vi.fn(async () => ({ data: [], total: 0 })),
174+
findOne: vi.fn(async (_object: string, id: string) => ({ id, name: id === 'L-1' ? 'Mira Costa' : 'Theo Park' })),
175+
create: vi.fn(),
176+
update: vi.fn(async () => ({})),
177+
delete: vi.fn(async () => ({})),
178+
};
179+
const metadata = {
180+
objects: OBJECTS, pages: [], loading: false, error: null,
181+
refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], getItem: async () => null, getItemsByType: () => [],
182+
} as never;
183+
render(
184+
<MemoryRouter initialEntries={entries as never} initialIndex={entries.length - 1}>
185+
<MetadataCtx.Provider value={metadata}>
186+
<Routes>
187+
<Route path="/home" element={<div />} />
188+
<Route path={`/apps/${APP}/${LEAD}`} element={<ListRoute phone={phone} />} />
189+
<Route
190+
path="/apps/:appName/:objectName/record/:recordId"
191+
element={<RecordDetailView dataSource={dataSource as never} objects={OBJECTS as never} onEdit={() => {}} />}
192+
/>
193+
</Routes>
194+
<Where />
195+
</MetadataCtx.Provider>
196+
</MemoryRouter>,
197+
);
198+
}
199+
200+
const where = () => screen.getByTestId('where').textContent;
201+
const backLink = () => document.querySelector('[data-record-page-root] > [data-record-top-bar] [data-record-crumb-back]') as HTMLElement | null;
202+
203+
describe('the record page a phone list pushes (objectstack#29074)', () => {
204+
beforeEach(() => {
205+
cleanup();
206+
Object.defineProperty(window, 'innerWidth', { value: 390, configurable: true, writable: true });
207+
RelatedCountStore._reset();
208+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } })));
209+
const measure = HTMLElement.prototype.getBoundingClientRect;
210+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
211+
if (!this.hasAttribute('data-record-page-root')) return measure.call(this);
212+
return { x: 0, y: 0, top: 0, left: 0, right: 390, bottom: 800, width: 390, height: 800, toJSON: () => ({}) } as DOMRect;
213+
});
214+
});
215+
afterEach(() => {
216+
vi.unstubAllGlobals();
217+
vi.restoreAllMocks();
218+
Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true, writable: true });
219+
});
220+
221+
it('「‹ Leads」 goes back to the list entry, and Back then leaves the list for home', async () => {
222+
mountHistory(['/home', `/apps/${APP}/${LEAD}?view=all`]);
223+
fireEvent.click(screen.getByTestId('row'));
224+
await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`), { timeout: 5000 });
225+
await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 });
226+
expect(backLink()!.tagName).toBe('BUTTON');
227+
expect(backLink()!.textContent).toBe('Leads');
228+
fireEvent.click(backLink()!);
229+
await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`));
230+
fireEvent.click(screen.getByTestId('browser-back'));
231+
await waitFor(() => expect(where()).toBe('/home'));
232+
});
233+
234+
it('control, the push unmarked (a desktop list): 「‹」 is a link that replaces the record (#29210), so the list sits twice and Back stays on it', async () => {
235+
mountHistory(['/home', `/apps/${APP}/${LEAD}?view=all`], false);
236+
fireEvent.click(screen.getByTestId('row'));
237+
await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`), { timeout: 5000 });
238+
await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 });
239+
expect(backLink()!.tagName).toBe('A');
240+
expect(backLink()!.textContent).toBe('Leads');
241+
fireEvent.click(backLink()!);
242+
await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`));
243+
// The record's entry was replaced by a second copy of the list: Back
244+
// lands on the first copy, and only a second Back reaches home. The
245+
// marked push above reaches home in one.
246+
fireEvent.click(screen.getByTestId('browser-back'));
247+
await waitFor(() => expect(screen.getByTestId('row')).toBeTruthy());
248+
expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`);
249+
fireEvent.click(screen.getByTestId('browser-back'));
250+
await waitFor(() => expect(where()).toBe('/home'));
251+
});
252+
253+
it('a lead pushed from another lead\'s page still reads that record, not the plural', async () => {
254+
const parent = `/apps/${APP}/${LEAD}/record/L-1`;
255+
mountHistory([parent, { pathname: `/apps/${APP}/${LEAD}/record/r-2`, state: recordPagePushState({ pathname: parent, search: '' }, 'Mira Costa') }]);
256+
await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 });
257+
expect(backLink()!.tagName).toBe('BUTTON');
258+
expect(backLink()!.textContent).toBe('Mira Costa');
259+
});
260+
});

‎ui/packages/app-shell/src/views/objectViewRecordNavigation.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type { ViewNavigationConfig } from '@object-ui/types';
3131
import { RECORD_DRAWER_PARAM } from '../urlParams.js';
3232
import { viewTabLabel } from './viewTabLabel.js';
3333
import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js';
34+
import { RECORD_PAGE_PUSHED_STATE } from './recordPagePush.js';
3435
import {
3536
expandedRecordPath,
3637
recordDrawerReturnState,
@@ -238,14 +239,20 @@ export function useObjectViewRecordNavigation({
238239
label: listLabel,
239240
...(set ?? {}),
240241
},
242+
// A phone pushes the page over its list, so the page's 「‹」
243+
// goes back in the history to that list entry, as a page a
244+
// related row pushes does (objectstack#29074). Not on a
245+
// replace: the drawer link a phone answers takes the list's
246+
// place, so there is no list entry below to go back to.
247+
...(phone && !replace ? { [RECORD_PAGE_PUSHED_STATE]: true } : {}),
241248
};
242249
if (viewId) {
243250
navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState, replace });
244251
} else {
245252
navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState, replace });
246253
}
247254
},
248-
[navigate, viewId, listLabel]
255+
[navigate, viewId, listLabel, phone]
249256
);
250257
/**
251258
* The list surface's half of the record-link mechanism (objectui#4490):

0 commit comments

Comments
 (0)