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
24 changes: 24 additions & 0 deletions .changeset/29074-phone-list-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@object-ui/plugin-list': patch
'@object-ui/plugin-grid': patch
'@object-ui/components': patch
'@object-ui/app-shell': patch
'@object-ui/mobile': patch
---

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

Clause-②: no
Metadata-protocol: none

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.

- **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.
- **Panels.** The Filter, Sort and Display panels keep 16 px from both screen edges. They used to run flush against one edge.
- **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.
- **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.
- **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.
- **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.
- **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.

Nothing a view declares changes, and no public prop, package export or translation key is added.
6 changes: 5 additions & 1 deletion ui/packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4072,7 +4072,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
const parent = pageTrail.length > 0 ? pageTrail[pageTrail.length - 1] : undefined;
const label = parent
? parent.t || shortRecordId(parent.i)
: phonePushed || (originFromState?.pathname && !originIsObjectList)
// A page a list pushed (objectstack#29074) still reads the object's
// plural, as Target 3 says; a page pushed from a record, of this
// object or another, reads that record's label.
: (phonePushed && !(originIsObjectList && !originPathOnly!.startsWith(`${objectListPath}/record/`)))
|| (originFromState?.pathname && !originIsObjectList)
? originFromState?.label ?? plural
: plural;
const face = (
Expand Down
5 changes: 5 additions & 0 deletions ui/packages/app-shell/src/views/objectViewPluginSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { normalizeListViewSchema } from '@object-ui/core';
import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import type { useServedViewItems } from '../hooks/useServedViewItems.js';
import { viewTabLabel } from './viewTabLabel.js';
import { RECORD_PAGE_PUSHED_STATE } from './recordPagePush.js';
import type { buildViewTabs } from './objectViewTabs.js';
import type { ConsoleObjectViewProps } from './ObjectView.js';

Expand Down Expand Up @@ -104,6 +105,10 @@ export function useObjectViewPluginSchema({
pathname: pathname + (search || ''),
label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language),
},
// The page is pushed over this list, so on a phone its 「‹」
// goes back in the history to it (objectstack#29074). The
// record page reads the marker on a phone only.
[RECORD_PAGE_PUSHED_STATE]: true,
};
if (viewId) {
navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#29074 — on a phone a list pushes a record page over itself, and
* marks the push as a related row does (`recordPagePushState`). The record
* page reads that marker on a phone (`isRecordPagePushed`) and its 「‹」 goes
* back in the history, so Back after it pops cleanly instead of leaving a
* second copy of the list on the stack.
*
* The controls: a desktop list's state is unchanged, and a phone's answer to
* a drawer link (`?recordId=`), which replaces the list's entry, is not
* marked, since there is no list below it to go back to.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook, render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter, Routes, Route, useLocation, useNavigate, type Location } 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() }),
}));
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 { useObjectViewRecordNavigation } from './objectViewRecordNavigation';
import { useObjectViewPluginSchema } from './objectViewPluginSchema';
import { isRecordPagePushed, recordPagePushState, RECORD_PAGE_PUSHED_STATE } from './recordPagePush';
import { RecordDetailView } from './RecordDetailView';

const ROWS = [{ id: 'r-1' }, { id: 'r-2' }];
const LIST = { pathname: '/apps/crm/crm_lead', search: '?view=all', state: null, hash: '', key: 'k' } as Location;

function hookInputs(navigate: ReturnType<typeof useNavigate>, phone: boolean, search = 'view=all', appName = 'crm') {
return {
activeView: { id: 'all', name: 'all', label: 'All leads' } as never,
objectDef: { name: 'crm_lead', label: 'Lead', fields: {}, navigation: { mode: 'page' } } as never,
objects: [{ name: 'crm_lead', label: 'Lead' }] as never,
appName,
viewId: undefined,
searchParams: new URLSearchParams(search),
setSearchParams: vi.fn(),
location: { ...LIST, search: `?${search}` } as Location,
navigate,
routerRootHref: '/',
servedViews: { views: [], objects: [] } as never,
viewLabel: (_object: string, _view: string, fallback: string) => fallback,
unlabelledViewText: 'Untitled view',
language: 'en',
recordOpenPreference: 'object' as never,
phone,
};
}

const wrapper = ({ children }: { children: React.ReactNode }) => (
<MemoryRouter initialEntries={['/apps/crm/crm_lead?view=all']}>{children}</MemoryRouter>
);

const lastState = (navigate: ReturnType<typeof vi.fn>) => navigate.mock.calls.at(-1)![1].state;

describe('a list marks the record page it pushes on a phone (objectstack#29074)', () => {
it('a phone row click pushes the page with the list as its origin and the pushed marker', () => {
const navigate = vi.fn();
const { result } = renderHook(() => useObjectViewRecordNavigation(hookInputs(navigate, true)), { wrapper });
act(() => result.current.handleListRowsChange(ROWS));
act(() => result.current.handleRowClick({ id: 'r-2' }));
expect(navigate.mock.calls[0][0]).toBe('record/r-2');
expect(navigate.mock.calls[0][1].replace).toBeFalsy();
const state = lastState(navigate);
expect(state.from).toEqual({ pathname: '/apps/crm/crm_lead?view=all', label: 'All leads', siblings: ['r-1', 'r-2'] });
expect(isRecordPagePushed(state)).toBe(true);
});

it('control: a desktop row click carries no marker', () => {
const navigate = vi.fn();
const { result } = renderHook(() => useObjectViewRecordNavigation(hookInputs(navigate, false)), { wrapper });
act(() => result.current.handleRowClick({ id: 'r-2' }));
expect(lastState(navigate)).not.toHaveProperty(RECORD_PAGE_PUSHED_STATE);
});

it('control: a phone answering a drawer link replaces the list entry and carries no marker', () => {
const navigate = vi.fn();
renderHook(() => useObjectViewRecordNavigation(hookInputs(navigate, true, 'view=all&recordId=r-1')), { wrapper });
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate.mock.calls[0][1].replace).toBe(true);
expect(isRecordPagePushed(lastState(navigate))).toBe(false);
});

it('a plugin view (kanban, calendar, …) marks its row push the same way', () => {
const navigate = vi.fn();
const { result } = renderHook(() =>
useObjectViewPluginSchema({
views: [{ id: 'all', name: 'all', label: 'All leads', type: 'kanban' }] as never,
activeViewId: 'all',
viewDraft: null,
activeView: { id: 'all', name: 'all', label: 'All leads', type: 'kanban' } as never,
objectDef: { name: 'crm_lead', label: 'Lead', fields: {} } as never,
onEdit: vi.fn(),
isAdmin: false,
location: LIST,
navigate,
viewId: undefined,
servedViews: { views: [], objects: [] } as never,
viewLabel: (_object: string, _view: string, fallback: string) => fallback,
unlabelledViewText: 'Untitled view',
language: 'en',
}),
{ wrapper },
);
act(() => (result.current.objectViewSchema as { onNavigate: (id: string, mode: 'view') => void }).onNavigate('r-1', 'view'));
const state = lastState(navigate);
expect(state.from.pathname).toBe('/apps/crm/crm_lead?view=all');
expect(isRecordPagePushed(state)).toBe(true);
});
});

/**
* The history the marker buys, on the real record page at 390 px. Home → the
* lead list → a row → the page's 「‹」 → Back: 「‹」 returns to the list entry
* and Back leaves the list for home; the record is never reopened. The page
* still names its way back by the object's plural (Target 3, objectstack#27058).
*/
const APP = 'demo';
const LEAD = 'crm_lead';
const OBJECTS = [
{
name: LEAD,
label: 'Lead',
pluralLabel: 'Leads',
fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } },
navigation: { mode: 'page' },
},
];

function ListRoute({ phone }: { phone: boolean }) {
const navigate = useNavigate();
const location = useLocation();
const nav = useObjectViewRecordNavigation({ ...hookInputs(navigate, phone, 'view=all', APP), objectDef: OBJECTS[0] as never, location });
return <button type="button" data-testid="row" onClick={() => nav.handleRowClick({ id: 'r-1' })}>row</button>;
}

function Where() {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output data-testid="where">{`${location.pathname}${location.search}`}</output>
<button type="button" data-testid="browser-back" onClick={() => navigate(-1)}>browser back</button>
</>
);
}

function mountHistory(entries: Array<string | { pathname: string; search?: string; state?: unknown }>, phone = true) {
const dataSource = {
find: vi.fn(async () => ({ data: [], total: 0 })),
findOne: vi.fn(async (_object: string, id: string) => ({ id, name: id === 'L-1' ? 'Mira Costa' : 'Theo Park' })),
create: vi.fn(),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
};
const metadata = {
objects: OBJECTS, pages: [], loading: false, error: null,
refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], getItem: async () => null, getItemsByType: () => [],
} as never;
render(
<MemoryRouter initialEntries={entries as never} initialIndex={entries.length - 1}>
<MetadataCtx.Provider value={metadata}>
<Routes>
<Route path="/home" element={<div />} />
<Route path={`/apps/${APP}/${LEAD}`} element={<ListRoute phone={phone} />} />
<Route
path="/apps/:appName/:objectName/record/:recordId"
element={<RecordDetailView dataSource={dataSource as never} objects={OBJECTS as never} onEdit={() => {}} />}
/>
</Routes>
<Where />
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

const where = () => screen.getByTestId('where').textContent;
const backLink = () => document.querySelector('[data-record-page-root] > [data-record-top-bar] [data-record-crumb-back]') as HTMLElement | null;

describe('the record page a phone list pushes (objectstack#29074)', () => {
beforeEach(() => {
cleanup();
Object.defineProperty(window, 'innerWidth', { value: 390, configurable: true, writable: true });
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: 390, bottom: 800, width: 390, height: 800, toJSON: () => ({}) } as DOMRect;
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true, writable: true });
});

it('「‹ Leads」 goes back to the list entry, and Back then leaves the list for home', async () => {
mountHistory(['/home', `/apps/${APP}/${LEAD}?view=all`]);
fireEvent.click(screen.getByTestId('row'));
await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`), { timeout: 5000 });
await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 });
expect(backLink()!.tagName).toBe('BUTTON');
expect(backLink()!.textContent).toBe('Leads');
fireEvent.click(backLink()!);
await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`));
fireEvent.click(screen.getByTestId('browser-back'));
await waitFor(() => expect(where()).toBe('/home'));
});

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 () => {
mountHistory(['/home', `/apps/${APP}/${LEAD}?view=all`], false);
fireEvent.click(screen.getByTestId('row'));
await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`), { timeout: 5000 });
await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 });
expect(backLink()!.tagName).toBe('A');
expect(backLink()!.textContent).toBe('Leads');
fireEvent.click(backLink()!);
await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`));
// The record's entry was replaced by a second copy of the list: Back
// lands on the first copy, and only a second Back reaches home. The
// marked push above reaches home in one.
fireEvent.click(screen.getByTestId('browser-back'));
await waitFor(() => expect(screen.getByTestId('row')).toBeTruthy());
expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`);
fireEvent.click(screen.getByTestId('browser-back'));
await waitFor(() => expect(where()).toBe('/home'));
});

it('a lead pushed from another lead\'s page still reads that record, not the plural', async () => {
const parent = `/apps/${APP}/${LEAD}/record/L-1`;
mountHistory([parent, { pathname: `/apps/${APP}/${LEAD}/record/r-2`, state: recordPagePushState({ pathname: parent, search: '' }, 'Mira Costa') }]);
await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 });
expect(backLink()!.tagName).toBe('BUTTON');
expect(backLink()!.textContent).toBe('Mira Costa');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { ViewNavigationConfig } from '@object-ui/types';
import { RECORD_DRAWER_PARAM } from '../urlParams.js';
import { viewTabLabel } from './viewTabLabel.js';
import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js';
import { RECORD_PAGE_PUSHED_STATE } from './recordPagePush.js';
import {
expandedRecordPath,
recordDrawerReturnState,
Expand Down Expand Up @@ -238,14 +239,20 @@ export function useObjectViewRecordNavigation({
label: listLabel,
...(set ?? {}),
},
// A phone pushes the page over its list, so the page's 「‹」
// goes back in the history to that list entry, as a page a
// related row pushes does (objectstack#29074). Not on a
// replace: the drawer link a phone answers takes the list's
// place, so there is no list entry below to go back to.
...(phone && !replace ? { [RECORD_PAGE_PUSHED_STATE]: true } : {}),
};
if (viewId) {
navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState, replace });
} else {
navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState, replace });
}
},
[navigate, viewId, listLabel]
[navigate, viewId, listLabel, phone]
);
/**
* The list surface's half of the record-link mechanism (objectui#4490):
Expand Down
Loading
Loading