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
5 changes: 5 additions & 0 deletions .changeset/wild-pugs-smoke.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff 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.
*/

/**
* 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> = {}): ActionParamDef =>
({
name: 'env',
label: 'Environment',
type: 'select',
options: [
{ label: 'Production', value: 'prod' },
{ label: 'Staging', value: 'stage' },
],
...over,
}) as ActionParamDef;

const dialog = (over: Partial<ActionParamDef> = {}) => (
<ActionParamDialog
params={[selectParam(over)]}
open
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>
);

function renderIn(language: string, over: Partial<ActionParamDef> = {}) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{dialog(over)}
</I18nProvider>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
@@ -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
* `<I18nProvider config={{ defaultLanguage: 'zh' }}>`, 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> = {}): 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(
<ActionParamDialog
params={[selectParam()]}
open
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);

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(
<ActionParamDialog
params={[selectParam({ placeholder: 'Pick an environment' })]}
open
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);

expect(screen.getByText('Pick an environment')).toBeInTheDocument();
expect(screen.queryByText('Select…')).not.toBeInTheDocument();
});
});
58 changes: 57 additions & 1 deletion packages/components/src/custom/action-param-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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[];
Expand Down Expand Up @@ -59,6 +113,8 @@ export const ActionParamDialog: React.FC<ActionParamDialogProps> = ({
title = 'Action Parameters',
description = 'Please provide the required parameters.',
}) => {
const { t } = useSafeParamTranslation();

// Initialize values from defaultValues
const [values, setValues] = useState<Record<string, any>>(() => {
const initial: Record<string, any> = {};
Expand Down Expand Up @@ -195,7 +251,7 @@ export const ActionParamDialog: React.FC<ActionParamDialogProps> = ({
`button` is a labelable element, so the plain `htmlFor`
association is enough here; no `aria-labelledby` needed. */}
<SelectTrigger id={param.name} aria-required={ariaRequired}>
<SelectValue placeholder={param.placeholder || 'Select...'} />
<SelectValue placeholder={param.placeholder || t('common.select')} />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt) => (
Expand Down
Loading