diff --git a/.changeset/10448-designer-invalid-not-published-flags.md b/.changeset/10448-designer-invalid-not-published-flags.md new file mode 100644 index 0000000000..ff7495e18c --- /dev/null +++ b/.changeset/10448-designer-invalid-not-published-flags.md @@ -0,0 +1,22 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): two more designer unknown-value flags read in the designer's locale + +Two designer pickers still wrote their unknown-value flag as an English template +literal, so a zh-CN author read untranslated English on an otherwise Chinese +inspector: the approver membership-tier select flagged a stored tier outside its +vocabulary `(invalid)`, and the Hook inspector's object picker flagged a selected +object the live catalog does not list `(not published)`. + +Both flags now come from the designer's string catalogue through the same +`engine.form.flaggedValue` template the other unknown-value flags use. Two rows +sit beside `engine.form.notFound` and `engine.form.deprecated`: +`engine.form.invalid` and `engine.form.notPublished`, each in en and zh. zh shows +the value followed directly by its full-width-bracketed flag. The en-US text is +unchanged. + +The membership-tier select has no `locale` prop, so it follows the designer's +active language through `useMetadataLocale()`. The Hook inspector uses the +`locale` it already receives. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 09a3e76b70..b8eb668426 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1010,6 +1010,10 @@ const ENGINE_STRINGS_EN: Record = { // full-width bracket its flags open with. 'engine.form.notFound': '(not found)', 'engine.form.deprecated': '(deprecated)', + // objectui#10448: a stored tier outside the org-membership-level enum, and a + // hook's selected object that the live catalog does not list. + 'engine.form.invalid': '(invalid)', + 'engine.form.notPublished': '(not published)', 'engine.form.flaggedValue': '{value} {flag}', 'engine.form.searchIcons': 'Search icons…', 'engine.form.chooseIcon': 'Choose an icon', @@ -2988,6 +2992,8 @@ const ENGINE_STRINGS_ZH: Record = { // `flaggedValue`: every flag opens with a full-width bracket. 'engine.form.notFound': '(未找到)', 'engine.form.deprecated': '(已弃用)', + 'engine.form.invalid': '(无效)', + 'engine.form.notPublished': '(未发布)', 'engine.form.flaggedValue': '{value}{flag}', 'engine.form.searchIcons': '搜索图标…', 'engine.form.chooseIcon': '选择图标', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.flag.i18n-10448.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.flag.i18n-10448.test.tsx new file mode 100644 index 0000000000..3f228a5a33 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.flag.i18n-10448.test.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10448 — the flag the org-membership-tier select puts on a STORED + * tier outside its vocabulary reads in the designer's locale. + * + * The strict select keeps a legacy out-of-enum tier on screen, flagged, so an + * old row is never silently blanked. That flag was a hard-coded English + * template literal, so a zh-CN author read `VALUE (invalid)` on an otherwise + * Chinese inspector. It now goes through `flagUnknownValue` with the + * catalogue's `engine.form.invalid`, the device objectui#9652 landed for the + * designer's other unknown-value flags. + * + * `ReferenceCombobox` (the control `FlowReferenceField` wraps) has no `locale` + * prop, so it reads `useMetadataLocale()`: each case mounts an `I18nProvider` + * in the language under test and reads the RENDERED trigger text. + * + * - zh: the value followed directly by the catalogue's zh flag, no space. The + * flag is read from the catalogue rather than retyped, and the guard below + * proves it is a real zh row, so the case cannot pass on a missing key. + * - en: the pre-fix wording, byte for byte, as a literal. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; + +const state = vi.hoisted(() => { + const metaList = vi.fn(async () => [] as unknown[]); + return { + // STABLE identity, like the real memoized client — a fresh `{ list }` per + // render would setState → re-render → setState forever and hang the run. + metadataClient: { list: metaList }, + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => null, + // @object-ui/components wires this at module scope (related-count-store). + subscribeDataChanges: () => () => {}, +})); +vi.mock('@object-ui/fields', async (importOriginal) => ({ + ...(await importOriginal()), + LookupField: () =>
, +})); +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => state.metadataClient, +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [] }), +})); + +import { t } from '../i18n'; +import { FlowReferenceField } from './FlowReferenceField'; + +afterEach(cleanup); + +describe('engine.form.invalid is a real row in both locales (non-vacuity guard)', () => { + it('is translated, not echoed and not English', () => { + const zh = t('engine.form.invalid', 'zh-CN'); + expect(zh, 'a missing zh row echoes the key back').not.toBe('engine.form.invalid'); + expect(zh, 'the zh row must not be the English one').not.toBe(t('engine.form.invalid', 'en-US')); + expect(zh.startsWith('('), 'the zh flag opens with a full-width bracket').toBe(true); + }); +}); + +describe('FlowReferenceField — a stored membership tier outside the enum', () => { + function mount(language: 'en' | 'zh') { + render( + + + , + ); + return screen.getByRole('combobox'); + } + + it('zh: the flag is the catalogue\'s "invalid", after the value, unspaced', () => { + expect(mount('zh').textContent).toBe(`sales_manager${t('engine.form.invalid', 'zh-CN')}`); + }); + + it('en: the wording is unchanged', () => { + expect(mount('en').textContent).toBe('sales_manager (invalid)'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index e4eef20cb2..0eeaa2af59 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -46,6 +46,8 @@ import { LookupField } from '@object-ui/fields'; import type { FlowReferenceSpec, ReferenceKind, RefValueSource } from './flow-node-config.js'; import { useMetadataClient } from '../useMetadata.js'; import { useObjectFields } from '../previews/useObjectFields.js'; +import { t, useMetadataLocale } from '../i18n.js'; +import { flagUnknownValue } from './_shared.js'; /** Context the reference picker needs to resolve dynamic option sources. */ export interface FlowReferenceContext { @@ -613,6 +615,9 @@ export interface ReferenceComboboxProps { */ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, disabled, placeholder, context, showHint = true }: ReferenceComboboxProps) { const listId = React.useId(); + // No `locale` prop here: the out-of-enum tier flag reads the designer's + // locale itself, as the sibling `FlowObjectListField` does (objectui#10448). + const locale = useMetadataLocale(); const ctx: FlowReferenceContext = context ?? { draft: {}, node: null }; const kind = resolved?.kind; // Picker-style commits have no blur event; default to commit-then-flush. @@ -700,7 +705,7 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, const current = value != null ? String(value) : ''; const tiers = membershipLevelOptions(resolved?.source); const shown = current && !tiers.some((o) => o.value === current) - ? [...tiers, { value: current, label: `${current} (invalid)` }] + ? [...tiers, { value: current, label: flagUnknownValue(current, t('engine.form.invalid', locale), locale) }] : tiers; return (