Skip to content

Commit 0e33108

Browse files
fix(app-shell): Studio list-view filter offers a picklist field's options and stores the option value (#27628)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:Studio「Interfaces → 列表视图 → Filter」里,选项(picklist)字段的过滤值从自由文本框改为该字段的选项下拉,存的是选项的 value(`new`),不是标签(`New Request`)。 - 为什么改:作者照着看到的标签输入,视图存了一个谁都匹配不上的值,草稿还显示有效,运行时列表却显示「没有符合此视图过滤条件的记录」。 - 风险与回滚:只给字段目录多带一个可选的 `options` 键,复用运行时列表已在用的同一个 FilterBuilder,没有新编辑器;其它字段类型不变。回滚 = revert 本 PR。 - 先看:`ObjectListViewInspector.picklistFilterOptions-27515.test.tsx`(钉住的行为)、`useObjectFields.ts`(读出 options)、`widgets.tsx` 的 `FilterBuilderField`(把 options 交给 FilterBuilder)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/ObjectListViewInspector.picklistFilterOptions-27515.test.tsx`,预期 3 passed。 - 需要你做的:无,席位落地。 Fixes #27515 Clause-②: no Metadata-protocol: none ## What changes The Studio list-view inspector already mounts the runtime list filter's `FilterBuilder` (through the designer's `filter-builder` widget). That builder draws a picklist column's value as a picker over the column's `options` and stores the option `value`, but only when the column it is handed carries `options`. The Studio field catalog dropped them at three layers, so a picklist column fell through to the plain text input: 1. `useObjectFields` (`ObjectFieldInfo`) now reads the served field's declared `options` in the spec's `SelectOption` shape, `{ value, label }`, and only that shape. 2. `ObjectListViewInspector` passes them on in its `fieldCatalog` (`ObjectFieldOption` gains an optional `options`). 3. `FilterBuilderField` (the `filter-builder` widget) hands them to `FilterBuilder` with the column. No second picklist editor and no new export: the value control is the runtime one, per the triage note. Other field types are unchanged (a number field keeps its input; the control test pins it). Stored shape is unchanged: the spec's `ViewFilterRule[]`, now with the option value. ## Tests `ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.picklistFilterOptions-27515.test.tsx`, through the REAL `useObjectListViewDraft`, the REAL panel and the REAL `useObjectFields` over a REAL `MetadataClient` (only the Radix popover is rendered inline, as `widgets.filterBuilderHeldRow-26738.test.tsx` does): - a stored picklist rule (`status equals new`) shows a picker reading `New Request`, and no free-text value box; - Add filter on the picklist, pick `New Request`: the buffered view's filter is `[{ field: 'status', operator: 'equals', value: 'new' }]`; - CONTROL: a number rule keeps its plain value input. Ablation, one leg per layer, each through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob moved, restored to the HEAD blob with `git diff HEAD` empty). Each leg: tests 1 and 2 red, the control green. <details><summary>Ablation readings</summary> ```text === A1 useObjectFields ablation-replace: ok mutation landed: anchor 1 -> 0, blob 8e588c1 -> e6bd754fbf51 Tests 2 failed | 1 passed (3) ablation-replace: ok restored: blob == HEAD (8e588c1) and `git diff HEAD` is empty A1 exit 1 === A2 inspector catalog ablation-replace: ok mutation landed: anchor 1 -> 0, blob 6e528a8 -> 21aaebe46ff5 Tests 2 failed | 1 passed (3) ablation-replace: ok restored: blob == HEAD (6e528a8) and `git diff HEAD` is empty A2 exit 1 === A3 widget fbFields ablation-replace: ok mutation landed: anchor 1 -> 0, blob be60ad2 -> 3d032f6a741c Tests 2 failed | 1 passed (3) ablation-replace: ok restored: blob == HEAD (be60ad2) and `git diff HEAD` is empty A3 exit 1 ``` </details> ## Acceptance notes - **Collapsed inspector summary.** The filter trigger's summary is built from the rules' field labels only (`summary` in `FilterBuilderField`); it has no place for the operator or the value, so it still reads `Status`. Not changed here; adding operator and option label to it is a new piece of copy for the seat to card if wanted. - **Runtime half (ui seat 2's area, not fixed here).** In `@object-ui/plugin-list`'s `ListView`, the toolbar Filter popover's `FilterBuilder` is drawn from `currentFilters`, the user's session conditions seeded from the `initialFilters` prop; the view's own declared `filter` is applied as the base query and is never drawn among those rows, so an end user can neither see nor clear it there. Read from source, not measured in a browser. - **Sibling host with the same strip.** `ViewVariantInspector` (metadata-admin; also mounted by the runtime `ViewConfigPanel` through `objectFieldsOverride`) builds its `filter-builder` catalog as name, label and type only, so a picklist column there still gets the text box. Same defect class, outside this card's file surface; listed for the seat. - **Option labels are the author's, untranslated.** The runtime list translates option labels through its i18n resolver; Studio shows the declared label, as the rest of the Studio inspector does. - **Dispatch-order conflict, named:** the order asks for the runtime half "with file:line"; `ui/AGENTS.md` commandment 11 (cite by content) bans line addresses in issue and PR bodies, so it is cited by symbol above. - The harness's after-PR assignee/review step is skipped on purpose (maintainer rule: a human sees the PR only after the seat's review). ## Verification <details><summary>Local gates at 12451e3</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 1 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `pnpm --dir ui exec vitest run scripts/` exited 1 on the shared box: 2 of 4903 tests timed out at 15000ms (`check-hand-rolled-comment-mask.test.ts` "exits 0 as a process, which is what CI reads", `check-vi-mock-inherit.test.ts` "no `@object-ui/i18n` factory freezes the surface") while unlocked gates ran beside it. Re-run alone under the lock: `Test Files 2 passed (2)`, `Tests 105 passed (105)`, exit 0. CI is the authority for that family. Reconciliation: `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. Also: `pnpm --filter @object-ui/app-shell run type-check` exit 0 (both `tsc --noEmit` and `tsc -p tsconfig.test.json`); the new test file 3/3; neighbouring suites (every `ObjectListViewInspector*`, `StudioDesignSurface.listViewInspector*`, `widgets*`, `useObjectFields*`, `ViewVariantInspector*` test, 15 files) `Tests 75 passed (75)`; `pnpm check:new-line-citations` (in `ui/`): 0 new citation(s). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbbpSUvCd7G2qbzz4G)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c2716f8 commit 0e33108

5 files changed

Lines changed: 309 additions & 14 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): Studio's list-view filter offers a picklist field's options and stores the option value
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
In Studio, **Interfaces → a list view → Filter → Add filter**, a picklist field's value was a plain text box. An author typed the label they see (`New Request`), the view saved `value: 'New Request'`, the draft still read valid, and the runtime list showed "No records match this view's filter", because the stored value is the option's `value` (`new`).
11+
12+
The panel mounts the runtime list filter's `FilterBuilder`, which draws a picklist column's value as a picker over the field's options; the field catalog handed to it had dropped `options` on the way. They now travel with the field, so the value control lists the field's options (by label) and stores the option's `value`, the same control and the same stored shape as the runtime list filter. Other field types keep their value controls.

‎ui/packages/app-shell/src/views/metadata-admin/previews/useObjectFields.ts‎

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,34 @@ export interface ObjectFieldInfo {
5757
* no such refusal.
5858
*/
5959
required?: boolean;
60+
/**
61+
* The field's declared choices (`FieldSchema.options`, the spec's
62+
* `SelectOption` — `{ value, label }`), in declared order. Absent when the
63+
* field declares none.
64+
*
65+
* Carried for the list-view inspector's filter (objectstack#27515): the
66+
* runtime `FilterBuilder` it mounts draws an option picker for a picklist
67+
* column and stores the option's `value`, but only when the column it is
68+
* handed carries `options`. Without them the builder fell back to a plain
69+
* text box, an author typed the label they see (`New Request`), and the view
70+
* saved a filter no record matches (`new` is what is stored).
71+
*/
72+
options?: Array<{ value: string; label: string }>;
73+
}
74+
75+
/**
76+
* The declared choices of a served field definition, in the spec's own
77+
* `SelectOption` shape and only in it — an entry that is not `{ value, label }`
78+
* with string members is not a choice the builder can offer.
79+
*/
80+
function declaredFieldOptions(def: Record<string, unknown>): Array<{ value: string; label: string }> | undefined {
81+
if (!Array.isArray(def.options)) return undefined;
82+
const options = (def.options as unknown[]).flatMap((o) => {
83+
if (!o || typeof o !== 'object') return [];
84+
const { value, label } = o as { value?: unknown; label?: unknown };
85+
return typeof value === 'string' && typeof label === 'string' ? [{ value, label }] : [];
86+
});
87+
return options.length > 0 ? options : undefined;
6088
}
6189

6290
export interface UseObjectFieldsResult {
@@ -135,17 +163,21 @@ export function useObjectFields(
135163
return;
136164
}
137165
const view = readFields((obj as any).fields);
138-
const fields: ObjectFieldInfo[] = view.entries.map((e) => ({
139-
name: e.name,
140-
label:
141-
typeof e.def.label === 'string' && e.def.label
142-
? (e.def.label as string)
143-
: e.name,
144-
type: typeof e.def.type === 'string' ? (e.def.type as string) : 'text',
145-
hidden: e.def.hidden === true,
146-
...(e.def.system === true ? { system: true } : {}),
147-
...(e.def.required === true ? { required: true } : {}),
148-
}));
166+
const fields: ObjectFieldInfo[] = view.entries.map((e) => {
167+
const options = declaredFieldOptions(e.def);
168+
return {
169+
name: e.name,
170+
label:
171+
typeof e.def.label === 'string' && e.def.label
172+
? (e.def.label as string)
173+
: e.name,
174+
type: typeof e.def.type === 'string' ? (e.def.type as string) : 'text',
175+
hidden: e.def.hidden === true,
176+
...(e.def.system === true ? { system: true } : {}),
177+
...(e.def.required === true ? { required: true } : {}),
178+
...(options ? { options } : {}),
179+
};
180+
});
149181
setState({ result: { fields, loading: false, error: null }, notFound: false });
150182
})
151183
.catch((err) => {

‎ui/packages/app-shell/src/views/metadata-admin/widgets.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ export interface ObjectFieldOption {
7979
name: string;
8080
label?: string;
8181
type?: string;
82+
/**
83+
* The field's declared choices, `{ value, label }` (the spec's
84+
* `SelectOption`). Read by the `filter-builder` widget alone: the runtime
85+
* `FilterBuilder` draws an option picker for a picklist column only when the
86+
* column carries them (objectstack#27515). Absent when none are declared.
87+
*/
88+
options?: Array<{ value: string; label: string }>;
8289
}
8390

8491
/** One entry of the source object's view catalog (`view-ref`). */
@@ -2750,7 +2757,7 @@ function groupOfStoredRules(rules: FilterRuleLite[]) {
27502757
export function FilterBuilderField({ value, onChange, fields, readOnly, id, ariaLabelledBy, loadError }: {
27512758
value?: FilterRuleLite[];
27522759
onChange: (rules: FilterRuleLite[]) => void;
2753-
fields: Array<{ name: string; label?: string; type?: string }>;
2760+
fields: Array<{ name: string; label?: string; type?: string; options?: Array<{ value: string; label: string }> }>;
27542761
readOnly?: boolean;
27552762
/**
27562763
* Why `fields` is empty, when the reason is that the catalog failed to load
@@ -2799,7 +2806,17 @@ export function FilterBuilderField({ value, onChange, fields, readOnly, id, aria
27992806
const held = foldFilterGroupToSpecRules(draft);
28002807
if (!held.ok || JSON.stringify(held.rules) !== rulesKey) setDraft(groupOfStoredRules(rules));
28012808
}
2802-
const fbFields = fields.map((f) => ({ value: f.name, label: f.label || f.name, type: f.type }));
2809+
// `options` travel with the column (objectstack#27515): the builder draws a
2810+
// picklist column's value as a picker over them and stores the option's
2811+
// `value` — the runtime list filter's control, not a second editor. Without
2812+
// them a picklist column got a free-text box, and the label an author typed
2813+
// there matched no record.
2814+
const fbFields = fields.map((f) => ({
2815+
value: f.name,
2816+
label: f.label || f.name,
2817+
type: f.type,
2818+
...(f.options ? { options: f.options } : {}),
2819+
}));
28032820
const summary = rules.length
28042821
? rules.map((r) => `${fields.find((f) => f.name === r.field)?.label || r.field}`).filter(Boolean).join(', ')
28052822
: '';
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27515 — the list-view panel's filter offers a picklist field's
5+
* options and stores the option's value.
6+
*
7+
* Measured on the card: Studio, Interfaces, a list view's Filter, Add filter,
8+
* Status, Equals — the value was a plain text box. The author typed the label
9+
* they see (`New Request`), the view saved `value: 'New Request'`, and the
10+
* runtime list matched no record, because the stored value is `new`.
11+
*
12+
* The panel mounts the runtime `FilterBuilder`, which already draws a picklist
13+
* column as a picker over its options; the field catalog it was handed had
14+
* dropped `options` on the way (`useObjectFields`, the panel's catalog, the
15+
* `filter-builder` widget). The REAL hook (`useObjectListViewDraft`), the REAL
16+
* panel and the REAL `useObjectFields` over a REAL `MetadataClient` here, so
17+
* all three layers are crossed.
18+
*
19+
* Pinned:
20+
* - a picklist rule's value is a picker over the field's options, showing the
21+
* option's LABEL for the stored value, with no free-text box;
22+
* - picking an option writes the option's VALUE (`new`, not `New Request`);
23+
* - CONTROL: a number field keeps its plain value input.
24+
*/
25+
26+
import '@testing-library/jest-dom/vitest';
27+
import * as React from 'react';
28+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
29+
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
30+
import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';
31+
import { MetadataClient } from '@object-ui/data-objectstack';
32+
33+
// The builder lives inside a Radix popover, which does not open in jsdom.
34+
// Rendering the popover parts inline is this directory's existing way of
35+
// reaching it (`widgets.filterBuilderHeldRow-26738.test.tsx`); the rest of the
36+
// barrel stays real — the value picker included. No JSX in the factory:
37+
// `vi.mock` is hoisted above the imports.
38+
vi.mock('@object-ui/components', async () => {
39+
const actual = await vi.importActual<Record<string, unknown>>('@object-ui/components');
40+
const Pass = ({ children }: { children?: unknown }) => children as never;
41+
return { ...actual, Popover: Pass, PopoverTrigger: Pass, PopoverContent: Pass };
42+
});
43+
44+
const PKG = 'com.acme.app';
45+
const VIEW = 'acme_work_order.default';
46+
47+
const OBJECT_DEF = {
48+
name: 'acme_work_order',
49+
label: 'Work Order',
50+
fields: {
51+
status: {
52+
name: 'status',
53+
label: 'Status',
54+
type: 'select',
55+
options: [
56+
{ value: 'new', label: 'New Request' },
57+
{ value: 'done', label: 'Completed' },
58+
],
59+
},
60+
estimated_hours: { name: 'estimated_hours', label: 'Estimated Hours', type: 'number' },
61+
},
62+
};
63+
64+
const gridView = (extra: Record<string, unknown> = {}) => ({
65+
name: VIEW,
66+
object: 'acme_work_order',
67+
viewKind: 'list',
68+
isDefault: true,
69+
config: { type: 'grid', data: { provider: 'object', object: 'acme_work_order' }, columns: ['status'], ...extra },
70+
});
71+
72+
const server = { active: new Map<string, Record<string, unknown>>() };
73+
74+
const json = (status: number, body: unknown) =>
75+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
76+
77+
async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
78+
const method = (init?.method ?? 'GET').toUpperCase();
79+
const url = new URL(String(input), 'http://studio.test');
80+
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
81+
if (!meta) return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
82+
const seg = meta[1].split('/').map(decodeURIComponent);
83+
const key = `${seg[0]}/${seg[1]}`;
84+
if (seg[0] === '_drafts') return json(200, { drafts: [] });
85+
if (method === 'PUT') return json(200, { success: true, version: 'v2', state: 'draft' });
86+
if (seg[0] === 'object' && seg.length === 2) return json(200, { type: 'object', name: seg[1], item: OBJECT_DEF });
87+
if (seg.length === 3 && seg[2] === 'layers') {
88+
const row = server.active.get(key);
89+
return row
90+
? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row })
91+
: json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
92+
}
93+
// No view draft is pending in this suite.
94+
if (seg.length === 2 && url.searchParams.get('state') === 'draft') {
95+
return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } });
96+
}
97+
const row = server.active.get(key);
98+
return row ? json(200, { type: seg[0], name: seg[1], item: row }) : json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
99+
}
100+
101+
const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
102+
103+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
104+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
105+
return { ...mod, useMetadataClient: () => client };
106+
});
107+
108+
import { ObjectListViewInspector, useObjectListViewDraft } from './ObjectListViewInspector';
109+
import type { Surface } from './navSurface';
110+
111+
const METADATA: MetadataContextValue = {
112+
apps: [],
113+
objects: [OBJECT_DEF],
114+
dashboards: [],
115+
reports: [],
116+
pages: [],
117+
loading: false,
118+
error: null,
119+
refresh: async () => {},
120+
invalidate: () => {},
121+
ensureType: async () => [],
122+
getItem: async () => null,
123+
getItemsByType: () => [],
124+
};
125+
126+
const LEAF: Surface = { type: 'object', name: 'acme_work_order', label: 'Work Orders' };
127+
128+
/** The panel, and the view item its edits leave in the buffer. */
129+
function Host() {
130+
const listView = useObjectListViewDraft({ client, packageId: PKG, leaf: LEAF, publishNonce: 0 });
131+
return (
132+
<>
133+
<ObjectListViewInspector listView={listView} readOnly={false} locale="en" />
134+
<pre data-testid="buffered-row">{JSON.stringify(listView.row)}</pre>
135+
</>
136+
);
137+
}
138+
139+
async function settle() {
140+
await act(async () => {
141+
for (let i = 0; i < 25; i++) await new Promise((r) => setTimeout(r, 0));
142+
});
143+
}
144+
145+
async function openFilter(): Promise<HTMLElement> {
146+
render(
147+
<MetadataCtx.Provider value={METADATA}>
148+
<Host />
149+
</MetadataCtx.Provider>,
150+
);
151+
await settle();
152+
return within(screen.getByTestId('studio-list-view-inspector')).getByTestId('list-view-filter');
153+
}
154+
155+
const bufferedFilter = () =>
156+
(JSON.parse(screen.getByTestId('buffered-row').textContent ?? 'null') as { config?: { filter?: unknown } } | null)
157+
?.config?.filter;
158+
159+
// Radix Select opens on pointer events jsdom does not implement
160+
// (`option-value-round-trip.test.tsx` in `@object-ui/components`).
161+
const savedPointer = {
162+
PointerEvent: (window as { PointerEvent?: unknown }).PointerEvent,
163+
hasPointerCapture: (HTMLElement.prototype as { hasPointerCapture?: unknown }).hasPointerCapture,
164+
releasePointerCapture: (HTMLElement.prototype as { releasePointerCapture?: unknown }).releasePointerCapture,
165+
};
166+
beforeAll(() => {
167+
class MockPointerEvent extends Event {
168+
button: number;
169+
ctrlKey: boolean;
170+
pointerType: string;
171+
constructor(type: string, props: { button?: number; ctrlKey?: boolean; pointerType?: string } & EventInit = {}) {
172+
super(type, props);
173+
this.button = props.button ?? 0;
174+
this.ctrlKey = props.ctrlKey ?? false;
175+
this.pointerType = props.pointerType ?? 'mouse';
176+
}
177+
}
178+
(window as { PointerEvent?: unknown }).PointerEvent = MockPointerEvent;
179+
(HTMLElement.prototype as { hasPointerCapture?: unknown }).hasPointerCapture = vi.fn();
180+
(HTMLElement.prototype as { releasePointerCapture?: unknown }).releasePointerCapture = vi.fn();
181+
});
182+
afterAll(() => {
183+
(window as { PointerEvent?: unknown }).PointerEvent = savedPointer.PointerEvent;
184+
(HTMLElement.prototype as { hasPointerCapture?: unknown }).hasPointerCapture = savedPointer.hasPointerCapture;
185+
(HTMLElement.prototype as { releasePointerCapture?: unknown }).releasePointerCapture = savedPointer.releasePointerCapture;
186+
});
187+
188+
beforeEach(() => {
189+
server.active.clear();
190+
});
191+
afterEach(cleanup);
192+
193+
describe('ObjectListViewInspector — a picklist filter offers the field’s options (objectstack#27515)', () => {
194+
it('a picklist rule’s value is a picker showing the option label for the stored value, not a text box', async () => {
195+
server.active.set(`view/${VIEW}`, gridView({ filter: [{ field: 'status', operator: 'equals', value: 'new' }] }));
196+
const filter = await openFilter();
197+
198+
expect(within(filter).queryAllByPlaceholderText('Value')).toHaveLength(0);
199+
const pickers = within(filter).getAllByRole('combobox');
200+
expect(pickers.map((p) => p.textContent)).toContain('New Request');
201+
// Opening it is a read: nothing is written.
202+
expect(bufferedFilter()).toEqual([{ field: 'status', operator: 'equals', value: 'new' }]);
203+
});
204+
205+
it('picking an option writes the option’s value, not its label', async () => {
206+
server.active.set(`view/${VIEW}`, gridView());
207+
const filter = await openFilter();
208+
209+
fireEvent.click(within(filter).getByRole('button', { name: 'Add filter' }));
210+
// The new row starts on the first field, `status`, and offers its options.
211+
const valuePicker = within(filter)
212+
.getAllByRole('combobox')
213+
.find((p) => p.textContent === 'Select value');
214+
expect(valuePicker).toBeTruthy();
215+
fireEvent.pointerDown(valuePicker!, { button: 0 });
216+
expect(screen.getByRole('option', { name: 'Completed' })).toBeInTheDocument();
217+
fireEvent.click(screen.getByRole('option', { name: 'New Request' }));
218+
219+
expect(bufferedFilter()).toEqual([{ field: 'status', operator: 'equals', value: 'new' }]);
220+
});
221+
222+
it('CONTROL: a number field keeps its plain value input', async () => {
223+
server.active.set(`view/${VIEW}`, gridView({ filter: [{ field: 'estimated_hours', operator: 'equals', value: 4 }] }));
224+
const filter = await openFilter();
225+
226+
const inputs = within(filter).getAllByPlaceholderText('Value') as HTMLInputElement[];
227+
expect(inputs).toHaveLength(1);
228+
expect(inputs[0].value).toBe('4');
229+
});
230+
});

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -908,7 +908,11 @@ export function ObjectListViewInspector({
908908
? failed(fieldState.error)
909909
: fieldState.loading
910910
? { status: 'loading' }
911-
: loaded(fields.map((f) => ({ name: f.name, label: f.label, type: f.type })));
911+
: loaded(
912+
// `options` ride along for the filter's picklist value picker
913+
// (objectstack#27515); the other pickers read name, label and type.
914+
fields.map((f) => ({ name: f.name, label: f.label, type: f.type, ...(f.options ? { options: f.options } : {}) })),
915+
);
912916
const labelOf = (name: string) => fields.find((f) => f.name === name)?.label ?? name;
913917
const sort = sortEntriesOf(body.sort);
914918
const sortSummary = sort

0 commit comments

Comments
 (0)