Skip to content

Commit 10fc9bf

Browse files
fix(console): a domain-bearing settings select shows the option name for a stored code (#28134)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoFtuMs1JdQpk89qqQegsLgW)_ Fixes #28068 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:设置页里带值域的下拉框(公司「国家」、本地化「默认国家」、时区、货币)现在在输入框里显示已存代码对应的选项名称,例如存的是 `DE` 就显示「Germany (DE)」;建议列表也按名称列出,选中后仍然保存代码。 - **为什么改**:上一轮把国家改成了带国名的选项,但控制台框里仍只显示裸代码 `DE`,国名只出现在下拉建议里,管理员看不出当前选的是哪个国家。 - **风险与回滚**:低。只改控制台这一个控件的显示;保存到服务端的仍是代码,不在选项表里的自由输入仍原样提交并由服务端按值域校验,接受的值集合不变。回滚 = revert 本 PR。 - **先看**:`ui/apps/console/src/pages/settings/SettingsField.tsx` 的 `DomainCombobox`(显示名与存储码的映射);`ui/apps/console/src/pages/settings/__tests__/SettingsField.domainOptionName-28068.test.tsx`(新钉子)。 - **两分钟验证**:`pnpm --dir ui exec vitest run apps/console/src/pages/settings/__tests__/SettingsField.domainOptionName-28068.test.tsx`,预期 6 例全绿;或打开 Setup → Company,已存 `DE` 的「国家」框里显示 Germany (DE)。 - **需要你做的**:无,席位落地。 ## What changes `DomainCombobox` (the control a settings `select` gets when its specifier declares a `valueDomain`) now splits what the box shows from what the draft holds: - A stored value that equals an option's `value` is shown as that option's (localized) label, so a stored `DE` reads "Germany (DE)". A stored value no option carries shows verbatim, as before. - The datalist suggestions carry the label as their `value` attribute. A datalist pick writes that attribute into the box, so picking "France (FR)" leaves the name in the box, and the component maps the label back: `FR` is what `onChange` commits. - Typed text that equals an option's `value` is a code and is committed as typed; it reads as its name once the field loses focus. Any other text is committed verbatim (an emptied box is still unset, `null`), so the suggest-but-allow-anything contract and the server's `invalid_value` + `constraint: { valueDomain }` verdict are unchanged. - A value changed from outside the box (Reset to default, the re-read after save) shows as its name; the component keeps the admin's typed text only while it still stands for the stored value, with no effect or remount. Measured before building: on `origin/main` `6410029d` the control rendered the stored value verbatim plus a datalist whose option `value` was the code. A datalist alone cannot meet "an existing stored code shows as its picked option": a pick writes the option's `value` attribute into the box, so the box always ends up holding the code. Hence the display-label / stored-code split. Upstream PR #28063 is merged and ships the `iso_3166_alpha2` options named with their codes; this builds on that. ## Tests - `SettingsField.domainOptionName-28068.test.tsx` (new, 6 cases): a stored `DE` reads as its name; picking a named option stores its code and keeps the name; a code typed by hand is committed as typed and reads as its name on blur; free text outside the table (`CH`, `Germ`) is committed verbatim and shows as itself; an unmatched stored value shows verbatim and an emptied box commits `null`; a value changed from outside shows as its name. - `SettingsField.valueDomain.test.tsx`: the datalist pin now expects the suggestions by label (they were codes); the free-text half and the closed-dropdown half are unchanged. ## Acceptance notes - The time zone and currency rows are domain-bearing too, so they get the same display: a stored zone that is in the curated list (e.g. `Asia/Shanghai`) now shows its label. `WorkspaceTimezonePrompt` renders the same field, so its prefilled browser zone shows its label when the curated list carries it, and verbatim otherwise; its tests are unchanged and green. - Not browser-driven: the datalist behaviour (a pick writes the option's `value` attribute into the box) is the HTML standard's and was exercised through jsdom change events, not re-driven in Chromium. ## Verification Code readings at `021fe3c4`; the head `fb0d3190` adds only the changeset. Gate readings at `fb0d3190` (base `origin/main` `6410029d`). <details><summary>Tests, type-check, lint (under the shared verify lock)</summary> - `pnpm --dir ui exec vitest run apps/console/src/pages/settings/`: Test Files 31 passed (31), Tests 249 passed (249). - `pnpm --filter @object-ui/console run type-check`: exit 0, after a full `pnpm turbo run build --filter='@object-ui/console^...'` (70 tasks). - `eslint` on the three changed source and test files: 0 errors (2 pre-existing `no-explicit-any` warnings on untouched lines). </details> <details><summary>Ablations (scripts/ablation-replace.mjs, restored by blob hash)</summary> - The label lookup in `displayOf` replaced by the bare code: the new file goes 3 failed / 3 passed (a stored DE reads as its name; a code typed by hand reads as its name on blur; a value changed from outside shows as its name); restored blob `b302e7f9` equals HEAD. - The label-to-code mapping in `committedValue` replaced by the raw text: 1 failed / 5 passed (picking a named option stores its code); restored blob `b302e7f9` equals HEAD. </details> <details><summary>Gate families (dispatch-gates --commands, reconciled with --ran)</summary> - 33 families derived; 33 run with exit 0, including `node scripts/check-sdui-manifest.mjs` and its `--self-test`, and `pnpm --dir ui exec vitest run scripts/` (Test Files 169 passed, 2 skipped; Tests 4901 passed, 2 skipped). - `pnpm check:new-line-citations` (ui): 0 new citations. - Reconcile: `33 derived famil(ies) accounted for — 33 run, 0 NOT-MEASURED`. - The repo-wide lint and the rest of the farm are CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwdTq7tNaWVGBgsXcg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd3dc6e commit 10fc9bf

4 files changed

Lines changed: 165 additions & 8 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): a domain-bearing settings select shows the option name for a stored code
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A settings `select` that declares a `valueDomain` (Company → Country, Localization → Default country, Time zone, Currency) showed its stored value bare in the box, so a stored `DE` read as `DE` and the option names were visible only in the suggestion list. The box now shows the matching option's name ("Germany (DE)") and the suggestions are offered by name; picking one still stores its code. A code typed by hand is committed as typed and reads as its name once the field loses focus, and a value outside the option list is still committed verbatim and judged by the server, as before.

‎ui/apps/console/src/pages/settings/SettingsField.tsx‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* (which carries provenance and lock state).
77
*/
88

9-
import { cloneElement, isValidElement, lazy, Suspense, useId, type ReactElement } from 'react';
9+
import { cloneElement, isValidElement, lazy, Suspense, useId, useState, type ReactElement } from 'react';
1010
import {
1111
Input,
1212
Textarea,
@@ -274,6 +274,15 @@ function FieldError({ id, message }: { id: string; message: string }) {
274274
* refused by the server with `invalid_value` + `constraint: { valueDomain }`,
275275
* which lands in the field-error slot the wrapper already owns.
276276
*
277+
* The box shows NAMES, the draft holds CODES (objectstack#28068). A stored
278+
* value that equals an option's `value` is shown as that option's label, and
279+
* the suggestions are the labels, so picking "Germany (DE)" leaves the name in
280+
* the box while `DE` is what is committed. A datalist commits its option's
281+
* `value` attribute into the box verbatim, which is why the suggestions carry
282+
* the label there and `committedValue` maps it back. Typed text that equals an
283+
* option's `value` is a code and is committed as typed; any other text is
284+
* committed verbatim, as before.
285+
*
277286
* Props beyond its own are forwarded to the `<input>` so `wrapper`'s
278287
* `aria-invalid` / `aria-describedby` reach the focusable control rather than a
279288
* wrapping node (same seam as Combobox's trigger pass-through, objectui#3318).
@@ -284,6 +293,7 @@ function DomainCombobox({
284293
options,
285294
disabled,
286295
onChange,
296+
onBlur,
287297
...inputProps
288298
}: {
289299
id: string;
@@ -293,6 +303,17 @@ function DomainCombobox({
293303
onChange: (next: unknown) => void;
294304
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'id'>) {
295305
const listId = `${id}-domain`;
306+
const stored = value == null ? null : String(value);
307+
const displayOf = (code: string | null): string =>
308+
code === null ? '' : options.find((opt) => opt.value === code)?.label ?? code;
309+
const committedValue = (text: string): string | null => {
310+
if (text === '' || options.some((opt) => opt.value === text)) return textOrUnset(text);
311+
return options.find((opt) => opt.label === text)?.value ?? text;
312+
};
313+
// What the admin typed, kept while it still stands for the stored value; a
314+
// value changed from outside (a reset, a re-read after save) shows as its name.
315+
const [typed, setTyped] = useState<string | null>(null);
316+
const shown = typed !== null && committedValue(typed) === stored ? typed : displayOf(stored);
296317
return (
297318
<>
298319
<Input
@@ -301,17 +322,23 @@ function DomainCombobox({
301322
// `list` only when there is something to suggest: an empty <datalist>
302323
// renders a dead dropdown affordance on some browsers.
303324
list={options.length > 0 ? listId : undefined}
304-
value={value == null ? '' : String(value)}
325+
value={shown}
305326
disabled={disabled}
306-
onChange={(e) => onChange(textOrUnset(e.target.value))}
327+
onChange={(e) => {
328+
setTyped(e.target.value);
329+
onChange(committedValue(e.target.value));
330+
}}
331+
onBlur={(e) => {
332+
// A code typed by hand reads as its name once the admin moves on.
333+
setTyped(null);
334+
onBlur?.(e);
335+
}}
307336
{...inputProps}
308337
/>
309338
{options.length > 0 ? (
310339
<datalist id={listId}>
311340
{options.map((opt) => (
312-
<option key={opt.value} value={opt.value}>
313-
{opt.label}
314-
</option>
341+
<option key={opt.value} value={opt.label} />
315342
))}
316343
</datalist>
317344
) : null}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
* SettingsField — a domain-bearing `select` shows a stored code as its
11+
* option's name, and still stores the code (objectstack#28068).
12+
*
13+
* Company `country` and Localization `default_country` are selects over the
14+
* `iso_3166_alpha2` domain whose options name each code ("Germany (DE)"). The
15+
* combobox showed the stored `DE` bare in the box, so the names were visible
16+
* only in the suggestion list. The suggest-but-allow-anything contract of the
17+
* domain control is unchanged: a value outside the table is still committed
18+
* verbatim, and the server stays the judge of the domain.
19+
*/
20+
21+
import { describe, it, expect, vi, afterEach } from 'vitest';
22+
import { render, cleanup, fireEvent } from '@testing-library/react';
23+
import { useState } from 'react';
24+
25+
import { SettingsField } from '../SettingsField';
26+
import type { Specifier } from '../types';
27+
28+
/** Abridged `company.country` as the server declares it. */
29+
const COUNTRY: Specifier = {
30+
type: 'select',
31+
key: 'country',
32+
label: 'Country',
33+
valueDomain: 'iso_3166_alpha2',
34+
options: [
35+
{ value: 'US', label: 'United States (US)' },
36+
{ value: 'DE', label: 'Germany (DE)' },
37+
{ value: 'FR', label: 'France (FR)' },
38+
],
39+
};
40+
41+
/** A settings page's draft in one row: `value` follows what `onChange` commits. */
42+
function Row({ initial, onCommit }: { initial: unknown; onCommit: (v: unknown) => void }) {
43+
const [value, setValue] = useState<unknown>(initial);
44+
return (
45+
<SettingsField
46+
spec={COUNTRY}
47+
value={value}
48+
onChange={(next) => {
49+
onCommit(next);
50+
setValue(next);
51+
}}
52+
/>
53+
);
54+
}
55+
56+
function renderRow(initial: unknown) {
57+
const onCommit = vi.fn();
58+
const view = render(<Row initial={initial} onCommit={onCommit} />);
59+
const input = view.container.querySelector('input') as HTMLInputElement;
60+
return { ...view, input, onCommit };
61+
}
62+
63+
afterEach(cleanup);
64+
65+
describe('SettingsField — a domain-bearing select shows the option name for a stored code', () => {
66+
it('a stored DE reads as its name in the box', () => {
67+
const { input } = renderRow('DE');
68+
expect(input.value).toBe('Germany (DE)');
69+
});
70+
71+
it('picking a named option stores its code and keeps the name in the box', () => {
72+
const { input, onCommit } = renderRow('US');
73+
// A datalist pick writes the suggestion's `value` attribute into the box.
74+
fireEvent.change(input, { target: { value: 'France (FR)' } });
75+
expect(onCommit).toHaveBeenLastCalledWith('FR');
76+
expect(input.value).toBe('France (FR)');
77+
});
78+
79+
it('a code typed by hand is committed as typed and reads as its name on blur', () => {
80+
const { input, onCommit } = renderRow(null);
81+
fireEvent.change(input, { target: { value: 'DE' } });
82+
expect(onCommit).toHaveBeenLastCalledWith('DE');
83+
expect(input.value).toBe('DE');
84+
fireEvent.blur(input);
85+
expect(input.value).toBe('Germany (DE)');
86+
});
87+
88+
it('a free-text value outside the table is still committed verbatim', () => {
89+
const { input, onCommit } = renderRow('DE');
90+
fireEvent.change(input, { target: { value: 'CH' } });
91+
expect(onCommit).toHaveBeenLastCalledWith('CH');
92+
fireEvent.change(input, { target: { value: 'Germ' } });
93+
expect(onCommit).toHaveBeenLastCalledWith('Germ');
94+
fireEvent.blur(input);
95+
// No option carries it, so it shows as itself.
96+
expect(input.value).toBe('Germ');
97+
});
98+
99+
it('an unmatched stored value shows verbatim, and an emptied box is unset', () => {
100+
const { input, onCommit } = renderRow('CH');
101+
expect(input.value).toBe('CH');
102+
fireEvent.change(input, { target: { value: '' } });
103+
expect(onCommit).toHaveBeenLastCalledWith(null);
104+
expect(input.value).toBe('');
105+
});
106+
107+
it('a value changed from outside the box shows as its name', () => {
108+
// A reset or a re-read after save hands the row a new value while the
109+
// admin's last pick is still held in the box.
110+
const { container, rerender } = render(
111+
<SettingsField spec={COUNTRY} value="US" onChange={() => {}} />,
112+
);
113+
const box = container.querySelector('input') as HTMLInputElement;
114+
fireEvent.change(box, { target: { value: 'United States (US)' } });
115+
rerender(<SettingsField spec={COUNTRY} value="DE" onChange={() => {}} />);
116+
expect(box.value).toBe('Germany (DE)');
117+
});
118+
});

‎ui/apps/console/src/pages/settings/__tests__/SettingsField.valueDomain.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,16 @@ describe('SettingsField — `select` follows the specifier\'s valueDomain declar
159159
const input = container.querySelector('input');
160160
expect(input, 'a domain-bearing select must render an editable control').not.toBeNull();
161161

162-
// The curated table survives — as SUGGESTIONS, not as the boundary.
162+
// The curated table survives — as SUGGESTIONS, not as the boundary. They
163+
// are offered by name; the name maps back to its code on pick
164+
// (`SettingsField.domainOptionName-28068.test.tsx`).
163165
const listId = input!.getAttribute('list');
164166
const datalist = container.querySelector('datalist');
165167
expect(datalist).not.toBeNull();
166168
expect(datalist!.id).toBe(listId);
167169
expect(
168170
Array.from(datalist!.querySelectorAll('option')).map((o) => o.getAttribute('value')),
169-
).toEqual(optionValues(spec));
171+
).toEqual((spec.options ?? []).map((o) => o.label));
170172

171173
// …and free text outside them is committed verbatim, not swallowed.
172174
fireEvent.change(input!, { target: { value: probe } });

0 commit comments

Comments
 (0)