Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/10448-designer-invalid-not-published-flags.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,10 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
// 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',
Expand Down Expand Up @@ -2988,6 +2992,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
// `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': '选择图标',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
useAdapter: () => null,
// @object-ui/components wires this at module scope (related-count-store).
subscribeDataChanges: () => () => {},
}));
vi.mock('@object-ui/fields', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/fields')>()),
LookupField: () => <div data-testid="record-lookup" />,
}));
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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<FlowReferenceField
field={{ label: 'Tier', ref: { kind: 'org-membership-level' } }}
value="sales_manager"
onCommit={vi.fn()}
/>
</I18nProvider>,
);
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)');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<Select value={current || undefined} onValueChange={commitSelection} disabled={disabled}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#10448 — the flag the Hook inspector's object picker puts on a
* SELECTED object the live catalog does not list reads in the designer's
* locale.
*
* The picker keeps such an object on screen, flagged, so a draft-only or
* cross-package selection is never silently dropped. That flag was a
* hard-coded English template literal, so a zh-CN author read
* `VALUE (not published)` on an otherwise Chinese inspector. It now goes
* through `flagUnknownValue` with the catalogue's `engine.form.notPublished`,
* the device objectui#9652 landed for the designer's other unknown-value flags.
*
* `HookDefaultInspector` takes its locale as a prop, so each case passes
* `locale` and mounts no provider. The catalog ANSWERS here with one object
* that is not the selected one, and each case waits for that answer before
* reading the flagged row.
*
* - 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 { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

const state = vi.hoisted(() => ({
metadataClient: {
get: vi.fn(async () => undefined),
list: vi.fn(async () => [{ name: 'account', label: 'Account' }] as unknown[]),
},
}));
vi.mock('../useMetadata', () => ({
useMetadataClient: () => state.metadataClient,
}));

import { t } from '../i18n';
import { HookDefaultInspector } from './HookDefaultInspector';

afterEach(cleanup);

describe('engine.form.notPublished is a real row in both locales (non-vacuity guard)', () => {
it('is translated, not echoed and not English', () => {
const zh = t('engine.form.notPublished', 'zh-CN');
expect(zh, 'a missing zh row echoes the key back').not.toBe('engine.form.notPublished');
expect(zh, 'the zh row must not be the English one').not.toBe(t('engine.form.notPublished', 'en-US'));
expect(zh.startsWith('('), 'the zh flag opens with a full-width bracket').toBe(true);
});
});

describe('HookDefaultInspector — a selected object missing from the live catalog', () => {
async function mount(locale: 'en-US' | 'zh-CN') {
render(
<HookDefaultInspector
type="hook"
name="audit_hook"
draft={{ name: 'audit_hook', object: 'ghost_object', events: ['beforeInsert'] }}
onPatch={vi.fn()}
readOnly={false}
locale={locale}
/>,
);
// The catalog answered: the published object is on screen.
await screen.findByRole('checkbox', { name: 'Account (account)' });
}

it('zh-CN: the flag is the catalogue\'s "not published", after the value, unspaced', async () => {
await mount('zh-CN');
const flagged = `ghost_object${t('engine.form.notPublished', 'zh-CN')}`;
expect(screen.getByRole('checkbox', { name: flagged })).toBeTruthy();
expect(screen.queryByText('ghost_object (not published)')).toBeNull();
});

it('en-US: the wording is unchanged', async () => {
await mount('en-US');
expect(screen.getByRole('checkbox', { name: 'ghost_object (not published)' })).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
InspectorSelectField,
InspectorNumberField,
InspectorCheckboxField,
flagUnknownValue,
} from './_shared.js';
import { useObjectOptions } from '../previews/useObjectOptions.js';
import { ConditionBuilder, RECORD_CONDITION_SUBJECTS } from './ConditionBuilder.js';
Expand Down Expand Up @@ -126,14 +127,16 @@ export function HookDefaultInspector({

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

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

Expand Down
Loading