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/console-chrome-i18n-5407.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@object-ui/components': minor
'@object-ui/fields': minor
'@object-ui/plugin-detail': patch
'@object-ui/i18n': patch
---

Console chrome i18n gaps (objectstack#5407).

- A dependency-gated lookup now names its controlling field by its **label**
instead of its raw API name. The sentence was localized but the interpolated
name was not, so every locale — English included — read `Select crm_account
first`. The form renderer passes a new `dependsOnLabels` widget prop (the
lookup-side counterpart of `emptyHint`, which it already resolves to labels
for the fixed-option widgets); a name the host does not cover still falls
back to itself.
- The page-header overflow trigger's `More actions` accessible name now reads
`detail.moreActions`, the same key `action:menu`'s own overflow trigger uses,
so the two cannot diverge per locale.
- The activity-feed reaction button's `Add reaction` accessible name is now a
bundle key (`detail.addReaction`, added to all ten packs).
- The "check the highlighted fields" toast joins field names with a per-locale
separator (`validation.formInvalidJoiner`) instead of a hardcoded `、`
(U+3001) — right for zh/ja by accident, wrong in English and every Latin
locale. Latin packs use `, `, CJK `、`, Arabic `، `.
- The Spanish `validation.required` / `validation.unique` templates gained
their own masculine head noun (`El campo {{field}} es obligatorio`) so the
adjective agrees for feminine field labels too — `Cuenta es obligatorio` was
ungrammatical.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
/**
* 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 page header's overflow trigger speaks the session locale —
* objectstack#5407.
*
* `page:header` collapses everything past `maxVisible` into a `⋯` button whose
* accessible name was the English literal "More actions". The button is
* icon-only, so that literal IS the button to a screen reader (and to a hover
* tooltip): under a zh/ja/es session it was the only English left in the
* header row.
*
* It now reads `detail.moreActions` — deliberately the SAME key `action:menu`'s
* own overflow trigger already used, not a new one. A record page can show both
* `⋯` buttons at once, and two keys would let them drift apart per locale.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { ActionProvider } from '@object-ui/react';
import { I18nProvider } from '@object-ui/i18n';
// Registers `page:header` at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. Importing it here also keeps this
// file out of the `heavyDomTests` list: it brings its own registration instead
// of depending on the full DOM setup to have run one
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010/#3021).
import '../renderers';

function PageHeader({ schema }: { schema: any }) {
const Component = ComponentRegistry.get('page:header');
if (!Component) throw new Error('page:header not registered');
// eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render
return <Component schema={schema} />;
}

/** Four header actions against the default `maxVisible` of 3 → one overflows. */
const schema = {
type: 'page:header',
title: 'Acme Corp',
actions: [
{ name: 'convert', locations: ['record_header'], label: 'Convert', type: 'flow' },
{ name: 'clone', locations: ['record_header'], label: 'Clone', type: 'flow' },
{ name: 'share', locations: ['record_header'], label: 'Share', type: 'flow' },
{ name: 'archive', locations: ['record_header'], label: 'Archive', type: 'flow' },
],
};

function renderHeaderIn(language: string) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<ActionProvider>
<PageHeader schema={schema} />
</ActionProvider>
</I18nProvider>,
);
}

afterEach(() => cleanup());

describe('page:header overflow trigger — accessible name (objectstack#5407)', () => {
it('reads the zh bundle value under a zh session', () => {
renderHeaderIn('zh');

expect(screen.getByRole('button', { name: '更多操作' })).toBeTruthy();
// The literal this replaced. Asserted negatively too: a re-inlined English
// string would still let the positive assertion pass if the header ever
// rendered two overflow triggers.
expect(screen.queryByRole('button', { name: 'More actions' })).toBeNull();
});

it('reads the ja bundle value under a ja session', () => {
renderHeaderIn('ja');

expect(screen.getByRole('button', { name: 'その他の操作' })).toBeTruthy();
});

it('still reads English under an en session', () => {
renderHeaderIn('en');

expect(screen.getByRole('button', { name: 'More actions' })).toBeTruthy();
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
/**
* 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 form renderer hands data-source widgets the LABELS of the sibling fields
* whose VALUES it already hands them — objectstack#5407.
*
* A dependency-gated lookup has to name its controlling field in user-visible
* copy ("Select Account first"), but the widget only ever sees `depends_on`,
* which holds API names. Only the form knows the label. It already resolves
* exactly this map for the fixed-option widgets (`emptyHint`); the lookup side
* had no equivalent, so the gate sentence interpolated `crm_account` into every
* locale, English included.
*
* This pins the PLUMBING (form → widget prop). The widget's own use of the map
* is pinned in `packages/fields`' `LookupField.gateHintLabel.test.tsx`; the two
* halves are asserted separately because `@object-ui/components` cannot import
* `@object-ui/fields` (that is the dependency direction, not a test shortcut).
*
* The strip half matters as much as the pass half: `stripRegisteredFieldProps`
* allow-lists these props precisely so an unknown object prop cannot reach a
* DOM node through a widget's `...props` spread — a React warning, and the
* reason `emptyHint` is allow-listed rather than passed unconditionally.
*/

import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../../../renderers';

/** Props each stub widget saw on its last render, keyed by registry type. */
const seen: Record<string, any> = {};

function makeStub(type: string) {
return function Stub(props: any) {
seen[type] = props;
return <div data-testid={`stub-${type}`} />;
};
}

beforeAll(() => {
// `field:lookup` is contributed by `@object-ui/fields`, which this package
// does not (and must not) depend on — so registering a stub here shadows
// nothing and leaks into no other suite.
ComponentRegistry.register('field:lookup', makeStub('lookup'));
ComponentRegistry.register('field:tags', makeStub('tags'));
});

afterEach(() => cleanup());

const fields = [
{ name: 'crm_account', label: 'Account', type: 'input' },
{
name: 'contact',
label: 'Contact',
type: 'lookup',
field: { name: 'contact', reference_to: 'crm_contact', depends_on: ['crm_account'] },
},
// A widget outside the data-source family, to pin the strip half.
{ name: 'topics', label: 'Topics', type: 'tags' },
];

function renderForm() {
const Form = ComponentRegistry.get('form')!;
return render(
<Form schema={{ type: 'form', showSubmit: false, showCancel: false, fields }} />,
);
}

describe('form renderer — dependsOnLabels plumbing (objectstack#5407)', () => {
it('passes a data-source widget the sibling field name → label map', () => {
renderForm();

expect(seen.lookup.dependsOnLabels).toEqual(
expect.objectContaining({ crm_account: 'Account', contact: 'Contact' }),
);
});

it('keys the map by API name, so the widget can resolve its own depends_on', () => {
renderForm();

// The exact lookup the widget performs. Written as the resolution rather
// than as a shape assertion, because the shape is only useful if this
// reads back the label.
const depends = 'crm_account';
expect(seen.lookup.dependsOnLabels[depends]).toBe('Account');
});

it('falls back to the field name for a field that declared no label', () => {
const Form = ComponentRegistry.get('form')!;
render(
<Form
schema={{
type: 'form',
showSubmit: false,
showCancel: false,
fields: [
{ name: 'crm_account', type: 'input' },
{
name: 'contact',
type: 'lookup',
field: { name: 'contact', depends_on: ['crm_account'] },
},
],
}}
/>,
);

// No label authored → the map still answers, with the name. The widget's
// own `|| d.field` fallback therefore never has to fire for a field the
// form knows about.
expect(seen.lookup.dependsOnLabels.crm_account).toBe('crm_account');
});

it('strips the map for widgets outside the data-source family', () => {
renderForm();

// `tags` spreads its leftover props onto a DOM node; an object-valued
// `dependsOnLabels` attribute there is a React warning.
expect(seen.tags).toBeDefined();
expect(seen.tags.dependsOnLabels).toBeUndefined();
});

it('strips the map before the BUILTIN branch reaches the DOM', () => {
// The builtin types (`input`/`textarea`/`checkbox`/`switch`/`select`) never
// pass through `stripRegisteredFieldProps` — they render their control
// directly and spread what is left onto it. `stripRendererOnlyProps` is the
// strip that covers them, and it was the one this prop was first missing:
// every plain text field on every form logged "React does not recognize the
// `dependsOnLabels` prop on a DOM element".
const errors: unknown[][] = [];
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
errors.push(args);
});
try {
const Form = ComponentRegistry.get('form')!;
const { container } = render(
<Form
schema={{
type: 'form',
showSubmit: false,
showCancel: false,
fields: [{ name: 'subject', label: 'Subject', type: 'input' }],
}}
/>,
);

const input = container.querySelector('input')!;
expect(input).not.toBeNull();
expect(input.getAttributeNames().map((n) => n.toLowerCase())).not.toContain(
'dependsonlabels',
);
expect(
errors.filter((e) => e.some((a) => String(a).includes('dependsOnLabels'))),
).toEqual([]);
} finally {
spy.mockRestore();
}
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
/**
* 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 invalid-submit toast joins the field names with a PER-LOCALE separator —
* objectstack#5407.
*
* `announceFieldErrors` used to build the list with a hardcoded `、` (U+3001).
* That is the CJK enumeration comma: correct for zh/ja by accident, wrong in
* every Latin locale, and most visibly wrong in English, where the toast read
*
* Please check the highlighted fields: Subject、Account、Status
*
* List punctuation is a property of the locale, not of the code, so the
* separator is now its own bundle entry (`validation.formInvalidJoiner`) that
* each pack declares. `Intl.ListFormat` was measured and rejected for this
* call site: its `unit` styles emit an EMPTY separator for zh and ru, and its
* `conjunction` styles splice in "and"/"和"/"y", which reads as a sentence
* rather than as the truncated list this is ("A, B, C…").
*
* The two locales asserted here are the two SIDES of the bug: `en` is the one
* that was visibly wrong, `zh` the one that was right by accident and must
* stay right now that the value is declared rather than assumed.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { I18nProvider } from '@object-ui/i18n';
import { toast } from '../../../ui/sonner';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../../../renderers';

let toastErrorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
toastErrorSpy = vi.spyOn(toast, 'error').mockImplementation(() => 'id' as any);
if (!(Element.prototype as any).scrollIntoView) {
(Element.prototype as any).scrollIntoView = () => {};
}
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

/** Three required fields, so the joiner appears twice and truncation does not. */
const fields = [
{ name: 'subject', label: 'Subject', type: 'input', required: true },
{ name: 'account', label: 'Account', type: 'input', required: true },
{ name: 'status', label: 'Status', type: 'input', required: true },
];

function renderFormIn(language: string) {
const Form = ComponentRegistry.get('form')!;
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<Form
schema={{
type: 'form',
mode: 'create',
showSubmit: true,
showCancel: false,
submitLabel: 'Create',
fields,
}}
/>
</I18nProvider>,
);
}

async function toastTextAfterSubmit(language: string): Promise<string> {
renderFormIn(language);
fireEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
return String(toastErrorSpy.mock.calls[0][0]);
}

describe('form renderer — invalid-submit toast list joiner (objectstack#5407)', () => {
it('joins with a comma+space under an en session, never the CJK comma', async () => {
const text = await toastTextAfterSubmit('en');

expect(text).toContain('Subject, Account, Status');
// The literal this replaced. Asserted negatively as well as positively so a
// re-hardcoded joiner cannot pass by rendering both forms somewhere.
expect(text).not.toContain('、');
});

it('still joins with the CJK comma under a zh session', async () => {
const text = await toastTextAfterSubmit('zh');

expect(text).toContain('Subject、Account、Status');
// zh's sentence, to prove the joiner is being read from the zh pack and not
// from an `en` fallback that happens to carry the same punctuation.
expect(text).toContain('请检查表单中标记的字段');
});
});
Loading
Loading