Skip to content

Commit ac56acf

Browse files
fix(console): a deployment-level settings value set here reads Modified and offers Reset to default (#30428)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgSR1JGX3VuyVmi9T3mtH6Z4)_ domain:ui#2 · os-bill · session_01Utp6Cb5R6JDdfyvtj3dPR2 Fixes #30329 Clause-②: yes (widening) — two published language-pack keys in all ten built-in packs: `console.settingsView.sourceBadgeModified` and `console.settingsView.defaultValueHint` (with `{{value}}`). Metadata-protocol: none ## 维护者速读 - 改了什么:部署级设置页(清单 `scope: 'global'`,如 Setup 的认证页)上管理员自己改过的值,徽标从「继承自全局」改为「已修改」,旁边写出默认值(如「默认:8」,选项显示选项名、开关显示是/否、密码从不显示),并提供「恢复默认」;点了以后照常要按保存才生效。 - 为什么改:原来改过的值看上去像是从别处继承来的,也没有恢复默认按钮,想回到默认只能自己记得默认值或清空输入框。 - 风险与回滚:只动 console 设置页两个文件、十个语言包各加两个键、一个测试文件和 changeset;不动 spec、服务端或元数据。组织级和用户级页面的徽标不变,环境变量锁定的值仍不给恢复。回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/pages/settings/SettingsField.tsx` 的 `sourceBadgeOf`(新增 `modified` 分支)和 `FieldHeader`(恢复按钮条件、默认值提示)→ `SettingsView.tsx` 传入的 `writeScope` → 测试文件新增的 describe。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/pages/settings/__tests__/SettingsView.unsetAndReset-13145.test.tsx`,预期 9/9 通过(在 main 源码上新加的 3 条失败)。 - 需要你做的:无,席位落地。 ## What changes The card's two Expected bullets, quoted: > - A value changed here says so, for example 已修改, and names the default. > - Reset to default is available on it, with Save confirming it as other changes do. - `SettingsView` passes each row `writeScope`, the scope its Save writes, resolved exactly as the server resolves it in `registerManifest`: the specifier's `scope`, else the manifest's, else the spec default `tenant` (`SettingsManifestSchema` declares `scope: SpecifierScopeSchema.default('tenant')`). The served manifest is the registered one unparsed, so an absent `scope` is the server's `tenant` too. - `sourceBadgeOf` returns a new `modified` badge when the effective layer is `global` and the row's `writeScope` is `global`; any other `global` or `env` value still reads `Inherited from …`. The `tenant` and `user` arms are unchanged, so the organization and user pages keep their `Overrides …` badges. - A `modified` row shows `Modified` (`已修改`), the default layer's value from the server's `cascadeChain` as `Default: VALUE` (`默认:VALUE`), and Reset to default. The default is spelled as an option's label, Yes or No for a boolean, and never shown for a password or encrypted key or for an object value. - Reset is the existing draft `null` path: Save sends `null` and the badge follows the server's answer. - An env-locked row (`resolved.locked`) gets no badge and no Reset, as before. - The number control now shows an empty box for an unset value. With `value={null}` React left the last number on screen, so a Reset on a number row (the card's 密码最小长度) looked as if nothing happened. This is the same path the card asks for, in the same file. PM mechanism assumptions, measured on `origin/main` 6fa842a: - The badge: held. `sourceBadgeOf` returned `inherited` for every effective scope other than `default`, `user` and `tenant`. - The reset button: held. It rendered only for `badge.kind === 'overrides'`. - The page's scope: the console already had it. `GET /api/settings/:namespace` serves the manifest with `scope` on it and on each specifier, and the server's write scope per key is `spec.scope ?? manifest.scope ?? 'tenant'` (`registerManifest` in `service-settings`). No server change was needed, and `packages/**` is untouched. - Locked values: held, unchanged. - The org and user pages: unchanged. The existing `Overrides` and `Inherited` pins in the same file pass unmodified, and the new describe adds a `scope: 'tenant'` specifier on a global manifest as a control. ## Tests New top-level describe `a value set on a deployment-level page (objectstack#30329)` in `SettingsView.unsetAndReset-13145.test.tsx`, the existing reset harness: the whole page over a stubbed `fetch` and the real `../api`, so the PUT body is read as it leaves the client. - A global number value: no `Inherited from`, reads `Modified` and `Default: 8`, Reset empties the box and dirties the page, Save sends exactly `{"password_min_length":null}`, and after the re-read the row shows `8` with no badge and no Reset. - A global toggle names `Default: No`, and a global select names its option label `Default: Lax`, each with Reset. - CONTROL: an env-locked value, a value at its default and a `scope: 'tenant'` key on the global manifest offer no Reset; the tenant key still reads `Inherited from`; the page carries exactly three Resets. <details><summary>Readings</summary> - Red first, on test-only commit 753defd (unfixed source): `Tests 3 failed | 6 passed (9)`. The first case failed on `Inherited from` being present, the second on `Modified` missing, the third on the Reset count (0 instead of 3); every existing case passed. - After the badge change alone, the first case failed on the number box still reading `12` after Reset (`expected '12' to be ''`), which is the number-control fix above. Then `Tests 9 passed (9)`. - `pnpm --dir ui exec vitest run apps/console/src/pages/settings/ packages/i18n/` (every settings test, the timezone prompt that also renders `SettingsField` included, and the i18n pack parity tests), on b001f22 after merging `origin/main`: `Test Files 128 passed (128)`, `Tests 1720 passed | 13 skipped (1733)`. - `pnpm exec turbo run type-check --filter=@object-ui/console --concurrency=2`: `Tasks: 74 successful, 74 total`. - eslint on the changed ui files: 0 errors; 4 `no-explicit-any` warnings, all on lines this PR does not add (one fewer in `SettingsField.tsx` than on main). - `pnpm check:new-line-citations`: 0 new citations. - `node scripts/pm/dispatch-gates.mjs --ran`: 35 derived families, 35 run, 0 NOT-MEASURED, every exit 0 (`check-sdui-manifest.mjs` and its self-test included). </details> ## Acceptance notes - The organization and user pages keep `Overrides Default` for a value set there, while the deployment page now reads `Modified · Default: VALUE`. That is what the dispatch asked (keep their badges); bringing the other pages to the same wording is a separate choice. - A tenant or user key whose default is `null` and whose own value is set shows no badge and no Reset (no upper layer carries a value). Unchanged here, noted only. ## Verification `pnpm --dir ui exec vitest run apps/console/src/pages/settings/__tests__/SettingsView.unsetAndReset-13145.test.tsx` --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CaTSNwaf1AMz4qR7Fs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0892a94 commit ac56acf

14 files changed

Lines changed: 282 additions & 15 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(console): a value set on a deployment-level settings page reads `Modified`, names the default, and offers Reset to default (#30329)
7+
8+
Clause-②: yes (widening) — two published language-pack keys in every built-in pack: `console.settingsView.sourceBadgeModified` and `console.settingsView.defaultValueHint` (with `{{value}}`).
9+
Metadata-protocol: none
10+
11+
A settings page whose manifest declares `scope: 'global'` (Setup's Authentication page is one) writes the deployment's row. A value it had saved was badged `Inherited from Global`, as if it came from somewhere else, and had no Reset to default, so going back meant knowing the default or emptying the box.
12+
13+
- The page now passes each row the scope its Save writes, resolved the way the server resolves it: the specifier's `scope`, else the manifest's, else the spec default `tenant`. A value whose effective layer is that scope reads `Modified`, names the default layer's value (`Default: 8`; an option's label, Yes or No for a toggle; never a secret's), and offers Reset to default. Reset puts `null` in the draft and Save sends it, like any other change.
14+
- An env-locked value never gets Reset. A value from a layer above the page's scope still reads `Inherited from …`, and the organization and user pages keep their `Overrides …` badges unchanged.
15+
- An unset number box now reads empty after Reset; it kept showing the last number.

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

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ import {
3333
import { useObjectTranslation } from '@object-ui/i18n';
3434
import { AlertTriangle, ChevronRight, Info, RotateCcw } from 'lucide-react';
3535
import { EnvLockBadge } from './EnvLockBadge';
36-
import { resolveLabel, type Specifier, type ResolvedSettingValue } from './types';
36+
import {
37+
resolveLabel,
38+
type Specifier,
39+
type SpecifierScope,
40+
type ResolvedSettingValue,
41+
} from './types';
3742
import type { SettingsLabelHelpers } from './useSettingsLabel';
3843

3944
/**
@@ -100,17 +105,27 @@ export interface SettingsFieldProps {
100105
* sitting under a red error reads as if the field has two states at once.
101106
*/
102107
error?: string;
108+
/**
109+
* The scope this row's Save writes. The page passes it; a host that does
110+
* not (the timezone prompt) keeps the badges a lower scope would see.
111+
*/
112+
writeScope?: SpecifierScope;
103113
}
104114

105115
type SourceBadge =
106116
| { kind: 'inherited'; scope: ResolvedSettingValue['source'] }
107-
| { kind: 'overrides'; scope: ResolvedSettingValue['source'] };
117+
| { kind: 'overrides'; scope: ResolvedSettingValue['source'] }
118+
/** The value is the one this page writes; `defaultValue` is the default layer's. */
119+
| { kind: 'modified'; defaultValue: unknown };
108120

109121
/**
110122
* Which source badge a row carries, read off the server's `cascadeChain` alone:
111123
* the console never invents a source state.
112124
*/
113-
function sourceBadgeOf(resolved: ResolvedSettingValue): SourceBadge | null {
125+
function sourceBadgeOf(
126+
resolved: ResolvedSettingValue,
127+
writeScope: SpecifierScope | undefined,
128+
): SourceBadge | null {
114129
const chain = resolved.cascadeChain;
115130
if (!chain || chain.length === 0) return null;
116131

@@ -125,7 +140,12 @@ function sourceBadgeOf(resolved: ResolvedSettingValue): SourceBadge | null {
125140
effective.scope !== 'user' &&
126141
effective.scope !== 'tenant'
127142
) {
128-
// global/env: show as inherited for downstream scopes
143+
// global/env: inherited for downstream scopes, but a global value on the
144+
// page that writes the global scope was set here.
145+
if (effective.scope === writeScope) {
146+
const defaultEntry = chain.find((e) => e.scope === 'default');
147+
return { kind: 'modified', defaultValue: defaultEntry?.value };
148+
}
129149
return { kind: 'inherited', scope: effective.scope };
130150
}
131151

@@ -143,6 +163,13 @@ function InheritanceBadge({
143163
labels?: SettingsLabelHelpers;
144164
}) {
145165
const { t } = useObjectTranslation();
166+
if (badge.kind === 'modified') {
167+
return (
168+
<Badge variant="outline" className="text-amber-700 border-amber-300 text-[10px]">
169+
{t('console.settingsView.sourceBadgeModified')}
170+
</Badge>
171+
);
172+
}
146173
const source = labels?.sourceLabel?.(badge.scope) ?? badge.scope;
147174

148175
// Each badge is ONE sentence with the layer's label interpolated as
@@ -178,6 +205,8 @@ function FieldHeader({
178205
htmlFor,
179206
onReset,
180207
resetDisabled,
208+
writeScope,
209+
describeDefault,
181210
}: {
182211
spec: Specifier;
183212
resolved?: ResolvedSettingValue;
@@ -186,15 +215,20 @@ function FieldHeader({
186215
labelId?: string;
187216
htmlFor?: string;
188217
/**
189-
* Clears this layer's value (objectui#13145). Offered only beside an
190-
* `Overrides` badge, the one state where clearing hands the row back to a
191-
* value the layer above carries.
218+
* Clears this layer's value (objectui#13145). Offered beside an `Overrides`
219+
* badge and a `Modified` one, the states where clearing hands the row back
220+
* to a value the layer above, or the default, carries.
192221
*/
193222
onReset?: () => void;
194223
resetDisabled?: boolean;
224+
writeScope?: SpecifierScope;
225+
/** The default value in words, or `null` when it has none worth showing. */
226+
describeDefault?: (value: unknown) => string | null;
195227
}) {
196228
const { t } = useObjectTranslation();
197-
const badge = resolved && !resolved.locked ? sourceBadgeOf(resolved) : null;
229+
const badge = resolved && !resolved.locked ? sourceBadgeOf(resolved, writeScope) : null;
230+
const defaultText =
231+
badge?.kind === 'modified' && describeDefault ? describeDefault(badge.defaultValue) : null;
198232
return (
199233
<div className="flex items-center gap-2">
200234
<Label id={labelId} htmlFor={htmlFor} className="text-sm font-medium">
@@ -210,7 +244,12 @@ function FieldHeader({
210244
) : null}
211245
{resolved?.locked ? <EnvLockBadge reason={resolved.lockedReason} /> : null}
212246
{badge ? <InheritanceBadge badge={badge} labels={labels} /> : null}
213-
{badge?.kind === 'overrides' && onReset ? (
247+
{defaultText !== null ? (
248+
<span className="text-[11px] text-muted-foreground">
249+
{t('console.settingsView.defaultValueHint', { value: defaultText })}
250+
</span>
251+
) : null}
252+
{(badge?.kind === 'overrides' || badge?.kind === 'modified') && onReset ? (
214253
// Sets the key to `null` in the draft; the page's Save sends it, and
215254
// the badge then follows the server's answer for the row.
216255
<Button
@@ -347,7 +386,8 @@ function DomainCombobox({
347386
}
348387

349388
export function SettingsField(props: SettingsFieldProps) {
350-
const { spec, resolved, value, onChange, onAction, locked, saving, labels, error } = props;
389+
const { spec, resolved, value, onChange, onAction, locked, saving, labels, error, writeScope } =
390+
props;
351391
const id = useId();
352392
const { t } = useObjectTranslation();
353393
const disabled = Boolean(locked || saving);
@@ -490,6 +530,8 @@ export function SettingsField(props: SettingsFieldProps) {
490530
htmlFor={labelFor}
491531
onReset={onReset}
492532
resetDisabled={disabled}
533+
writeScope={writeScope}
534+
describeDefault={describeDefault}
493535
/>
494536
{error && isValidElement(children)
495537
? cloneElement(children as ReactElement<Record<string, unknown>>, {
@@ -511,6 +553,20 @@ export function SettingsField(props: SettingsFieldProps) {
511553
return labels.optionLabel(spec.key, String(opt.value), literal);
512554
};
513555

556+
// The default a `Modified` row names. A secret's default is never shown,
557+
// and a value with no plain spelling (an object) is left unnamed.
558+
const describeDefault = (raw: unknown): string | null => {
559+
if (raw === null || raw === undefined || spec.type === 'password' || spec.encrypted) return null;
560+
if (typeof raw === 'boolean') return t(raw ? 'common.yes' : 'common.no');
561+
const optionText = (v: unknown): string => {
562+
const opt = spec.options?.find((o) => o.value === v);
563+
return opt ? renderOptionLabel(opt) : String(v);
564+
};
565+
if (Array.isArray(raw)) return raw.length > 0 ? raw.map(optionText).join(', ') : null;
566+
if (typeof raw === 'object') return null;
567+
return optionText(raw);
568+
};
569+
514570
switch (spec.type) {
515571
case 'text':
516572
case 'email':
@@ -556,7 +612,9 @@ export function SettingsField(props: SettingsFieldProps) {
556612
<Input
557613
id={id}
558614
type="number"
559-
value={value as any}
615+
// An unset value (a reset in the draft) is an empty box: React leaves
616+
// the last number on screen when `value` is `null`.
617+
value={value === null || value === undefined ? '' : (value as number | string)}
560618
min={spec.min}
561619
max={spec.max}
562620
step={spec.step}
@@ -577,6 +635,8 @@ export function SettingsField(props: SettingsFieldProps) {
577635
htmlFor={id}
578636
onReset={onReset}
579637
resetDisabled={disabled}
638+
writeScope={writeScope}
639+
describeDefault={describeDefault}
580640
/>
581641
{error ? (
582642
<FieldError id={errorId} message={error} />

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,9 @@ export function SettingsView() {
661661
saving={saving}
662662
labels={labels}
663663
error={key ? fieldErrors[key] : undefined}
664+
// The scope the server writes this key at: the specifier's
665+
// own, else the manifest's, else the spec's default `tenant`.
666+
writeScope={spec.scope ?? manifest.scope ?? 'tenant'}
664667
/>
665668
);
666669
})}

0 commit comments

Comments
 (0)