diff --git a/.changeset/wild-pugs-smoke.md b/.changeset/wild-pugs-smoke.md new file mode 100644 index 000000000..6c62857f1 --- /dev/null +++ b/.changeset/wild-pugs-smoke.md @@ -0,0 +1,5 @@ +--- +'@object-ui/components': patch +--- + +`ActionParamDialog`'s `select` branch no longer renders a hardcoded English `Select...` placeholder. The fallback used when an action param declares no `placeholder` of its own now reads the existing `common.select` pack key, so it is translated in all ten locales and carries the typographic ellipsis (U+2026) that #3878 converged the packs on. Authored `placeholder` metadata keeps priority, and no locale pack changed — the key was reused from `LookupField`'s identical select-trigger use. diff --git a/packages/components/src/__tests__/action-param-dialog-select-placeholder-i18n.test.tsx b/packages/components/src/__tests__/action-param-dialog-select-placeholder-i18n.test.tsx new file mode 100644 index 000000000..8cd43ee45 --- /dev/null +++ b/packages/components/src/__tests__/action-param-dialog-select-placeholder-i18n.test.tsx @@ -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. + */ + +/** + * ActionParamDialog (components/custom) — the `select` branch's placeholder + * fallback is a pack key, not a hardcoded English literal (objectui#4386). + * + * The branch rendered `param.placeholder || 'Select...'` behind a file that + * imported no translation hook at all, so the fallback — which fires whenever + * an action param of kind `select` declares no `placeholder` of its own, the + * ordinary case for hand-written action metadata — was English in every locale + * AND spelled its ellipsis in ASCII, which #3878 converged the ten packs away + * from. + * + * The fix reuses `common.select` (already in all ten packs, already consumed by + * `packages/fields`' `LookupField` for the same select-trigger job, already in + * `ellipsis-glyph-3878.test.ts`'s `CONVERGED_KEYS`), so no pack file changed. + * + * ## Reverse verification + * + * Restoring `|| 'Select...'` turns the zh and en-glyph cases below red — both, + * because both reach the fallback. The authored-value case stays green either + * way: it never reaches the fallback, and it is here as the surviving pin that + * authored metadata keeps priority over the pack word. + * + * The two ASCII-negative assertions are the glyph half of the defect on its + * own: a fix that translated the string but kept `...` would satisfy the zh + * case and still fail the en one. + * + * ── The provider-less fallback is a SEPARATE FILE ───────────────────────── + * `action-param-dialog-select-placeholder-no-provider.test.tsx`, for the reason + * `ObjectKanban.overlayTitleNoProviderFallback.test.tsx` documents: `createI18n` + * registers its instance as react-i18next's module-global default, and that + * registration survives unmount and `cleanup()`. Once any test in a file mounts + * a zh provider, every later "no provider" render in the SAME file resolves + * against the Chinese instance. Measured here first-hand — the case asserted + * `Select…` and rendered `选择…`. Do not add a provider-less case to this file. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import type { ActionParamDef } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +import { ActionParamDialog } from '../custom/action-param-dialog'; + +afterEach(cleanup); + +const selectParam = (over: Partial = {}): ActionParamDef => + ({ + name: 'env', + label: 'Environment', + type: 'select', + options: [ + { label: 'Production', value: 'prod' }, + { label: 'Staging', value: 'stage' }, + ], + ...over, + }) as ActionParamDef; + +const dialog = (over: Partial = {}) => ( + +); + +function renderIn(language: string, over: Partial = {}) { + return render( + + {dialog(over)} + , + ); +} + +describe('custom ActionParamDialog — select placeholder i18n (objectui#4386)', () => { + it('renders the locale word when the param declared no placeholder (zh)', () => { + renderIn('zh'); + + expect(screen.getByText('选择…')).toBeInTheDocument(); + // The defect verbatim: hardcoded English in an otherwise Chinese dialog. + expect(screen.queryByText('Select...')).not.toBeInTheDocument(); + }); + + it('renders the English pack value — with the typographic ellipsis — under en', () => { + renderIn('en'); + + // U+2026, not `...`: #3878's converged glyph, inherited from the pack. + expect(screen.getByText('Select…')).toBeInTheDocument(); + expect(screen.queryByText('Select...')).not.toBeInTheDocument(); + }); + + it('honours an authored placeholder verbatim, in any locale', () => { + renderIn('zh', { placeholder: 'Pick an environment' }); + + expect(screen.getByText('Pick an environment')).toBeInTheDocument(); + expect(screen.queryByText('选择…')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/__tests__/action-param-dialog-select-placeholder-no-provider.test.tsx b/packages/components/src/__tests__/action-param-dialog-select-placeholder-no-provider.test.tsx new file mode 100644 index 000000000..61e9eeacb --- /dev/null +++ b/packages/components/src/__tests__/action-param-dialog-select-placeholder-no-provider.test.tsx @@ -0,0 +1,110 @@ +/** + * 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. + */ + +/** + * `ActionParamDialog`'s select placeholder still resolves to ENGLISH, and to + * the same word the pack ships, when no `I18nProvider` is mounted — + * objectui#4386. + * + * Routing a literal through `t()` without a working default is exactly how a + * provider-less consumer breaks, and this dialog has provider-less consumers: + * embedded hosts render it without a provider, and this package's own + * `action-param-dialog-aria-required` / `action-param-dialog-label-association` + * suites mount it bare. The English default lives in the defaults map handed to + * `createSafeTranslation` in `action-param-dialog.tsx`, and it is byte-identical + * to `en`'s `common.select`. Without that entry this render would show the raw + * key `common.select`, which is precisely the regression this file catches. + * + * ── Measured reverse-verification direction ────────────────────────────── + * This file is RED before the fix and GREEN after. That is worth writing down + * because the obvious expectation for a no-provider FALLBACK pin is the other + * one — green both sides, since the English bytes are supposed to be unchanged + * — and it was the expectation this file was drafted with. It is wrong here for + * a reason specific to #4386: the literal being replaced was `Select...`, so + * the English bytes did NOT survive the fix unchanged. The ellipsis moved from + * ASCII to U+2026, and `getByText('Select…')` is what goes red on the revert. + * + * So the two assertions below fail on genuinely different mutations, and only + * the first is exercised by reverting the fix: + * - `getByText('Select…')` — red on the revert, on the GLYPH half. + * - `queryByText('common.select')` — the defaults-map pin proper. Reverting + * the fix does not move it (the pre-fix literal is not a raw key either); + * it goes red only if the defaults entry is dropped or misspelled, which is + * the regression this file exists to catch long after #4386. Measured by + * emptying the defaults map: the placeholder then renders the literal text + * `common.select` and this assertion fails. + * + * The translation half of the fix is asserted in + * `action-param-dialog-select-placeholder-i18n.test.tsx`. + * + * ── Why this is its own FILE, not a describe block ──────────────────────── + * `createI18n` calls `instance.use(initReactI18next)`, which registers that + * instance as react-i18next's module-global default, and the registration + * survives unmount and `cleanup()`. So the moment any test in a file mounts + * ``, every later "no provider" + * render in that same file silently resolves against the Chinese instance. + * Measured on this very case while writing it: the assertion expected `Select…` + * and the dialog rendered `选择…`. Same trap, same remedy, as + * `ObjectKanban.overlayTitleNoProviderFallback.test.tsx`. + * + * Vitest's `dom` project runs with `isolate: true`, so a file that never mounts + * a provider gets a genuinely clean global. Keep it that way: **do not import + * or mount `I18nProvider` here.** + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import type { ActionParamDef } from '@object-ui/core'; +import { ActionParamDialog } from '../custom/action-param-dialog'; + +afterEach(cleanup); + +const selectParam = (over: Partial = {}): ActionParamDef => + ({ + name: 'env', + label: 'Environment', + type: 'select', + options: [ + { label: 'Production', value: 'prod' }, + { label: 'Staging', value: 'stage' }, + ], + ...over, + }) as ActionParamDef; + +describe('custom ActionParamDialog — select placeholder, no I18nProvider (objectui#4386)', () => { + it('renders the English pack word from the defaults map, never the raw key', () => { + render( + , + ); + + expect(screen.getByText('Select…')).toBeInTheDocument(); + // The failure mode a missing defaults entry produces. + expect(screen.queryByText('common.select')).not.toBeInTheDocument(); + // The ellipsis half of #4386 survives on this path too. + expect(screen.queryByText('Select...')).not.toBeInTheDocument(); + }); + + it('still honours an authored placeholder with no provider', () => { + render( + , + ); + + expect(screen.getByText('Pick an environment')).toBeInTheDocument(); + expect(screen.queryByText('Select…')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/custom/action-param-dialog.tsx b/packages/components/src/custom/action-param-dialog.tsx index 52eaaa5b1..d27374931 100644 --- a/packages/components/src/custom/action-param-dialog.tsx +++ b/packages/components/src/custom/action-param-dialog.tsx @@ -11,6 +11,7 @@ import React, { useState, useCallback } from 'react'; import type { ActionParamDef } from '@object-ui/core'; +import { createSafeTranslation } from '@object-ui/i18n'; import { Dialog, DialogContent, @@ -32,6 +33,59 @@ import { SelectValue, } from '../ui/select'; +/** + * The `select` branch's placeholder — objectui#4386. + * + * ## Why a pack key, not a two-character glyph edit + * + * The branch used to render `param.placeholder || 'Select...'`, and that one + * literal carried two defects at once: it is hardcoded English (this file + * imported no translation hook at all, so a zh-CN user read `Select...` in an + * otherwise Chinese dialog — the #4024 family), and its ellipsis is ASCII, + * which #3878 converged the ten packs away from. Routing the fallback through + * a pack key fixes BOTH, because each locale's value carries whatever glyph + * that locale wants; re-spelling `...` to `…` in place would have fixed only + * the smaller half and left the English frozen in. + * + * ## Why `common.select`, and why no pack file changed + * + * REUSED, not added. `common.select` already ships in all ten packs + * (`Select…` / `选择…` / `Auswählen…` / …) and `packages/fields`' + * `LookupField` already consumes it for the structurally identical job — the + * placeholder of a Radix select trigger, behind the same authored-metadata-wins + * `authored?.placeholder || t(...)` shape this branch uses. One key, one + * wording, on both select-trigger paths. + * + * The near-miss candidate is `common.selectOption` (`Select an option` / + * `请选择`), which the FORM renderer's built-in select uses. It was rejected on + * two counts: it is a different sentence, so adopting it would silently rewrite + * this dialog's visible copy, and neither its `en` nor its `zh` value ends in an + * ellipsis — it would have dropped the very glyph half of the defect. A NEW key + * was not needed and would have collided with the #4375/#4376 pair in flight + * across the packs. + * + * Because `common.select` is already in `ellipsis-glyph-3878.test.ts`'s + * `CONVERGED_KEYS`, the U+2026 this site now renders is pinned by that gate for + * free, in every locale. + * + * ## Why the SAFE hook + * + * `createSafeTranslation`, not a bare `useObjectTranslation`: this dialog is + * rendered with no `I18nProvider` by embedded hosts and by this package's own + * bare-render tests (`action-param-dialog-aria-required`, + * `action-param-dialog-label-association` both mount it provider-less). With no + * provider the bare hook returns the raw KEY, so the placeholder would read + * `common.select`. The default below is the pack's stand-in on that path and is + * byte-identical to the `en` value, so a provider-less host renders `Select…` — + * the literal this replaced, modulo the glyph the card asked for. + */ +const useSafeParamTranslation = createSafeTranslation( + { 'common.select': 'Select…' }, + // Probe key: one this component itself consumes, so it cannot rot into + // pointing at a key no caller reads. + 'common.select', +); + export interface ActionParamDialogProps { /** The param definitions to render */ params: ActionParamDef[]; @@ -59,6 +113,8 @@ export const ActionParamDialog: React.FC = ({ title = 'Action Parameters', description = 'Please provide the required parameters.', }) => { + const { t } = useSafeParamTranslation(); + // Initialize values from defaultValues const [values, setValues] = useState>(() => { const initial: Record = {}; @@ -195,7 +251,7 @@ export const ActionParamDialog: React.FC = ({ `button` is a labelable element, so the plain `htmlFor` association is enough here; no `aria-labelledby` needed. */} - + {param.options?.map((opt) => (