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
29 changes: 29 additions & 0 deletions .changeset/selectfirst-gate-joiner-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@object-ui/components': patch
'@object-ui/fields': patch
---

The dependency-gate hint now enumerates its controlling fields with the locale's
own list separator, and reads identically whichever caller produced it.

`lookup.selectFirst` and `fields.options.selectFirst` are deliberately one
wording, so a field gated on two or more parents says the same thing whether the
lookup widget or the form renderer rendered it. The sentence was shared but its
`{{fields}}` slot was not: each call site joined the controlling-field names
with its own hardcoded separator, and not even the same one — `', '` in
`LookupField`, `' / '` in the form renderer's `gatedHint` and in
`OptionsEmptyState`. A field gated on Account and Lead Source read
`Select Account, Lead Source first` from one side and
`Select Account / Lead Source first` from the other.

A list separator is a property of the locale rather than of the code, so both
spellings were also wrong for the script under zh/ja (which enumerate with
U+3001) and under ar (U+060C). All three call sites now read
`validation.formInvalidJoiner` — the key already shipped in all ten packs for
the invalid-submit toast's field list, which is the same class of truncated-name
list. One key, every caller: a second, gate-specific key would have recreated
the divergence the shared sentence exists to prevent.

No locale pack changes, and no change to what a provider-less render produces in
English: the `@object-ui/fields` defaults table declares the joiner as `', '`,
the `en` pack's value and the literal `LookupField` previously hardcoded.
14 changes: 13 additions & 1 deletion packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1550,9 +1550,21 @@ ComponentRegistry.register('form',
// interpolates the controlling fields' LABELS, a standalone widget its
// raw metadata names — so the gate can never read differently depending
// on which side produced it.
//
// That invariant held for the sentence but not for its `{{fields}}`
// slot, which each caller filled with a hardcoded separator — `' / '`
// here, `', '` in the lookup's own gate — so a field gated on two
// parents still read two ways (objectui#4026). The separator is a
// property of the locale, so all of them now read
// `validation.formInvalidJoiner`: the key objectstack#5407 added for the
// invalid-submit toast a few hundred lines up, which is the same class
// of truncated-name list, rather than a second gate-only twin that would
// reintroduce the divergence.
const gatedHint = optionGroupGated
? t('fields.options.selectFirst', {
fields: dependsOnFields.map((fn) => fieldLabelByName[fn] || fn).join(' / '),
fields: dependsOnFields
.map((fn) => fieldLabelByName[fn] || fn)
.join(t('validation.formInvalidJoiner')),
})
: undefined;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The dependency-gate hint reads the SAME way whichever caller produced it, in
* every locale — objectui#4026.
*
* The gate sentence is deliberately shared: `lookup.selectFirst` and
* `fields.options.selectFirst` are one wording, and the form renderer's own
* comment says the two callers exist so "the gate can never read differently
* depending on which side produced it". That invariant held for the sentence
* and not for its `{{fields}}` slot, which each caller filled with a hardcoded
* separator — and not even the same one:
*
* LookupField Select Account, Lead Source first
* form renderer Select Account / Lead Source first
*
* A separator is a property of the LOCALE, not of the code (the finding behind
* `validation.formInvalidJoiner`, objectstack#5407), so both spellings were
* also wrong under zh/ja, which enumerate with U+3001, and under ar, which
* uses U+060C.
*
* Three call sites fill that slot, not two — `OptionsEmptyState` is the
* standalone-widget caller of `fields.options.selectFirst` that the form
* renderer's comment refers to, and it hardcoded `' / '` as well. Fixing only
* the two the card names would have left the shared sentence still reading two
* ways, so all three are asserted here together.
*
* The cross-caller assertion is EQUALITY rather than three copies of an
* expected string: a joiner that later changes wrongly-but-consistently is a
* translation bug, while one that changes inconsistently is this defect coming
* back. The per-script cases below cover the first half.
*
* This file lives in `@object-ui/fields` because it is the only package that
* can see both sides — fields depends on components, not the reverse.
*/

import type { ReactNode } from 'react';
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { I18nProvider } from '@object-ui/i18n';
// Module scope, NOT a `beforeAll` — a cold transform billed to `hookTimeout` is
// this repo's known flake generator (AGENTS.md 测试纪律 / objectui#3010). This
// import pulls in the form renderer's registration side effect.
import '@object-ui/components';

import { LookupField } from '../widgets/LookupField';
import { OptionsEmptyState } from '../widgets/OptionsEmptyState';

/** U+3001 IDEOGRAPHIC COMMA — the CJK list separator. */
const CJK_COMMA = '、';
/** U+060C ARABIC COMMA. */
const ARABIC_COMMA = '،';

/** The two controlling fields, named the way the user sees them on the form. */
const PARENT_A = { name: 'crm_account', label: 'Account' };
const PARENT_B = { name: 'lead_source', label: 'Lead Source' };
const PARENT_LABELS = { [PARENT_A.name]: PARENT_A.label, [PARENT_B.name]: PARENT_B.label };

/**
* Surfaces the `emptyHint` the FORM computed, verbatim. The gate hint reaches a
* registered option widget as that prop (objectui#3231), so this is
* `gatedHint`'s own output with no other widget's formatting in between.
*
* The value is RENDERED rather than captured into a module variable: writing to
* one during render is a side effect (`react-hooks/globals`), and a probe that
* renders what it received needs no such write. `String(...)` so a hint that
* failed to compute reads as `"undefined"` and fails the comparison loudly
* instead of comparing empty to empty.
*/
function EmptyHintProbe(props: any) {
return <div data-testid="form-gate-probe">{String(props.emptyHint)}</div>;
}

const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any;

beforeAll(() => {
// `field:select` belongs to this package's registry index, which this file
// deliberately does not import — so the probe shadows nothing.
ComponentRegistry.register('field:select', EmptyHintProbe, { namespace: 'test' });
}, 30000);

beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as any;
});

afterEach(() => cleanup());

const withLocale = (language: string, ui: ReactNode) => (
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{ui}
</I18nProvider>
);

/** `packages/fields` — the lookup widget's gate (`lookup.selectFirst`). */
function lookupGateHint(language: string): string {
render(
withLocale(
language,
<LookupField
field={
{
name: 'contact',
label: 'Contact',
reference_to: 'crm_contact',
reference_field: 'name',
depends_on: [PARENT_A.name, PARENT_B.name],
} as any
}
value={undefined}
onChange={vi.fn()}
readonly={false}
dataSource={dataSource}
{...({
dependentValues: { [PARENT_A.name]: null, [PARENT_B.name]: null },
dependsOnLabels: PARENT_LABELS,
} as any)}
/>,
),
);
// The `title` rather than the text content: it is the whole sentence and
// nothing else, so it can be compared for EQUALITY. Its agreement with the
// visible copy is pinned in `LookupField.gateHintLabel.test.tsx`.
const title = screen.getByTestId('lookup-trigger-gated').getAttribute('title');
cleanup();
return String(title);
}

/** `packages/components` — the form renderer's `gatedHint` memo. */
function formGateHint(language: string): string {
const Form = ComponentRegistry.get('form')!;
render(
withLocale(
language,
<Form
schema={{
type: 'form',
showSubmit: false,
showCancel: false,
fields: [
{ name: PARENT_A.name, label: PARENT_A.label, type: 'input', defaultValue: '' },
{ name: PARENT_B.name, label: PARENT_B.label, type: 'input', defaultValue: '' },
{
name: 'contact',
label: 'Contact',
// The prefixed id `mapFieldTypeToFormType` emits, so the field
// resolves through the registry and reaches the probe.
type: 'field:select',
dependsOn: [PARENT_A.name, PARENT_B.name],
options: [
{ label: 'Anything', value: 'a', visibleWhen: "record.crm_account == 'x'" },
],
},
],
}}
/>,
),
);
const hint = screen.getByTestId('form-gate-probe').textContent ?? '';
cleanup();
return hint;
}

/** `packages/fields` — the standalone option widget's own gate copy. */
function optionsEmptyStateGateHint(language: string): string {
// No `emptyHint`: this is the standalone path, where the widget owns the
// sentence. It interpolates raw metadata names because it has no host to
// resolve labels — which is what the form renderer's comment describes. The
// JOINER is what is compared, so both sides are handed the same name set.
const { container } = render(
withLocale(
language,
<OptionsEmptyState gated dependsOnFields={[PARENT_A.label, PARENT_B.label]} />,
),
);
const text = container.textContent ?? '';
cleanup();
return text;
}

/** zh/ja enumerate with U+3001, ar with U+060C, Latin scripts and ko with ", ". */
const LOCALES: Array<{ language: string; joiner: string }> = [
{ language: 'en', joiner: ', ' },
{ language: 'de', joiner: ', ' },
{ language: 'ko', joiner: ', ' },
{ language: 'zh', joiner: CJK_COMMA },
{ language: 'ja', joiner: CJK_COMMA },
{ language: 'ar', joiner: `${ARABIC_COMMA} ` },
];

describe('selectFirst gate hint — one joiner, every caller (objectui#4026)', () => {
it.each(LOCALES)(
'the lookup and the form produce the identical sentence under $language',
({ language }) => {
const fromLookup = lookupGateHint(language);
const fromForm = formGateHint(language);

// The defect, stated directly: same controlling fields, same locale, two
// different sentences.
expect(fromForm).toBe(fromLookup);
// Neither side is empty or a raw key — an equality that both failed the
// same way would otherwise pass.
expect(fromLookup).toContain(PARENT_A.label);
expect(fromLookup).toContain(PARENT_B.label);
},
);

it.each(LOCALES)(
'the standalone option widget joins the same way under $language',
({ language }) => {
// The third caller of `fields.options.selectFirst`, and the one the form
// renderer's comment names. It hardcoded `' / '` too.
expect(optionsEmptyStateGateHint(language)).toBe(formGateHint(language));
},
);

it.each(LOCALES)('enumerates with the separator declared by $language', ({ language, joiner }) => {
const sentence = lookupGateHint(language);

expect(sentence).toContain(`${PARENT_A.label}${joiner}${PARENT_B.label}`);
// The literal this replaced, asserted negatively so a re-inlined separator
// cannot pass by rendering the right thing elsewhere in the sentence.
// `' / '` appears in no pack's copy, in any locale.
expect(sentence).not.toContain(' / ');
// And where the pack does NOT use a comma+space, the other old literal
// must be gone too.
if (joiner !== ', ') {
expect(sentence).not.toContain(`${PARENT_A.label}, `);
}
});

it('does not leak the CJK comma into a Latin session, or a comma into zh', () => {
// The two SIDES of the locale defect, in the one place all callers meet.
expect(lookupGateHint('en')).not.toContain(CJK_COMMA);
expect(formGateHint('en')).not.toContain(CJK_COMMA);
expect(lookupGateHint('zh')).not.toContain(', ');
expect(formGateHint('zh')).not.toContain(', ');
});
});
16 changes: 14 additions & 2 deletions packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,23 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
* not merely an untranslated word. The host form supplies the name→label map
* (`dependsOnLabels`); a name it doesn't cover falls back to itself, so a
* standalone widget with no host renders exactly what it did before.
*
* The separator between the names is a LOCALE property, not a code constant
* (objectui#4026, the mechanism objectstack#5407 established for the
* invalid-submit toast). It used to be a hardcoded `', '` here while the
* form renderer's copy of this same gate hardcoded `' / '`, so one shared
* sentence read two different ways depending on which side produced it —
* and under zh/ja both spellings were wrong for the script. Every caller of
* the gate sentence now reads `validation.formInvalidJoiner`, the one
* already-shipped key for exactly this kind of truncated-name list.
*/
const dependsOnLabelsProp = props.dependsOnLabels;
const dependsOnFieldsText = useMemo(
() => dependsOn.map((d) => dependsOnLabelsProp?.[d.field] || d.field).join(', '),
[dependsOn, dependsOnLabelsProp],
() =>
dependsOn
.map((d) => dependsOnLabelsProp?.[d.field] || d.field)
.join(t('validation.formInvalidJoiner')),
[dependsOn, dependsOnLabelsProp, t],
);

// Resolve dependent field values from explicit prop or SchemaRendererContext.data
Expand Down
9 changes: 8 additions & 1 deletion packages/fields/src/widgets/OptionsEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,17 @@ export function OptionsEmptyState({
const { t } = useFieldTranslation();
// The host's hint when it computed one; otherwise this widget's own copy,
// translated. Never an English literal — that was the reported defect.
// The joiner between the controlling-field names is read from the locale
// pack, not hardcoded (objectui#4026). This site and the form renderer's
// `gatedHint` are the two callers of the SAME `fields.options.selectFirst`
// sentence, so a separator baked into either one is enough to make the
// shared sentence read two ways.
const hint =
emptyHint ||
(gated
? t('fields.options.selectFirst', { fields: dependsOnFields.join(' / ') })
? t('fields.options.selectFirst', {
fields: dependsOnFields.join(t('validation.formInvalidJoiner')),
})
: t('fields.options.empty'));
return (
<div
Expand Down
13 changes: 13 additions & 0 deletions packages/fields/src/widgets/useFieldTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ const FIELD_DEFAULTS: Record<string, string> = {
// renderer so both cannot drift apart in a locale.
'fields.options.empty': 'No options available',
'fields.options.selectFirst': 'Select {{fields}} first',
// objectui#4026 — the separator between the controlling-field names that
// fill `{{fields}}` of the two gate sentences above/below (`lookup.
// selectFirst`, `fields.options.selectFirst`). It is a LOCALE property, not
// a code constant: the call sites hardcoded it, and not even to the same
// value (`', '` in `LookupField`, `' / '` in `OptionsEmptyState` and the
// form renderer), so one deliberately-shared sentence read differently
// depending on which side produced it. Deliberately the SAME key
// objectstack#5407 added for the invalid-submit toast's field list rather
// than a gate-specific twin — it is the same class of truncated-name list,
// and a second key would recreate exactly the divergence being removed.
// The default here is the `en` pack's value, so a provider-less render is
// byte-identical to what `LookupField` produced before.
'validation.formInvalidJoiner': ', ',
// objectstack#3821 — sharing-rule authoring widgets (object-ref /
// recipient-picker / filter-condition). The recipient placeholder is keyed
// PER TYPE rather than interpolating the enum value into an English
Expand Down
Loading