From 14ee54a61dbeba4d1e128ccda6ec1f6ff87b9019 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 10:34:53 +0000 Subject: [PATCH 1/2] fix(components,fields,plugin-detail,i18n): console chrome i18n gaps (objectstack#5407) - lookup gate hint names the controlling field by LABEL, not API name - page-header overflow trigger reads detail.moreActions - reaction button reads detail.addReaction (new key, 10 packs) - formInvalid joins with a per-locale validation.formInvalidJoiner - es validation.required/unique carry their own head noun (gender agreement) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .changeset/console-chrome-i18n-5407.md | 29 +++++++++++++++++++ .../components/src/renderers/form/form.tsx | 26 +++++++++++++++-- .../src/renderers/layout/containers.tsx | 8 +++-- packages/fields/src/widgets/LookupField.tsx | 23 +++++++++++++-- packages/fields/src/widgets/types.ts | 14 +++++++++ packages/i18n/src/locales/ar.ts | 2 ++ packages/i18n/src/locales/de.ts | 2 ++ packages/i18n/src/locales/en.ts | 7 +++++ packages/i18n/src/locales/es.ts | 11 +++++-- packages/i18n/src/locales/fr.ts | 2 ++ packages/i18n/src/locales/ja.ts | 2 ++ packages/i18n/src/locales/ko.ts | 2 ++ packages/i18n/src/locales/pt.ts | 2 ++ packages/i18n/src/locales/ru.ts | 2 ++ packages/i18n/src/locales/zh.ts | 2 ++ packages/plugin-detail/src/ReactionPicker.tsx | 4 ++- .../plugin-detail/src/useDetailTranslation.ts | 4 +++ 17 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 .changeset/console-chrome-i18n-5407.md diff --git a/.changeset/console-chrome-i18n-5407.md b/.changeset/console-chrome-i18n-5407.md new file mode 100644 index 0000000000..385369e552 --- /dev/null +++ b/.changeset/console-chrome-i18n-5407.md @@ -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. diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index c6a70a0021..e801af5e80 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -128,6 +128,12 @@ const useSafeFormTranslation = createSafeTranslation( 'validation.email': 'Please enter a valid email address', 'validation.url': 'Please enter a valid URL', 'validation.formInvalid': 'Please check the highlighted fields: {{fields}}', + // objectstack#5407 — the joiner between the field names is a LOCALE + // property, not a code constant. It used to be a hardcoded `、` (U+3001), + // which is right for zh/ja and wrong everywhere else — including `en`, + // where the toast read "Subject、Account、Status". A locale that ships no + // entry falls back through i18next to `en`'s ", ". + 'validation.formInvalidJoiner': ', ', 'errors.forbidden': 'Access denied.', 'form.noPermissionToSave': "You don't have permission to save this record.", 'form.submitFailed': 'Could not save. Please try again.', @@ -282,6 +288,7 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende mobile_fullscreen: _mobileFullscreen, fullscreen: _fullscreen, dependentValues, + dependsOnLabels, emptyHint, // Retired from the widget contract in v17 (objectui#3233): `field` is the // single metadata carrier. The strip stays so an authored form field that @@ -294,7 +301,12 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende return { ...fieldProps, - ...(DATA_SOURCE_FIELD_TYPES.has(normalizedType) ? { dataSource, dependentValues } : {}), + // `dependsOnLabels` rides with `dependentValues`: the widgets that gate on + // a sibling field's VALUE are exactly the ones that have to NAME that field + // in the gate hint (objectstack#5407). Stripped for everything else for the + // same reason `emptyHint` is — an unknown object prop reaching a DOM node + // through a widget's `...props` spread is a React warning. + ...(DATA_SOURCE_FIELD_TYPES.has(normalizedType) ? { dataSource, dependentValues, dependsOnLabels } : {}), // The cascade option widgets own the gate hint's presentation, so they get // the computed `emptyHint` alongside the live record (objectui#3231). It is // stripped by default because every OTHER registered widget spreads its @@ -877,7 +889,9 @@ ComponentRegistry.register('form', setRejectedFieldNames(names); const labels = names.map((n) => fieldLabelByName[n] || n); const MAX = 3; - const fieldsText = labels.slice(0, MAX).join('、') + (labels.length > MAX ? '…' : ''); + const fieldsText = + labels.slice(0, MAX).join(t('validation.formInvalidJoiner')) + + (labels.length > MAX ? '…' : ''); toast.error(t('validation.formInvalid', { fields: fieldsText })); const errored = new Set(names); @@ -1410,6 +1424,14 @@ ComponentRegistry.register('form', // form, not read a stale record snapshot. Forwarded to // data-source widgets only (see stripRegisteredFieldProps). dependentValues: ruleRecord, + // Field name → label for the SAME sibling fields + // `dependentValues` carries the values of, so a gated lookup + // can say "Select Account first" instead of naming the raw + // API name (objectstack#5407). The whole form's map is passed + // (not a per-field slice) because the widget reads only the + // entries its own `depends_on` names, and one stable + // reference keeps the widget's memo deps from thrashing. + dependsOnLabels: fieldLabelByName, // The validation slot the widget props contract has declared // all along and nobody produced (objectui#3222). Spelled // `error` because that is what `@objectstack/spec/ui`'s diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index f179cb20ae..7f7ab37485 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -47,7 +47,7 @@ import { DropdownMenuItem, } from '../../ui'; import { RecordTitleChip } from '../../custom/RecordTitleChip'; -import { useObjectLabel, useSafeFieldLabel, useObjectTranslation, pickLocalized } from '@object-ui/i18n'; +import { useObjectLabel, useSafeFieldLabel, useObjectTranslation, useSafeTranslate, pickLocalized } from '@object-ui/i18n'; import { MoreHorizontal } from 'lucide-react'; /** @@ -875,6 +875,10 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { const { objectLabel: tObjectLabel, actionLabel: tActionLabel } = useObjectLabel(); const { fieldOptionLabel } = useSafeFieldLabel(); const { language } = useObjectTranslation(); + // Console-supplied chrome copy (objectstack#5407). `detail.moreActions` is + // the SAME key `action:menu`'s overflow trigger already reads, so the two + // `⋯` buttons a record page can show cannot read differently per locale. + const tt = useSafeTranslate(); // Spec bridge may either inline `properties.*` onto the node or preserve // the raw bag (see record:quick_actions for the same pattern). Read from // both so a `{ properties: { title } }` schema is rendered correctly. @@ -1204,7 +1208,7 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { variant="outline" size="sm" className="gap-1 px-2" - aria-label="More actions" + aria-label={tt('detail.moreActions', 'More actions')} > diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index ad3369c86a..1deadce319 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -280,6 +280,23 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro return []; }, [fieldMeta?.depends_on, fieldMeta?.dependsOn]); + /** + * The gate sentence's `{{fields}}` — the controlling fields named the way the + * user sees them on the form, not the way the metadata spells them. + * + * `depends_on` holds API names, and this used to interpolate them straight + * into the sentence, so every locale — `en` included — read "Select + * crm_account first" (objectstack#5407): an internal identifier in the UI, + * 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. + */ + const dependsOnLabelsProp = props.dependsOnLabels; + const dependsOnFieldsText = useMemo( + () => dependsOn.map((d) => dependsOnLabelsProp?.[d.field] || d.field).join(', '), + [dependsOn, dependsOnLabelsProp], + ); + // Resolve dependent field values from explicit prop or SchemaRendererContext.data const dependentValuesProp = props.dependentValues; @@ -931,7 +948,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro disabled={dependenciesMissing || props.disabled} data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((props.name || lookupField?.name) ? `lookup-trigger-${props.name || lookupField.name}` : 'lookup-trigger')} title={dependenciesMissing - ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') }) + ? t('lookup.selectFirst', { fields: dependsOnFieldsText }) : undefined} // AFTER the spread so this widget's own computation wins (#3222): // `fieldError` is the published validation slot — NOT the popover's @@ -948,7 +965,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro )} {dependenciesMissing - ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') }) + ? t('lookup.selectFirst', { fields: dependsOnFieldsText }) : hydrating // The value EXISTS but its labels are still loading — say so // instead of the empty placeholder (#3108). @@ -1230,7 +1247,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro onClick={() => setIsPickerOpen(true)} aria-label={t('lookup.browseAll')} title={dependenciesMissing - ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') }) + ? t('lookup.selectFirst', { fields: dependsOnFieldsText }) : t('lookup.browseAll')} data-testid="browse-all-records" > diff --git a/packages/fields/src/widgets/types.ts b/packages/fields/src/widgets/types.ts index cdfea8c77d..5c35d7d885 100644 --- a/packages/fields/src/widgets/types.ts +++ b/packages/fields/src/widgets/types.ts @@ -184,6 +184,20 @@ export type FieldWidgetComponentProps = { * a field it synthesised. Field metadata wins when both are present. */ dependsOn?: DependsOnInput; + /** + * Controlling-field name → its human LABEL, for widgets that have to NAME a + * sibling field in user-visible copy (the dependent-lookup gate hint). + * + * Only the form renderer knows a field's label — the widget sees its own + * metadata and a `depends_on` list of API names. Without this map the gate + * sentence interpolated the raw API name into every locale, `en` included + * ("Select crm_account first"), which is an internal identifier leaking into + * the UI, not merely an untranslated word (objectstack#5407). This is the + * lookup-side counterpart of `emptyHint`, which the form already resolves to + * labels for the fixed-option widgets. A name with no entry falls back to + * itself, so a host that passes nothing renders exactly what it did before. + */ + dependsOnLabels?: Record; /** * Hint shown when an option list cannot be filled — typically a * dependency-gated list still waiting on its controlling field (#2284). diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 94f3f4cbd8..78017f8026 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -103,6 +103,7 @@ const ar = { url: "يرجى إدخال رابط صالح", pattern: "صيغة {{field}} غير صالحة", formInvalid: "يرجى التحقق من الحقول المميزة: {{fields}}", + formInvalidJoiner: "، ", unique: "{{field}} يجب أن يكون فريداً", type: "{{field}} يجب أن يكون {{type}} صالحاً", }, @@ -729,6 +730,7 @@ const ar = { viewHistory: "عرض السجل", delete: "حذف", moreActions: "المزيد من الإجراءات", + addReaction: "إضافة تفاعل", addToFavorites: "إضافة إلى المفضلة", removeFromFavorites: "إزالة من المفضلة", previousRecord: "السجل السابق", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index ef6feb1bfe..01c1eb8cd9 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -103,6 +103,7 @@ const de = { url: "Bitte geben Sie eine gültige URL ein", pattern: "{{field}} hat ein ungültiges Format", formInvalid: "Bitte überprüfen Sie die markierten Felder: {{fields}}", + formInvalidJoiner: ", ", unique: "{{field}} muss eindeutig sein", type: "{{field}} muss ein gültiger {{type}} sein", }, @@ -727,6 +728,7 @@ const de = { viewHistory: "Verlauf anzeigen", delete: "Löschen", moreActions: "Weitere Aktionen", + addReaction: "Reaktion hinzufügen", addToFavorites: "Zu Favoriten hinzufügen", removeFromFavorites: "Aus Favoriten entfernen", previousRecord: "Vorheriger Datensatz", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index cc3e48d531..2b16e5ce1e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -109,6 +109,12 @@ const en = { url: 'Please enter a valid URL', pattern: '{{field}} format is invalid', formInvalid: 'Please check the highlighted fields: {{fields}}', + // Separator between the field names interpolated into `formInvalid`. + // Per-locale because list punctuation is a locale property, not a code + // constant: CJK enumerates with U+3001, Latin scripts with a comma+space, + // Arabic with U+060C. Hardcoding one of them in the renderer put the CJK + // comma into the English toast (objectstack#5407). + formInvalidJoiner: ', ', unique: '{{field}} must be unique', type: '{{field}} must be a valid {{type}}', }, @@ -771,6 +777,7 @@ const en = { viewHistory: 'View history', delete: 'Delete', moreActions: 'More actions', + addReaction: 'Add reaction', addToFavorites: 'Add to favorites', removeFromFavorites: 'Remove from favorites', previousRecord: 'Previous record', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 57e16f8e3e..232037ef00 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -94,7 +94,12 @@ const es = { }, }, validation: { - required: "{{field}} es obligatorio", + // "{{field}} es obligatorio" only agreed with masculine field labels — + // "Cuenta es obligatorio" is wrong (objectstack#5407). Spanish adjectives + // agree with the head noun, which a runtime label cannot declare, so the + // template supplies its own masculine head ("el campo") and the adjective + // agrees with THAT for every label. + required: "El campo {{field}} es obligatorio", minLength: "{{field}} debe tener al menos {{min}} caracteres", maxLength: "{{field}} debe tener como máximo {{max}} caracteres", min: "{{field}} debe ser al menos {{min}}", @@ -103,7 +108,8 @@ const es = { url: "Por favor, introduzca una URL válida", pattern: "El formato de {{field}} no es válido", formInvalid: "Por favor, revise los campos resaltados: {{fields}}", - unique: "{{field}} debe ser único", + formInvalidJoiner: ", ", + unique: "El campo {{field}} debe ser único", type: "{{field}} debe ser un {{type}} válido", }, form: { @@ -727,6 +733,7 @@ const es = { viewHistory: "Ver historial", delete: "Eliminar", moreActions: "Más acciones", + addReaction: "Añadir reacción", addToFavorites: "Añadir a favoritos", removeFromFavorites: "Quitar de favoritos", previousRecord: "Registro anterior", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 0bb66e6155..c4b80e027e 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -103,6 +103,7 @@ const fr = { url: "Veuillez saisir une URL valide", pattern: "Le format de {{field}} est invalide", formInvalid: "Veuillez vérifier les champs en surbrillance : {{fields}}", + formInvalidJoiner: ", ", unique: "{{field}} doit être unique", type: "{{field}} doit être un {{type}} valide", }, @@ -729,6 +730,7 @@ const fr = { viewHistory: "Voir l'historique", delete: "Supprimer", moreActions: "Plus d'actions", + addReaction: "Ajouter une réaction", addToFavorites: "Ajouter aux favoris", removeFromFavorites: "Retirer des favoris", previousRecord: "Enregistrement précédent", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index f409225f0e..035d54ae22 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -103,6 +103,7 @@ const ja = { url: "有効なURLを入力してください", pattern: "{{field}}の形式が正しくありません", formInvalid: "ハイライトされた項目を確認してください: {{fields}}", + formInvalidJoiner: "、", unique: "{{field}}は一意である必要があります", type: "{{field}}は有効な{{type}}である必要があります", }, @@ -727,6 +728,7 @@ const ja = { viewHistory: "履歴を表示", delete: "削除", moreActions: "その他の操作", + addReaction: "リアクションを追加", addToFavorites: "お気に入りに追加", removeFromFavorites: "お気に入りから削除", previousRecord: "前のレコード", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 333ff3757b..e74aa2c4eb 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -103,6 +103,7 @@ const ko = { url: "유효한 URL을 입력해주세요", pattern: "{{field}} 형식이 올바르지 않습니다", formInvalid: "표시된 필드를 확인해 주세요: {{fields}}", + formInvalidJoiner: ", ", unique: "{{field}}은(는) 고유해야 합니다", type: "{{field}}은(는) 유효한 {{type}}이어야 합니다", }, @@ -727,6 +728,7 @@ const ko = { viewHistory: "기록 보기", delete: "삭제", moreActions: "더 많은 작업", + addReaction: "반응 추가", addToFavorites: "즐겨찾기에 추가", removeFromFavorites: "즐겨찾기에서 제거", previousRecord: "이전 레코드", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index e20c4d4472..cf63fe8836 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -103,6 +103,7 @@ const pt = { url: "Por favor, insira uma URL válida", pattern: "O formato de {{field}} é inválido", formInvalid: "Verifique os campos destacados: {{fields}}", + formInvalidJoiner: ", ", unique: "{{field}} deve ser único", type: "{{field}} deve ser um {{type}} válido", }, @@ -729,6 +730,7 @@ const pt = { viewHistory: "Ver histórico", delete: "Excluir", moreActions: "Mais ações", + addReaction: "Adicionar reação", addToFavorites: "Adicionar aos favoritos", removeFromFavorites: "Remover dos favoritos", previousRecord: "Registro anterior", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 123de7970a..559fe913c7 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -103,6 +103,7 @@ const ru = { url: "Пожалуйста, введите корректный URL", pattern: "Неверный формат поля {{field}}", formInvalid: "Проверьте выделенные поля: {{fields}}", + formInvalidJoiner: ", ", unique: "{{field}} должно быть уникальным", type: "{{field}} должно быть допустимым {{type}}", }, @@ -729,6 +730,7 @@ const ru = { viewHistory: "Просмотр истории", delete: "Удалить", moreActions: "Другие действия", + addReaction: "Добавить реакцию", addToFavorites: "Добавить в избранное", removeFromFavorites: "Удалить из избранного", previousRecord: "Предыдущая запись", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 712b34c68e..c325a83ad2 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -108,6 +108,7 @@ const zh = { url: '请输入有效的URL', pattern: '{{field}}格式不正确', formInvalid: '请检查表单中标记的字段:{{fields}}', + formInvalidJoiner: '、', unique: '{{field}}必须唯一', type: '{{field}}必须是有效的{{type}}', }, @@ -777,6 +778,7 @@ const zh = { viewHistory: '查看历史', delete: '删除', moreActions: '更多操作', + addReaction: '添加表情回应', addToFavorites: '添加到收藏', removeFromFavorites: '从收藏中移除', previousRecord: '上一条记录', diff --git a/packages/plugin-detail/src/ReactionPicker.tsx b/packages/plugin-detail/src/ReactionPicker.tsx index 36da80c577..d089087ff4 100644 --- a/packages/plugin-detail/src/ReactionPicker.tsx +++ b/packages/plugin-detail/src/ReactionPicker.tsx @@ -10,6 +10,7 @@ import * as React from 'react'; import { cn, Button } from '@object-ui/components'; import { SmilePlus } from 'lucide-react'; import type { Reaction } from '@object-ui/types'; +import { useDetailTranslation } from './useDetailTranslation'; const DEFAULT_EMOJI_OPTIONS = ['👍', '❤️', '🎉', '😂', '😮', '😢']; @@ -35,6 +36,7 @@ export const ReactionPicker: React.FC = ({ className, }) => { const [showPicker, setShowPicker] = React.useState(false); + const { t } = useDetailTranslation(); const handleReaction = React.useCallback( (emoji: string) => { @@ -74,7 +76,7 @@ export const ReactionPicker: React.FC = ({ size="icon" className="h-6 w-6" onClick={() => setShowPicker(!showPicker)} - aria-label="Add reaction" + aria-label={t('detail.addReaction')} > diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index a7ae262736..7486a2daec 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -43,6 +43,10 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.viewHistory': 'View history', 'detail.delete': 'Delete', 'detail.moreActions': 'More actions', + // objectstack#5407 — the activity-feed reaction button's accessible name. + // It is icon-only, so this label IS the button as far as a screen reader + // (or a hover tooltip) is concerned; it used to be an English literal. + 'detail.addReaction': 'Add reaction', 'detail.addToFavorites': 'Add to favorites', 'detail.removeFromFavorites': 'Remove from favorites', 'detail.previousRecord': 'Previous record', From 61ec00fcbfa8541e75933eb594b1a2f84051acad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 10:42:03 +0000 Subject: [PATCH 2/2] test(components,fields,i18n,plugin-detail): pin the objectstack#5407 chrome i18n fixes Also strips `dependsOnLabels` in `stripRendererOnlyProps`: the builtin field branch spreads leftover props straight onto its control, so every plain input logged "React does not recognize the `dependsOnLabels` prop on a DOM element" until the strip covered it. Pinned by the new builtin-branch test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../page-header-more-actions-i18n.test.tsx | 89 ++++++++++ .../__tests__/form-depends-on-labels.test.tsx | 168 ++++++++++++++++++ .../form-invalid-toast-joiner.test.tsx | 105 +++++++++++ .../components/src/renderers/form/form.tsx | 5 + .../LookupField.gateHintLabel.test.tsx | 135 ++++++++++++++ ...lidation-list-joiner-locale-parity.test.ts | 88 +++++++++ .../src/ReactionPicker.i18n.test.tsx | 65 +++++++ 7 files changed, 655 insertions(+) create mode 100644 packages/components/src/__tests__/page-header-more-actions-i18n.test.tsx create mode 100644 packages/components/src/renderers/form/__tests__/form-depends-on-labels.test.tsx create mode 100644 packages/components/src/renderers/form/__tests__/form-invalid-toast-joiner.test.tsx create mode 100644 packages/fields/src/widgets/LookupField.gateHintLabel.test.tsx create mode 100644 packages/i18n/src/__tests__/validation-list-joiner-locale-parity.test.ts create mode 100644 packages/plugin-detail/src/ReactionPicker.i18n.test.tsx diff --git a/packages/components/src/__tests__/page-header-more-actions-i18n.test.tsx b/packages/components/src/__tests__/page-header-more-actions-i18n.test.tsx new file mode 100644 index 0000000000..e09eb71142 --- /dev/null +++ b/packages/components/src/__tests__/page-header-more-actions-i18n.test.tsx @@ -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 ; +} + +/** 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( + + + + + , + ); +} + +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(); + }); +}); diff --git a/packages/components/src/renderers/form/__tests__/form-depends-on-labels.test.tsx b/packages/components/src/renderers/form/__tests__/form-depends-on-labels.test.tsx new file mode 100644 index 0000000000..75cb2e8d42 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-depends-on-labels.test.tsx @@ -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 = {}; + +function makeStub(type: string) { + return function Stub(props: any) { + seen[type] = props; + return
; + }; +} + +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( +
, + ); +} + +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( + , + ); + + // 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( + , + ); + + 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(); + } + }); +}); diff --git a/packages/components/src/renderers/form/__tests__/form-invalid-toast-joiner.test.tsx b/packages/components/src/renderers/form/__tests__/form-invalid-toast-joiner.test.tsx new file mode 100644 index 0000000000..14dcd8f5b9 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-invalid-toast-joiner.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. + */ + +/** + * 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; + +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( + + + , + ); +} + +async function toastTextAfterSubmit(language: string): Promise { + 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('请检查表单中标记的字段'); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index e801af5e80..59c1de8d33 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -232,6 +232,11 @@ function stripRendererOnlyProps>(props: T): T { fullscreen: _fullscreen, dependentValues: _dependentValues, dependsOn: _dependsOn, + // objectstack#5407 — the sibling name→label map is for widgets that NAME a + // controlling field in their own copy (the lookup gate hint). No builtin + // branch renders such copy, and an object-valued prop reaching a DOM node + // is a React warning, so it is stripped here exactly like `dependentValues`. + dependsOnLabels: _dependsOnLabels, emptyHint: _emptyHint, // The validation message (objectui#3222) is for REGISTERED widgets, which // need it to put `aria-invalid` on the control they render. The builtin diff --git a/packages/fields/src/widgets/LookupField.gateHintLabel.test.tsx b/packages/fields/src/widgets/LookupField.gateHintLabel.test.tsx new file mode 100644 index 0000000000..38653c2292 --- /dev/null +++ b/packages/fields/src/widgets/LookupField.gateHintLabel.test.tsx @@ -0,0 +1,135 @@ +/** + * 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. + */ + +/** + * A gated lookup names its controlling field by LABEL — objectstack#5407. + * + * The gate sentence (`lookup.selectFirst`) is translated in all ten packs, but + * its `{{fields}}` interpolation came straight off `depends_on`, which holds + * API names. So the rendered hint was localized around a raw identifier in + * every locale, English included: + * + * en Select crm_account first + * zh-CN 请先选择crm_account + * + * The `en` row is why this is not merely an i18n bug: an English user was shown + * an internal identifier. The form renderer knows the label (it already + * resolves the same map for the fixed-option widgets' `emptyHint`) and now + * hands it over as `dependsOnLabels`. + * + * All three gated surfaces are asserted — the trigger's visible text, the + * trigger's `title`, and the "browse all" button's `title` — because they were + * three independent copies of the same expression and a fix applied to one of + * them looks identical from the outside. + */ + +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { LookupField } from './LookupField'; + +/** Mirrors the reported metadata: the parent field's API name IS an object-ish + * name (`crm_account`), which is exactly what made the leak so legible. */ +const gatedField = { + name: 'contact', + label: 'Contact', + reference_to: 'crm_contact', + reference_field: 'name', + depends_on: ['crm_account'], +} as any; + +const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any; + +function renderGated(extra: Record) { + return render( + , + ); +} + +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()); + +describe('LookupField gate hint names the controlling field (objectstack#5407)', () => { + it('interpolates the label the host supplied, not the API name', () => { + renderGated({ dependsOnLabels: { crm_account: 'Account' } }); + + const trigger = screen.getByTestId('lookup-trigger-gated'); + expect(trigger).toHaveTextContent('Select Account first'); + // The identifier must be gone from the copy entirely, not merely + // accompanied by the label. + expect(trigger).not.toHaveTextContent('crm_account'); + expect(trigger).toHaveAttribute('title', 'Select Account first'); + }); + + it('labels the gated "browse all" button the same way', () => { + renderGated({ dependsOnLabels: { crm_account: 'Account' } }); + + const browse = screen.getByTestId('browse-all-records'); + expect(browse).toBeDisabled(); + expect(browse).toHaveAttribute('title', 'Select Account first'); + }); + + it('joins several controlling fields by their labels', () => { + render( + , + ); + + expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent( + 'Select Account, Lead Source first', + ); + }); + + it('falls back to the API name for a field the host did not map', () => { + // Standalone use (no host form) has to keep rendering what it rendered + // before — the map is an enrichment, not a new requirement. This is also + // the assertion that would go red if `dependsOnLabels` were consulted for + // its VALUES rather than keyed by field name. + renderGated({ dependsOnLabels: { some_other_field: 'Irrelevant' } }); + + expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent( + 'Select crm_account first', + ); + }); + + it('falls back to the API name when the host passes no map at all', () => { + renderGated({}); + + expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent( + 'Select crm_account first', + ); + }); +}); diff --git a/packages/i18n/src/__tests__/validation-list-joiner-locale-parity.test.ts b/packages/i18n/src/__tests__/validation-list-joiner-locale-parity.test.ts new file mode 100644 index 0000000000..747fcad392 --- /dev/null +++ b/packages/i18n/src/__tests__/validation-list-joiner-locale-parity.test.ts @@ -0,0 +1,88 @@ +/** + * 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. + */ + +/** + * `validation.formInvalidJoiner` — objectstack#5407. + * + * `all-locales-key-parity` guarantees every pack DEFINES the key. It cannot + * catch the defect this key exists to fix, which was about the VALUE: the + * renderer hardcoded `、` (U+3001) for every locale, so the English toast read + * "Subject、Account、Status". A pack that back-fills the entry by copying en's + * `", "` into zh would satisfy parity and re-break Chinese, so the script + * families are asserted here by hand. + * + * `Intl.ListFormat` would have needed no entries at all and was measured + * first — it is rejected because its `unit` styles emit an EMPTY separator for + * zh and ru (worse than the bug) and its `conjunction` styles splice in + * "and"/"和"/"y", which does not belong in a truncated "A, B, C…" list. + */ +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '../locales'; + +const JOINER = 'formInvalidJoiner'; +/** U+3001 IDEOGRAPHIC COMMA — the CJK list separator. */ +const CJK_COMMA = '、'; +/** U+060C ARABIC COMMA. */ +const ARABIC_COMMA = '،'; + +const validationOf = (lang: string) => + (builtInLocales as Record)[lang].validation as Record; + +describe('validation.formInvalidJoiner is declared per locale (objectstack#5407)', () => { + it('every built-in pack declares a non-empty joiner', () => { + const langs = Object.keys(builtInLocales); + expect(langs.length).toBeGreaterThanOrEqual(10); + for (const lang of langs) { + const joiner = validationOf(lang)[JOINER]; + expect(typeof joiner, `${lang} joiner type`).toBe('string'); + expect(joiner.length, `${lang} joiner is empty`).toBeGreaterThan(0); + } + }); + + it('CJK packs enumerate with U+3001', () => { + for (const lang of ['zh', 'ja']) { + expect(validationOf(lang)[JOINER], lang).toBe(CJK_COMMA); + } + }); + + it('Latin-script and Korean packs enumerate with a comma + space', () => { + // Korean sits here rather than with zh/ja: it uses the ASCII comma. + for (const lang of ['en', 'es', 'de', 'fr', 'pt', 'ru', 'ko']) { + expect(validationOf(lang)[JOINER], lang).toBe(', '); + } + }); + + it('the Arabic pack enumerates with U+060C', () => { + expect(validationOf('ar')[JOINER]).toBe(`${ARABIC_COMMA} `); + }); + + it('no pack outside zh/ja carries the CJK comma — the exact shipped defect', () => { + const offenders = Object.keys(builtInLocales).filter( + (lang) => !['zh', 'ja'].includes(lang) && validationOf(lang)[JOINER].includes(CJK_COMMA), + ); + expect(offenders).toEqual([]); + }); +}); + +describe('es validation templates agree in gender (objectstack#5407)', () => { + // "{{field}} es obligatorio" only agreed when the field label was masculine — + // "Cuenta es obligatorio" is wrong. The template now supplies its own + // masculine head noun so the adjective agrees with THAT, whatever the label. + it('required and unique carry their own head noun', () => { + const es = validationOf('es'); + expect(es.required).toBe('El campo {{field}} es obligatorio'); + expect(es.unique).toBe('El campo {{field}} debe ser único'); + }); + + it('neither template starts with the bare interpolation', () => { + const es = validationOf('es'); + for (const key of ['required', 'unique']) { + expect(es[key].startsWith('{{field}}'), `es.validation.${key}`).toBe(false); + } + }); +}); diff --git a/packages/plugin-detail/src/ReactionPicker.i18n.test.tsx b/packages/plugin-detail/src/ReactionPicker.i18n.test.tsx new file mode 100644 index 0000000000..9941a4bd74 --- /dev/null +++ b/packages/plugin-detail/src/ReactionPicker.i18n.test.tsx @@ -0,0 +1,65 @@ +/** + * 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 activity-feed reaction button speaks the session locale — + * objectstack#5407. + * + * `ReactionPicker`'s add button is icon-only (`SmilePlus`), so its + * `aria-label` IS the button as far as a screen reader or a hover tooltip is + * concerned. It was the English literal "Add reaction" in every locale; it now + * reads `detail.addReaction`, backfilled into all ten packs. + * + * The no-provider case is asserted alongside the translated one because + * `useDetailTranslation` is a `createSafeTranslation` hook: its whole point is + * that a component rendered without an `I18nProvider` gets the English default + * rather than the raw key, and "aria-label reads `detail.addReaction`" is a + * failure mode that no positive assertion under a provider would catch. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ReactionPicker } from './ReactionPicker'; + +afterEach(() => cleanup()); + +function renderPickerIn(language: string) { + return render( + + + , + ); +} + +describe('ReactionPicker add button — accessible name (objectstack#5407)', () => { + it('reads the zh bundle value under a zh session', () => { + renderPickerIn('zh'); + + expect(screen.getByRole('button', { name: '添加表情回应' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Add reaction' })).toBeNull(); + }); + + it('reads the es bundle value under an es session', () => { + renderPickerIn('es'); + + expect(screen.getByRole('button', { name: 'Añadir reacción' })).toBeTruthy(); + }); + + it('still reads English under an en session', () => { + renderPickerIn('en'); + + expect(screen.getByRole('button', { name: 'Add reaction' })).toBeTruthy(); + }); + + it('never renders the raw key', () => { + renderPickerIn('en'); + + expect(screen.queryByRole('button', { name: 'detail.addReaction' })).toBeNull(); + }); +});