Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit f94cfa5

Browse files
fix(app-shell): the membership-tier and hook-object flags read in the designer's locale (objectui#10448) (#10551)
Fixes #10448 Clause-②: no — two designer display strings move onto the catalogue; no export, schema or accept set moves. ## What changes Two designer unknown-value flags were English template literals. Both now go through `flagUnknownValue` (the objectui#9652 device in `inspectors/_shared.tsx`), so the `engine.form.flaggedValue` template decides the order of value and flag and the gap between them. | site | before | new catalogue row (en / zh) | locale channel | |:--|:--|:--|:--| | `FlowReferenceField.tsx`, `ReferenceCombobox` org-membership-level strict select, a stored tier outside the enum | `` `${current} (invalid)` `` | `engine.form.invalid` = `(invalid)` / `(无效)` | `useMetadataLocale()` (the control has no `locale` prop) | | `HookDefaultInspector.tsx`, object picker `pickerOptions`, a selected object the live catalog does not list | `` `${n} (not published)` `` | `engine.form.notPublished` = `(not published)` / `(未发布)` | the `locale` prop the inspector already receives | - The zh words are the catalogue's own: `engine.list.invalid` is `无效`, `engine.studio.unpublished` is `未发布`. Both zh flags open with a full-width bracket, as the comment above the zh flag rows requires. - en is byte-identical: `{value} {flag}` with the en flag rebuilds the pre-fix string exactly, and both en cases pin it as a literal. - `pickerOptions` re-derives on the `locale` primitive, not on the `tr` callback identity (AGENTS.md rule #10). - `metadata-admin/i18n.ts`: one EN hunk and one ZH hunk, each directly after that table's `engine.form.deprecated` row, inside the `engine.form.*` flag block. Both are hunk-disjoint from the open PRs objectui#10509 and objectui#10527, which also edit this file. - Changeset: `.changeset/10448-designer-invalid-not-published-flags.md` (`'@object-ui/app-shell': patch`). ## Pins: red on base, green on head New files, one beside each site: `inspectors/FlowReferenceField.flag.i18n-10448.test.tsx` and `inspectors/HookDefaultInspector.flag.i18n-10448.test.tsx`. Each has three cases: - a non-vacuity guard: the zh row is not the key, not the en row, and opens with a full-width bracket; - a zh case: the RENDERED text is the value followed directly by the catalogue's zh flag. FlowReferenceField mounts an `I18nProvider` in zh. HookDefaultInspector passes `locale` and waits for the object catalog to answer first; - an en control: the pre-fix literal, byte for byte. **Red on base `111fa4c94`** (only the two new test files added): `Tests 4 failed | 2 passed (6)`. The 2 passes are the en controls. The FlowReferenceField zh case failed with `Received: "sales_manager (invalid)"`. The Hook zh case found no checkbox with the zh-flagged name; the checkbox on screen was named `ghost_object (not published)`. Both guards were red (the key was echoed). **Green on head `e337f8c1e`**: those two files, the objectui#9652 flag suite and the seven existing FlowReferenceField / HookDefaultInspector suites gave `Test Files 10 passed (10)` and `Tests 75 passed (75)`. **Ablation on the committed head.** The catalogue rows stayed; only the site went back to its English literal. It ran through `ablation-replace.mjs`, which reported: anchor 1 to 0, blob changed, restored blob equal to HEAD, `git diff HEAD` empty. - FlowReferenceField: `Expected: "sales_manager(无效)"`, `Received: "sales_manager (invalid)"`, `Tests 1 failed | 2 passed (3)`. - HookDefaultInspector: no checkbox named `ghost_object(未发布)`; the rendered name was `ghost_object (not published)`. `Tests 1 failed | 2 passed (3)`. ## Local verification (head `e337f8c1e`) - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`), after building the `@object-ui/app-shell^...` closure: `VERDICT command-exit 0`. `tsc -p tsconfig.test.json --listFiles` lists both new test files. - Tests, narrowed and declared. The full `packages/app-shell/` run did not finish inside the foreground cap (killed at 570 s, exit 124), so it is NOT MEASURED locally; CI runs it. The narrowed set had 117 files: the whole `metadata-admin/inspectors/` directory; every test file outside it that names FlowReferenceField, HookDefaultInspector, FlowObjectListField, FlowNodeConfigField, ReferenceCombobox or default-inspector-registry; every test naming `metadata-admin/i18n`; the metadata-admin i18n suites; and the four `scripts/__tests__/check-i18n-*` suites. Result: `Test Files 117 passed (117)`, `Tests 1614 passed | 1 skipped (1615)`. - Gates, each exit 0: `check-changeset-presence` (5 source files of 1 released package, 1 changeset); `check:new-line-citations` (`VERDICT new-cross-file-line-citations: 0 new citation(s)`); `check:control-bytes` OK; `check:i18n-keys`; `check:i18n-drift` (0 en values changed, 2 keys added); `check:i18n-designer-parity` (every en row has a zh row with the same placeholders); `check:i18n-dead-keys`; `check:vi-mock-specifiers` / `-inherit` / `-override-shape` OK; `check:test-path-roots` OK; `check:changeset-claims` (report-only: 3 pending changesets name `i18n.ts`, and all three still hold); `check:pending-changeset-literals`; `check-changeset-no-major`; `check:designer-field-key-parity`; `check-type-check-coverage`; `check:unreferenced-sources`. - `check:eager-locale-catalogues`: PREREQUISITE NOT MET (it reads a built console bundle), so NOT MEASURED. - eslint on the five changed ts/tsx files (`--no-inline-config`, JSON output): 5 files, 0 errors, 10 warnings. All 10 are in `FlowReferenceField.tsx` and on lines this PR does not touch; the base blob of that file also gives 10. HookDefaultInspector.tsx gives 0 on base and on head. `eslint.config.js` sets no `parserOptions`, so linting is not type-aware and this diff cannot move the verdict on any untouched file. ## Acceptance notes - Out of scope, handed to the seat, not changed here: HookDefaultInspector's object picker flags a selected object `(not published)` while the object catalog is still loading, and again after that load fails, with no failure notice. A probe of the real component with the metadata client's `list` pending, and then rejected, rendered a checkbox named `account (not published)` both times. This is the same class as objectui#8862 / objectui#9651 (the in-flight and failed roster for `ViewColumnInspector`). This PR's Hook pin waits for the catalog to answer, so it does not pin that behaviour in either direction. - Out of scope: both inspectors still render other hard-coded English under zh, for example `All objects (*)`, `Pick at least one object (or All objects).`, `Select at least one event.` and the `manager` cell's `Resolved automatically`. A zh-CN probe confirmed all four. --- _Generated by [Claude Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93a689d commit f94cfa5

6 files changed

Lines changed: 210 additions & 4 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): two more designer unknown-value flags read in the designer's locale
6+
7+
Two designer pickers still wrote their unknown-value flag as an English template
8+
literal, so a zh-CN author read untranslated English on an otherwise Chinese
9+
inspector: the approver membership-tier select flagged a stored tier outside its
10+
vocabulary `(invalid)`, and the Hook inspector's object picker flagged a selected
11+
object the live catalog does not list `(not published)`.
12+
13+
Both flags now come from the designer's string catalogue through the same
14+
`engine.form.flaggedValue` template the other unknown-value flags use. Two rows
15+
sit beside `engine.form.notFound` and `engine.form.deprecated`:
16+
`engine.form.invalid` and `engine.form.notPublished`, each in en and zh. zh shows
17+
the value followed directly by its full-width-bracketed flag. The en-US text is
18+
unchanged.
19+
20+
The membership-tier select has no `locale` prop, so it follows the designer's
21+
active language through `useMetadataLocale()`. The Hook inspector uses the
22+
`locale` it already receives.

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,10 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
10141014
// full-width bracket its flags open with.
10151015
'engine.form.notFound': '(not found)',
10161016
'engine.form.deprecated': '(deprecated)',
1017+
// objectui#10448: a stored tier outside the org-membership-level enum, and a
1018+
// hook's selected object that the live catalog does not list.
1019+
'engine.form.invalid': '(invalid)',
1020+
'engine.form.notPublished': '(not published)',
10171021
'engine.form.flaggedValue': '{value} {flag}',
10181022
'engine.form.searchIcons': 'Search icons…',
10191023
'engine.form.chooseIcon': 'Choose an icon',
@@ -2998,6 +3002,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
29983002
// `flaggedValue`: every flag opens with a full-width bracket.
29993003
'engine.form.notFound': '(未找到)',
30003004
'engine.form.deprecated': '(已弃用)',
3005+
'engine.form.invalid': '(无效)',
3006+
'engine.form.notPublished': '(未发布)',
30013007
'engine.form.flaggedValue': '{value}{flag}',
30023008
'engine.form.searchIcons': '搜索图标…',
30033009
'engine.form.chooseIcon': '选择图标',
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#10448 — the flag the org-membership-tier select puts on a STORED
5+
* tier outside its vocabulary reads in the designer's locale.
6+
*
7+
* The strict select keeps a legacy out-of-enum tier on screen, flagged, so an
8+
* old row is never silently blanked. That flag was a hard-coded English
9+
* template literal, so a zh-CN author read `VALUE (invalid)` on an otherwise
10+
* Chinese inspector. It now goes through `flagUnknownValue` with the
11+
* catalogue's `engine.form.invalid`, the device objectui#9652 landed for the
12+
* designer's other unknown-value flags.
13+
*
14+
* `ReferenceCombobox` (the control `FlowReferenceField` wraps) has no `locale`
15+
* prop, so it reads `useMetadataLocale()`: each case mounts an `I18nProvider`
16+
* in the language under test and reads the RENDERED trigger text.
17+
*
18+
* - zh: the value followed directly by the catalogue's zh flag, no space. The
19+
* flag is read from the catalogue rather than retyped, and the guard below
20+
* proves it is a real zh row, so the case cannot pass on a missing key.
21+
* - en: the pre-fix wording, byte for byte, as a literal.
22+
*/
23+
24+
import * as React from 'react';
25+
import { describe, it, expect, vi, afterEach } from 'vitest';
26+
import { render, screen, cleanup } from '@testing-library/react';
27+
import { I18nProvider } from '@object-ui/i18n';
28+
29+
const state = vi.hoisted(() => {
30+
const metaList = vi.fn(async () => [] as unknown[]);
31+
return {
32+
// STABLE identity, like the real memoized client — a fresh `{ list }` per
33+
// render would setState → re-render → setState forever and hang the run.
34+
metadataClient: { list: metaList },
35+
};
36+
});
37+
38+
vi.mock('@object-ui/react', async (importOriginal) => ({
39+
...(await importOriginal<Record<string, unknown>>()),
40+
useAdapter: () => null,
41+
// @object-ui/components wires this at module scope (related-count-store).
42+
subscribeDataChanges: () => () => {},
43+
}));
44+
vi.mock('@object-ui/fields', async (importOriginal) => ({
45+
...(await importOriginal<typeof import('@object-ui/fields')>()),
46+
LookupField: () => <div data-testid="record-lookup" />,
47+
}));
48+
vi.mock('../useMetadata', () => ({
49+
useMetadataClient: () => state.metadataClient,
50+
}));
51+
vi.mock('../previews/useObjectFields', () => ({
52+
useObjectFields: () => ({ fields: [] }),
53+
}));
54+
55+
import { t } from '../i18n';
56+
import { FlowReferenceField } from './FlowReferenceField';
57+
58+
afterEach(cleanup);
59+
60+
describe('engine.form.invalid is a real row in both locales (non-vacuity guard)', () => {
61+
it('is translated, not echoed and not English', () => {
62+
const zh = t('engine.form.invalid', 'zh-CN');
63+
expect(zh, 'a missing zh row echoes the key back').not.toBe('engine.form.invalid');
64+
expect(zh, 'the zh row must not be the English one').not.toBe(t('engine.form.invalid', 'en-US'));
65+
expect(zh.startsWith('('), 'the zh flag opens with a full-width bracket').toBe(true);
66+
});
67+
});
68+
69+
describe('FlowReferenceField — a stored membership tier outside the enum', () => {
70+
function mount(language: 'en' | 'zh') {
71+
render(
72+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
73+
<FlowReferenceField
74+
field={{ label: 'Tier', ref: { kind: 'org-membership-level' } }}
75+
value="sales_manager"
76+
onCommit={vi.fn()}
77+
/>
78+
</I18nProvider>,
79+
);
80+
return screen.getByRole('combobox');
81+
}
82+
83+
it('zh: the flag is the catalogue\'s "invalid", after the value, unspaced', () => {
84+
expect(mount('zh').textContent).toBe(`sales_manager${t('engine.form.invalid', 'zh-CN')}`);
85+
});
86+
87+
it('en: the wording is unchanged', () => {
88+
expect(mount('en').textContent).toBe('sales_manager (invalid)');
89+
});
90+
});

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import { LookupField } from '@object-ui/fields';
4646
import type { FlowReferenceSpec, ReferenceKind, RefValueSource } from './flow-node-config.js';
4747
import { useMetadataClient } from '../useMetadata.js';
4848
import { useObjectFields } from '../previews/useObjectFields.js';
49+
import { t, useMetadataLocale } from '../i18n.js';
50+
import { flagUnknownValue } from './_shared.js';
4951

5052
/** Context the reference picker needs to resolve dynamic option sources. */
5153
export interface FlowReferenceContext {
@@ -613,6 +615,9 @@ export interface ReferenceComboboxProps {
613615
*/
614616
export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, disabled, placeholder, context, showHint = true }: ReferenceComboboxProps) {
615617
const listId = React.useId();
618+
// No `locale` prop here: the out-of-enum tier flag reads the designer's
619+
// locale itself, as the sibling `FlowObjectListField` does (objectui#10448).
620+
const locale = useMetadataLocale();
616621
const ctx: FlowReferenceContext = context ?? { draft: {}, node: null };
617622
const kind = resolved?.kind;
618623
// Picker-style commits have no blur event; default to commit-then-flush.
@@ -700,7 +705,7 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect,
700705
const current = value != null ? String(value) : '';
701706
const tiers = membershipLevelOptions(resolved?.source);
702707
const shown = current && !tiers.some((o) => o.value === current)
703-
? [...tiers, { value: current, label: `${current} (invalid)` }]
708+
? [...tiers, { value: current, label: flagUnknownValue(current, t('engine.form.invalid', locale), locale) }]
704709
: tiers;
705710
return (
706711
<Select value={current || undefined} onValueChange={commitSelection} disabled={disabled}>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#10448 — the flag the Hook inspector's object picker puts on a
5+
* SELECTED object the live catalog does not list reads in the designer's
6+
* locale.
7+
*
8+
* The picker keeps such an object on screen, flagged, so a draft-only or
9+
* cross-package selection is never silently dropped. That flag was a
10+
* hard-coded English template literal, so a zh-CN author read
11+
* `VALUE (not published)` on an otherwise Chinese inspector. It now goes
12+
* through `flagUnknownValue` with the catalogue's `engine.form.notPublished`,
13+
* the device objectui#9652 landed for the designer's other unknown-value flags.
14+
*
15+
* `HookDefaultInspector` takes its locale as a prop, so each case passes
16+
* `locale` and mounts no provider. The catalog ANSWERS here with one object
17+
* that is not the selected one, and each case waits for that answer before
18+
* reading the flagged row.
19+
*
20+
* - zh: the value followed directly by the catalogue's zh flag, no space. The
21+
* flag is read from the catalogue rather than retyped, and the guard below
22+
* proves it is a real zh row, so the case cannot pass on a missing key.
23+
* - en: the pre-fix wording, byte for byte, as a literal.
24+
*/
25+
26+
import { describe, it, expect, vi, afterEach } from 'vitest';
27+
import { render, screen, cleanup } from '@testing-library/react';
28+
29+
const state = vi.hoisted(() => ({
30+
metadataClient: {
31+
get: vi.fn(async () => undefined),
32+
list: vi.fn(async () => [{ name: 'account', label: 'Account' }] as unknown[]),
33+
},
34+
}));
35+
vi.mock('../useMetadata', () => ({
36+
useMetadataClient: () => state.metadataClient,
37+
}));
38+
39+
import { t } from '../i18n';
40+
import { HookDefaultInspector } from './HookDefaultInspector';
41+
42+
afterEach(cleanup);
43+
44+
describe('engine.form.notPublished is a real row in both locales (non-vacuity guard)', () => {
45+
it('is translated, not echoed and not English', () => {
46+
const zh = t('engine.form.notPublished', 'zh-CN');
47+
expect(zh, 'a missing zh row echoes the key back').not.toBe('engine.form.notPublished');
48+
expect(zh, 'the zh row must not be the English one').not.toBe(t('engine.form.notPublished', 'en-US'));
49+
expect(zh.startsWith('('), 'the zh flag opens with a full-width bracket').toBe(true);
50+
});
51+
});
52+
53+
describe('HookDefaultInspector — a selected object missing from the live catalog', () => {
54+
async function mount(locale: 'en-US' | 'zh-CN') {
55+
render(
56+
<HookDefaultInspector
57+
type="hook"
58+
name="audit_hook"
59+
draft={{ name: 'audit_hook', object: 'ghost_object', events: ['beforeInsert'] }}
60+
onPatch={vi.fn()}
61+
readOnly={false}
62+
locale={locale}
63+
/>,
64+
);
65+
// The catalog answered: the published object is on screen.
66+
await screen.findByRole('checkbox', { name: 'Account (account)' });
67+
}
68+
69+
it('zh-CN: the flag is the catalogue\'s "not published", after the value, unspaced', async () => {
70+
await mount('zh-CN');
71+
const flagged = `ghost_object${t('engine.form.notPublished', 'zh-CN')}`;
72+
expect(screen.getByRole('checkbox', { name: flagged })).toBeTruthy();
73+
expect(screen.queryByText('ghost_object (not published)')).toBeNull();
74+
});
75+
76+
it('en-US: the wording is unchanged', async () => {
77+
await mount('en-US');
78+
expect(screen.getByRole('checkbox', { name: 'ghost_object (not published)' })).toBeTruthy();
79+
});
80+
});

‎packages/app-shell/src/views/metadata-admin/inspectors/HookDefaultInspector.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
InspectorSelectField,
3535
InspectorNumberField,
3636
InspectorCheckboxField,
37+
flagUnknownValue,
3738
} from './_shared.js';
3839
import { useObjectOptions } from '../previews/useObjectOptions.js';
3940
import { ConditionBuilder, RECORD_CONDITION_SUBJECTS } from './ConditionBuilder.js';
@@ -126,14 +127,16 @@ export function HookDefaultInspector({
126127

127128
const { options: objectOptions } = useObjectOptions();
128129
// Preserve any selected object missing from the live catalog (draft-only /
129-
// cross-package) so it is never dropped from the picker.
130+
// cross-package) so it is never dropped from the picker. Its flag reads in
131+
// the designer's locale (objectui#10448).
130132
const pickerOptions = React.useMemo(() => {
131133
const known = new Set(objectOptions.map((o) => o.value));
134+
const notPublished = t('engine.form.notPublished', locale);
132135
const extra = objectNames
133136
.filter((n) => !known.has(n))
134-
.map((n) => ({ value: n, label: `${n} (not published)` }));
137+
.map((n) => ({ value: n, label: flagUnknownValue(n, notPublished, locale) }));
135138
return [...extra, ...objectOptions];
136-
}, [objectOptions, objectNames]);
139+
}, [objectOptions, objectNames, locale]);
137140

138141
const patchBody = (p: Record<string, unknown>) => onPatch({ body: { ...body, ...p } });
139142

0 commit comments

Comments
 (0)