Skip to content

Commit 5517005

Browse files
fix(app-shell): a Studio list view's date sort reads Oldest → Newest (#28433)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuRPCAezXKfG3G9NsLcWNpYe)_ Part of #27520 (row 7, the Studio half of the date-sort label; the console half landed in #28423) Clause-②: no Metadata-protocol: none Skills-docs: none ## 维护者速读 - **改了什么**:Studio 列表视图属性面板把每个字段的 `type` 一并传给排序构建器,日期/日期时间字段的排序行显示「最早 → 最新」/「最新 → 最早」,不再是「A → Z」。 - **为什么改**:#28423 让 `SortBuilder` 按字段类型显示时间方向,console 列表已传 `type`;Studio 面板只传了 `{ value, label }`,同一个日期排序在 Studio 里仍写着 A → Z。 - **风险与回滚**:只改一行前端传参,不改保存内容、不加翻译键、不动 spec。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx` 中 `<SortBuilder fields=…>`;新测试 `ObjectListViewInspector.dateSortLabel-27520.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/ObjectListViewInspector.dateSortLabel-27520.test.tsx` → 2 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes On `main` `5da3a8ce`, the Studio list-view panel handed `SortBuilder` its fields as `{ value: f.name, label: f.label }`. `SortBuilder` picks the time wording only from a field's `type`, so a sort on a `date` field read "A → Z" in Studio while the console list read "Oldest → Newest". The panel now passes `type: f.type` (the type `useObjectFields` already returns). What is written to the view's `sort` is unchanged. ## Tests - New: `ObjectListViewInspector.dateSortLabel-27520.test.tsx` mounts the real `useObjectListViewDraft`, the real panel, the real `useObjectFields` over a real `MetadataClient` and the real `SortBuilder`. A `date` row reads "Oldest → Newest", a `datetime` row "Newest → Oldest"; CONTROL: a text row still reads "A → Z". - Red first on `main`'s source: the date case failed with `Received: A → Z`; the control passed. - Ablation via `scripts/ablation-replace.mjs` (`type: f.type` → `type: undefined`): 1 failed / 1 passed, then restored, blob == HEAD and `git diff HEAD` empty. - `vitest run packages/app-shell/src/views/studio-design/`: 281 files, 1993 tests passed. ## Verification Local readings at head `b7745efc` (merge of `origin/main` `4bc077d2`). - `dispatch-gates --commands`: 34 derived, each run with its exit code captured; all 34 exit 0. `--ran`: "34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN". - `node scripts/check-sdui-manifest.mjs`: exit 0. `eslint` on the changed source and the new test: exit 0. - `pnpm --dir ui --filter @object-ui/app-shell run type-check`: exit 0. - No open PR touches `ObjectListViewInspector.tsx` (REST scan of open PRs' files). - Branch name is the harness-assigned `claude/project-thread-t38h8d`, named in the claim (6544990253). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes on the changed files. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [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` touched: n/a. - [x] 7. No hand edits to generated references, changelogs or release notes. - [x] 8. Title, body and comments are in English (the maintainer summary is in Chinese by the seat's standing order). --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgWQeuFyKF7Jtpi9YZs)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgWQeuFyKF7Jtpi9YZs Co-authored-by: Claude <noreply@anthropic.com>
1 parent 630b407 commit 5517005

3 files changed

Lines changed: 174 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a Studio list view's sort on a date field reads Oldest → Newest
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The list-view panel in Studio now hands the sort builder each field's type, as the console list already does. A sort row on a `date` or `datetime` field names its direction "Oldest → Newest" / "Newest → Oldest" instead of "A → Z" / "Z → A". Text and other fields read as before; what is saved is unchanged.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27520 row 7, the Studio half — a list view's sort on a date
5+
* field names its direction as time.
6+
*
7+
* #28423 taught `SortBuilder` to read "Oldest → Newest" for a `date` or
8+
* `datetime` row when its `fields` carry `type`, and the console list passes
9+
* it. Studio's list-view panel handed the builder `{ value, label }` only, so
10+
* the same sort on the same field still read "A → Z" there.
11+
*
12+
* The REAL hook (`useObjectListViewDraft`), the REAL panel, the REAL
13+
* `useObjectFields` over a REAL `MetadataClient` and the REAL `SortBuilder`.
14+
*
15+
* Pinned:
16+
* - a `date` row reads "Oldest → Newest", a `datetime` row "Newest → Oldest";
17+
* - CONTROL: a text row still reads "A → Z".
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import * as React from 'react';
22+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
23+
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
24+
import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';
25+
import { MetadataClient } from '@object-ui/data-objectstack';
26+
27+
const PKG = 'com.acme.app';
28+
const VIEW = 'acme_loan.default';
29+
30+
const OBJECT_DEF = {
31+
name: 'acme_loan',
32+
label: 'Loan',
33+
fields: {
34+
book: { name: 'book', label: 'Book', type: 'text' },
35+
opened_on: { name: 'opened_on', label: 'Opened on', type: 'date' },
36+
approved_at: { name: 'approved_at', label: 'Approved at', type: 'datetime' },
37+
},
38+
};
39+
40+
const gridView = (extra: Record<string, unknown> = {}) => ({
41+
name: VIEW,
42+
object: 'acme_loan',
43+
viewKind: 'list',
44+
isDefault: true,
45+
config: { type: 'grid', data: { provider: 'object', object: 'acme_loan' }, columns: ['book'], ...extra },
46+
});
47+
48+
const server = { active: new Map<string, Record<string, unknown>>() };
49+
50+
const json = (status: number, body: unknown) =>
51+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
52+
53+
async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
54+
const method = (init?.method ?? 'GET').toUpperCase();
55+
const url = new URL(String(input), 'http://studio.test');
56+
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
57+
if (!meta) return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
58+
const seg = meta[1].split('/').map(decodeURIComponent);
59+
const key = `${seg[0]}/${seg[1]}`;
60+
if (seg[0] === '_drafts') return json(200, { drafts: [] });
61+
if (method === 'PUT') return json(200, { success: true, version: 'v2', state: 'draft' });
62+
if (seg[0] === 'object' && seg.length === 2) return json(200, { type: 'object', name: seg[1], item: OBJECT_DEF });
63+
if (seg.length === 3 && seg[2] === 'layers') {
64+
const row = server.active.get(key);
65+
return row
66+
? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row })
67+
: json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
68+
}
69+
// No view draft is pending in this suite.
70+
if (seg.length === 2 && url.searchParams.get('state') === 'draft') {
71+
return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } });
72+
}
73+
const row = server.active.get(key);
74+
return row ? json(200, { type: seg[0], name: seg[1], item: row }) : json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
75+
}
76+
77+
const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
78+
79+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
80+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
81+
return { ...mod, useMetadataClient: () => client };
82+
});
83+
84+
import { ObjectListViewInspector, useObjectListViewDraft } from './ObjectListViewInspector';
85+
import type { Surface } from './navSurface';
86+
87+
const METADATA: MetadataContextValue = {
88+
apps: [],
89+
objects: [OBJECT_DEF],
90+
dashboards: [],
91+
reports: [],
92+
pages: [],
93+
loading: false,
94+
error: null,
95+
refresh: async () => {},
96+
invalidate: () => {},
97+
ensureType: async () => [],
98+
getItem: async () => null,
99+
getItemsByType: () => [],
100+
};
101+
102+
const LEAF: Surface = { type: 'object', name: 'acme_loan', label: 'Loans' };
103+
104+
function Host() {
105+
const listView = useObjectListViewDraft({ client, packageId: PKG, leaf: LEAF, publishNonce: 0 });
106+
return <ObjectListViewInspector listView={listView} readOnly={false} locale="en" />;
107+
}
108+
109+
async function settle() {
110+
await act(async () => {
111+
for (let i = 0; i < 25; i++) await new Promise((r) => setTimeout(r, 0));
112+
});
113+
}
114+
115+
async function openPanel(): Promise<HTMLElement> {
116+
render(
117+
<MetadataCtx.Provider value={METADATA}>
118+
<Host />
119+
</MetadataCtx.Provider>,
120+
);
121+
await settle();
122+
return screen.getByTestId('studio-list-view-inspector');
123+
}
124+
125+
126+
/** The sort popover's direction triggers, one per row, in row order. */
127+
async function directionTriggers(panel: HTMLElement): Promise<HTMLElement[]> {
128+
fireEvent.click(within(panel).getByTestId('list-view-sort-trigger'));
129+
const popover = await screen.findByRole('dialog');
130+
// Each row draws two comboboxes: its field, then its direction.
131+
return within(popover)
132+
.getAllByRole('combobox')
133+
.filter((_, i) => i % 2 === 1);
134+
}
135+
136+
beforeEach(() => {
137+
server.active.clear();
138+
});
139+
afterEach(cleanup);
140+
141+
describe('ObjectListViewInspector — a date sort reads Oldest → Newest (objectstack#27520)', () => {
142+
it('a `date` row reads "Oldest → Newest" and a `datetime` row "Newest → Oldest"', async () => {
143+
server.active.set(
144+
`view/${VIEW}`,
145+
gridView({
146+
sort: [
147+
{ field: 'opened_on', order: 'asc' },
148+
{ field: 'approved_at', order: 'desc' },
149+
],
150+
}),
151+
);
152+
const [date, datetime] = await directionTriggers(await openPanel());
153+
expect(date).toHaveTextContent('Oldest → Newest');
154+
expect(datetime).toHaveTextContent('Newest → Oldest');
155+
});
156+
157+
it('CONTROL: a text row still reads "A → Z"', async () => {
158+
server.active.set(`view/${VIEW}`, gridView({ sort: [{ field: 'book', order: 'asc' }] }));
159+
const [text] = await directionTriggers(await openPanel());
160+
expect(text).toHaveTextContent('A → Z');
161+
});
162+
});

‎ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1487,7 +1487,8 @@ export function ObjectListViewInspector({
14871487
</PopoverTrigger>
14881488
<PopoverContent align="start" className="w-[440px] max-w-[90vw] p-3">
14891489
<SortBuilder
1490-
fields={fields.map((f) => ({ value: f.name, label: f.label }))}
1490+
// objectstack#27520 — the type lets a date row read "Oldest → Newest".
1491+
fields={fields.map((f) => ({ value: f.name, label: f.label, type: f.type }))}
14911492
value={sort.map((s, i) => ({ id: `${i}`, ...s }))}
14921493
onChange={(items: SortItem[]) => {
14931494
// The builder's row `id` is a React key: the spec refuses it on

0 commit comments

Comments
 (0)