Skip to content

Commit 69dfac0

Browse files
feat(plugin-grid): a phone list card row is the title, one subtitle line and one status chip (#28532)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao3wPRrD2ji5jSfkJhwo4Pz8)_ Fixes #28484 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:手机(小于 768 px)列表的卡片行改成看板的列表行:名称字段作标题,一行副标题(前两个显示列的值用「 · 」连接,脱敏值和空值跳过),右侧一个状态标签,不再逐列画「标签 … 值」;放大的相关列表页签在手机上也用同一种卡片行。 - 为什么改:原卡片每条线索约 138 px,首屏只放得下 3 条多;看板要求约 60 px 一行、首屏十行。现在实测 62 px 一行。 - 风险与回滚:只影响 768 px 以下的卡片面;卡片上的金额/日期/百分比改为与桌面单元格同一个渲染器(例如金额显示 ¥1,234,567 而不是 ¥1.2M)。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-grid/src/ObjectGrid.tsx`(`buildMobileCardRenderer` 非 compact 分支)、`ui/packages/plugin-detail/src/RelatedList.tsx`(手机 grid/table 分支的放大页签)、`ui/packages/plugin-grid/scripts/build-css.mjs`(换了两个样本类)。 - 两分钟验证:手机宽度(390 px)打开 CRM 的 Leads 列表,每行应是「名字 / 公司 · 来源 / 右侧状态标签」,无「Company」「Lead Source」字样。 - 需要你做的:无,席位落地。 ## What changes - **Phone list card row** (`ObjectGrid`'s card face below 768 px, plain and grouped lists): title = the declared name field (unchanged resolver); one subtitle line joining the first two shown columns that are neither the title nor the status field, each drawn through its own desktop cell, separated by " · ", with a masked or empty value skipped; one status chip at the right, which is the column `detectStatusField` (`@object-ui/types`, the resolver the record path and kanban lanes read) names, drawn through that column's own cell. No per-column label lines. The name-keyword heuristics the card used (amount / stage / date / percent by column name, the stage colour and left-border tables) are removed, so every value reads as the desktop row draws it. - **Enlarged related tab on a phone** (`RelatedList`, the `isMobile` grid/table arm, enlarged face only, per claim amendment 1): hands an `object-grid` node with the rows it already read inline (`provider: 'value'`), its columns and `onRowClick`, instead of the inset `object-gallery`. Resolved through the registry, since `plugin-grid` depends on `plugin-detail`. Compact related cards and every non-enlarged phone related list keep the gallery. - A row tap still hands the row to the host's `onRowClick`; 768 px and up are unchanged. - Root changeset `28484-phone-list-card-row.md` (patch, `@object-ui/plugin-grid`, `@object-ui/plugin-detail`). ## Tests - New `plugin-grid/src/__tests__/phoneCardRow-28484.test.tsx` (390 px): title by the declared name field, subtitle `Dev Admin · Atlas Construction`, one status chip drawn through the select cell (`New Lead`, never the stored `new`) as the card's last child, no label lines, empty and masked subtitle values skipped (raw value nowhere in the DOM), row tap reaches `onRowClick`; control at 768 px draws the table. Ablation: the file run against the `origin/main` `ObjectGrid.tsx` goes 6 failed / 1 passed (the 768 px control), restored to HEAD with `git diff HEAD` empty. - New `plugin-detail/src/__tests__/RelatedList.enlargedPhoneCards-28484.test.tsx`: the enlarged tab at 390 px hands `object-grid` its inline rows and columns, `onRowClick` reaches the host; controls: non-enlarged on a phone keeps `object-gallery`, enlarged at 1280 px keeps `data-table`. - Moved onto the new face (rewritten, none deleted): `ObjectGrid.emptyPlaceholderAffordance-8491` (the control reads the value, no label line), `gridCurrencyFaces.displayLocale-9909` (the subtitle amount in the session locale), `maskedColumnSurfaces-10583` (only the title draws the mask; masked subtitle values are skipped), `mobileCardDateLocale` (the date cell's face), `mobileCardFieldCurrency-10423` (the field currency through the desktop cell), `mobileCardTitleNameField-13150` (subtitle text). - Ledgers the old face carried: `ui/scripts/__tests__/percent-storage-callers-11475.test.ts` drops the `ObjectGrid.tsx` mobile-card `formatPercent` row (the call is gone); `plugin-grid/scripts/build-css.mjs` `MUST_SURVIVE` swaps `border-l-[3px]` and `dark:bg-slate-950/40` (only the old card produced them) for `min-w-[140px]` and `dark:bg-emerald-950/20`, both still produced by this package alone, keeping one arbitrary-value and one dark-variant specimen. ## Acceptance notes - Subtitle values are each column's desktop cell, so a select column draws its badge and a percent column its bar inside the one subtitle line (visible in the shots: lead source as a chip, probability as a bar). The board frame shows plain text; trimming cells to text would be a second display path, so this PR keeps one. - With no status field (`detectStatusField` answers null, including an object whose `stageField` is `false`) or with the status field not a shown column, the card draws no chip. The old card picked a "stage" by column name (`priority`, `category`, …); that guess is gone. - Enlarged related tab: cards now span x 32 to 358 of 390 (body content 24 to 366, minus the card list's own 8 px gutter, the same gutter the plain phone list has at x 8 to 382). Before: x 40 to 350. ## Verification Head `d63d4c94`, `origin/main` base `1f1a369b`. <details><summary>Screenshots, 390 x 844, examples/app-crm (fresh seed)</summary> Saved to the project files folder `ui-seat-1/r2026-10-31b/28484/` (before and after, three each): `01-lead-list`, `02-account-list`, `11-enlarged-related` (Initech, `?related=crm_opportunity.account`). Measured card boxes (x, right, height): | shot | before | after | |---|---|---| | lead list | 8 / 382 / 138 px | 8 / 382 / 62 px | | account list | 8 / 382 / 120 px | 8 / 382 / 62 px | | enlarged related | 40 / 350 / 113 px | 32 / 358 / 56 px | </details> <details><summary>Suites and gates</summary> - `pnpm --dir ui exec vitest run packages/plugin-grid/ packages/plugin-detail/` — VERDICT command-exit 0, Test Files 627 passed / 1 skipped, Tests 5516 passed / 18 skipped (on `c4721de6`; the later commit touches only `ui/scripts/__tests__` and `plugin-grid/scripts/build-css.mjs`). - `pnpm --dir ui exec vitest run scripts/` — VERDICT command-exit 0, Test Files 169 passed / 2 skipped, Tests 4900 passed (on `d63d4c94`). - `pnpm --filter @object-ui/plugin-grid run type-check`, `pnpm --filter @object-ui/plugin-detail run type-check` after a dts build of the closure — VERDICT command-exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 35 derived families, all run, all exit 0 (`check-sdui-manifest.mjs` and its `--self-test` included); `--ran` reconciliation: 35 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm check:new-line-citations` (ui): 0 new citations. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibyXUUW6oMgmoPfVFvW)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibyXUUW6oMgmoPfVFvW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa46d14 commit 69dfac0

13 files changed

Lines changed: 412 additions & 284 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
'@object-ui/plugin-detail': patch
4+
---
5+
6+
Clause-②: no
7+
Metadata-protocol: none
8+
9+
A phone's list card row now reads like the board's list row: the declared name field as the title, one subtitle line joining the first two shown columns' display values with " · " (a masked or empty value is skipped), and one status chip at the right (the object's status field as `detectStatusField` names it, drawn through its own cell), with no per-column label lines. Every value is drawn through the same cell the desktop row uses, so the amount, date and percent faces the card used to draw on its own are gone. The enlarged related tab on a phone draws these same card rows at the body's full width instead of the inset gallery cards; 768 px and wider are unchanged.

‎ui/packages/plugin-detail/src/RelatedList.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2726,6 +2726,22 @@ export const RelatedList: React.FC<RelatedListProps> = ({
27262726
// duplicate renderer. Falls back to the data-table on desktop and
27272727
// when explicit `type='list'` is requested (legacy path).
27282728
if (isMobile && (type === 'grid' || type === 'table')) {
2729+
// objectstack#28484 — the ENLARGED tab's phone cards are the phone
2730+
// list's card rows (`object-grid`'s card face: the name, one subtitle
2731+
// line, one status chip) at the body's full width, over the rows this
2732+
// list already read. Resolved through the registry: `plugin-grid`
2733+
// depends on this package, so it cannot be imported here.
2734+
if (enlargedFace) {
2735+
return {
2736+
type: 'object-grid',
2737+
objectName: api,
2738+
data: { provider: 'value', items: paginatedData },
2739+
columns: effectiveColumns
2740+
.map((c: any) => c.accessorKey || columnIdentity(c))
2741+
.filter(Boolean),
2742+
onRowClick: handleRowClick,
2743+
};
2744+
}
27292745
const titleField = effectiveColumns[0]?.accessorKey || columnIdentity(effectiveColumns[0]);
27302746
const visibleFields = effectiveColumns
27312747
.slice(1, 4)
@@ -2801,7 +2817,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
28012817
default:
28022818
return { type: 'div', children: 'No view configured' };
28032819
}
2804-
}, [type, paginatedData, tableColumns, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, handleRowClick, isMobile, compact, api, objectSchema, activeSort, handleTableSort]);
2820+
}, [type, paginatedData, tableColumns, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, handleRowClick, isMobile, compact, enlargedFace, api, objectSchema, activeSort, handleTableSort]);
28052821

28062822
const headerClassName = isCollapsible ? 'cursor-pointer select-none' : undefined;
28072823
const handleHeaderClick = isCollapsible ? () => setCollapsed((c) => !c) : undefined;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#28484 — on a phone, the ENLARGED related tab hands its rows to
11+
* `object-grid`, whose card face is the phone list's card row (the name, one
12+
* subtitle line, one status chip) at the body's full width, rather than to the
13+
* inset `object-gallery`. The rows are the ones this list read, inline; a tap
14+
* still reaches the host's `onRowClick` with the row.
15+
*
16+
* Controls: a related list that is NOT enlarged keeps the gallery on a phone,
17+
* and the enlarged tab at a desktop width keeps the table.
18+
*/
19+
20+
import { describe, it, expect, vi, afterEach } from 'vitest';
21+
import { cleanup, render, waitFor } from '@testing-library/react';
22+
import * as React from 'react';
23+
import { RelatedList } from '../RelatedList';
24+
import { RelatedListEnlargedContext } from '../relatedListCompact';
25+
26+
/** Capture the schema `RelatedList` hands to `SchemaRenderer`. */
27+
const h = vi.hoisted(() => ({ schema: null as any }));
28+
vi.mock('@object-ui/react', async (importOriginal) => {
29+
const actual = await importOriginal<Record<string, unknown>>();
30+
return {
31+
...actual,
32+
SchemaRenderer: (props: any) => {
33+
h.schema = props.schema;
34+
return null;
35+
},
36+
};
37+
});
38+
39+
const ORIGINAL_INNER_WIDTH = window.innerWidth;
40+
function setWidth(px: number) {
41+
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: px });
42+
}
43+
44+
afterEach(() => {
45+
cleanup();
46+
h.schema = null;
47+
setWidth(ORIGINAL_INNER_WIDTH);
48+
});
49+
50+
const ROWS = [
51+
{ id: 'c1', name: 'Ada Lovelace', title: 'Director of Sales', status: 'active', lead: 'L-1' },
52+
{ id: 'c2', name: 'Alan Turing', title: 'CTO', status: 'inactive', lead: 'L-1' },
53+
];
54+
55+
function renderList({ enlarged, onRowClick }: { enlarged: boolean; onRowClick?: (...args: unknown[]) => void }) {
56+
const dataSource = {
57+
find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
58+
getObjectSchema: vi.fn(async () => ({
59+
name: 'contact',
60+
fields: {
61+
name: { type: 'text', label: 'Name' },
62+
title: { type: 'text', label: 'Title' },
63+
status: { type: 'select', label: 'Status', options: [{ value: 'active', label: 'Active' }] },
64+
lead: { type: 'lookup', reference: 'lead', label: 'Lead' },
65+
},
66+
})),
67+
};
68+
return render(
69+
<RelatedListEnlargedContext.Provider value={enlarged}>
70+
<RelatedList
71+
title="Contacts"
72+
type="table"
73+
api="contact"
74+
objectName="contact"
75+
referenceField="lead"
76+
parentId="L-1"
77+
pageSize={25}
78+
columns={[{ field: 'name', label: 'Name' }, { field: 'title', label: 'Title' }, { field: 'status', label: 'Status' }]}
79+
onRowClick={onRowClick as never}
80+
dataSource={dataSource as never}
81+
/>
82+
</RelatedListEnlargedContext.Provider>,
83+
);
84+
}
85+
86+
describe('the enlarged related tab on a phone draws the phone list card rows (objectstack#28484)', () => {
87+
it('hands object-grid the rows it read, inline, with its columns', async () => {
88+
setWidth(390);
89+
renderList({ enlarged: true });
90+
await waitFor(() => expect(h.schema?.data?.items).toHaveLength(2));
91+
expect(h.schema.type).toBe('object-grid');
92+
expect(h.schema.objectName).toBe('contact');
93+
expect(h.schema.data.provider).toBe('value');
94+
expect(h.schema.data.items.map((row: any) => row.id)).toEqual(['c1', 'c2']);
95+
expect(h.schema.columns).toEqual(['name', 'title', 'status']);
96+
expect(h.schema.gallery, 'no gallery config rides along').toBeUndefined();
97+
});
98+
99+
it('a tapped card still reaches the host\'s onRowClick with the row', async () => {
100+
setWidth(390);
101+
const onRowClick = vi.fn();
102+
renderList({ enlarged: true, onRowClick });
103+
await waitFor(() => expect(h.schema?.data?.items).toHaveLength(2));
104+
h.schema.onRowClick(ROWS[1]);
105+
expect(onRowClick).toHaveBeenCalledTimes(1);
106+
expect(onRowClick.mock.calls[0][0]).toMatchObject({ id: 'c2' });
107+
});
108+
109+
it('CONTROL: a related list that is not enlarged keeps the gallery on a phone', async () => {
110+
setWidth(390);
111+
renderList({ enlarged: false });
112+
await waitFor(() => expect(h.schema?.type).toBe('object-gallery'));
113+
});
114+
115+
it('CONTROL: the enlarged tab at a desktop width keeps the table', async () => {
116+
setWidth(1280);
117+
renderList({ enlarged: true });
118+
await waitFor(() => expect(h.schema?.type).toBe('data-table'));
119+
});
120+
});

‎ui/packages/plugin-grid/scripts/build-css.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ export const MUST_SURVIVE = [
4646
'divide-border/50',
4747
'focus:ring-destructive/30',
4848
'text-muted-foreground/80',
49-
'border-l-[3px]',
49+
'min-w-[140px]',
5050
'bg-emerald-500/5',
51-
'dark:bg-slate-950/40',
51+
'dark:bg-emerald-950/20',
5252
];
5353

5454
/**

0 commit comments

Comments
 (0)