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
13 changes: 13 additions & 0 deletions .changeset/tags-placeholder-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@object-ui/fields": patch
"@object-ui/i18n": patch
---

`TagsField` no longer ships a hardcoded Chinese input placeholder
(objectui#3342, AGENTS.md Commandment #-1). The placeholder now resolves
through the pinned chain: the author-declared `field.placeholder` wins
(previously ignored by this widget); otherwise the widget's own copy arrives
via `useFieldTranslation()` under the new `fields.tags.placeholder` key, added
at full parity across all locale packs (Chinese lives in the zh pack, not in
code); with no `I18nProvider` mounted the English default from FIELD_DEFAULTS
renders — never a raw key.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* 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 tags widget's placeholder with NO `I18nProvider` mounted
* (objectui#3342) — standalone/embedded usage, and every test in the repo
* that renders a field widget bare.
*
* Routing the copy through `useFieldTranslation()` must not turn it into a
* raw i18n key when nothing is configured: `createSafeTranslation` probes the
* instance and falls back to its English defaults map. This is also where the
* Commandment #-1 pin lives: the copy shipped FROM CODE (the no-provider
* default) carries no CJK — Chinese exists only in the zh locale pack and can
* only arrive through a provider.
*
* Why a separate FILE rather than another case in
* `TagsField.placeholder.test.tsx`: mounting `I18nProvider` calls
* `initReactI18next`, which installs that instance as react-i18next's GLOBAL
* default. Once any sibling test has done so there is no "no provider" state
* left to observe in that module graph. Vitest's per-file isolation is what
* makes this file's observation honest (same split as
* `OptionsEmptyState.no-provider.test.tsx`).
*/
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { TagsField } from './TagsField';

function renderTags(field: Record<string, unknown>) {
return render(
<TagsField
value={[]}
onChange={vi.fn()}
field={field as any}
{...({ name: 'labels' } as any)}
/>,
);
}

const input = () => screen.getByRole('textbox');

describe('TagsField placeholder — English fallback survives with no i18n configured', () => {
it('renders the English default, not a raw key', () => {
renderTags({ name: 'labels', type: 'tags' });
expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…');
expect(input().getAttribute('placeholder')).not.toContain('fields.tags');
});

it('the code-shipped default carries no CJK (Commandment #-1)', () => {
renderTags({ name: 'labels', type: 'tags' });
// With no provider mounted, whatever renders here came from CODE
// (FIELD_DEFAULTS) — the commandment pin: no Chinese in the codebase.
// Escaped ranges on purpose: a literal CJK class would itself violate
// the commandment this test enforces (same shape as objectui#3321's pin).
expect(input().getAttribute('placeholder')).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/);
});

it('the author-declared field.placeholder still wins without a provider', () => {
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' });
expect(input()).toHaveAttribute('placeholder', 'Add product labels');
});
});
95 changes: 95 additions & 0 deletions packages/fields/src/widgets/TagsField.placeholder.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* 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 tags widget's input placeholder (objectui#3342).
*
* It used to be a hardcoded Chinese literal in code — a double violation:
* Commandment #-1 (English-only codebase) AND a bypass of the i18n channel the
* sibling widgets already use. The pinned resolution chain is:
*
* 1. `field.placeholder` — the field author's declaration always wins;
* 2. `t('fields.tags.placeholder')` — the widget's own copy, from the
* locale packs via `useFieldTranslation()`;
* 3. the English default in FIELD_DEFAULTS when no I18nProvider is mounted
* (that leg lives in `TagsField.placeholder.no-provider.test.tsx` — see
* its header for why it must be a separate file).
*
* Chinese now lives in the zh locale pack only, which is what the zh-locale
* case below observes end-to-end.
*/
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { TagsField } from './TagsField';

function renderTags(
field: Record<string, unknown>,
{ value = [] as string[], language }: { value?: string[]; language?: string } = {},
) {
const element = (
<TagsField
value={value}
onChange={vi.fn()}
field={field as any}
{...({ name: 'labels' } as any)}
/>
);
return render(
language ? (
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
{element}
</I18nProvider>
) : (
element
),
);
}

const input = () => screen.getByRole('textbox');

describe('TagsField placeholder — author declaration beats the widget copy (objectui#3342)', () => {
it('renders the author-declared field.placeholder', () => {
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'en' });
expect(input()).toHaveAttribute('placeholder', 'Add product labels');
});

it('the author declaration wins even under a non-English locale', () => {
// The author's copy is theirs verbatim — a locale switch must not
// overwrite an explicit declaration with the widget's own translation.
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'zh' });
expect(input()).toHaveAttribute('placeholder', 'Add product labels');
expect(input().getAttribute('placeholder')).not.toContain('输入后回车添加');
});
});

describe('TagsField placeholder — undeclared falls back to the translated key', () => {
it('renders the en pack copy under the en locale', () => {
renderTags({ name: 'labels', type: 'tags' }, { language: 'en' });
expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…');
});

it('renders the zh pack copy under the zh locale — Chinese comes from the pack, not code', () => {
renderTags({ name: 'labels', type: 'tags' }, { language: 'zh' });
expect(input()).toHaveAttribute('placeholder', '输入后回车添加…');
// …and never the raw key.
expect(input().getAttribute('placeholder')).not.toContain('fields.tags');
});
});

describe('TagsField placeholder — shown only while the tag list is empty', () => {
it('is empty once a tag exists (the input is a continuation strip)', () => {
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, {
value: ['alpha'],
language: 'en',
});
expect(input()).toHaveAttribute('placeholder', '');
});
});
13 changes: 12 additions & 1 deletion packages/fields/src/widgets/TagsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import { Badge, Input, EmptyValue, cn } from '@object-ui/components';
import { FieldWidgetComponentProps } from './types';
import { toDomProps } from './toDomProps';
import { useFieldTranslation } from './useFieldTranslation';

/**
* TagsField - free-form list of string tags. Type a value and press Enter (or
Expand All @@ -11,6 +12,8 @@ import { toDomProps } from './toDomProps';
export function TagsField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps<string[]>) {
const tags: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
const [draft, setDraft] = React.useState('');
// Unconditional hook call (rules-of-hooks): before the readonly early return.
const { t } = useFieldTranslation();

if (readonly) {
if (tags.length === 0) return <EmptyValue />;
Expand Down Expand Up @@ -64,7 +67,15 @@ export function TagsField({ value, onChange, field, readonly, className, error,
onKeyDown={onKeyDown}
onBlur={() => addTag(draft)}
disabled={props.disabled}
placeholder={tags.length === 0 ? '输入后回车添加…' : ''}
// Placeholder chain (objectui#3342): the author-declared
// `field.placeholder` wins; otherwise the widget's own copy arrives
// via `useFieldTranslation()` — `fields.tags.placeholder` in the
// locale packs, English default from FIELD_DEFAULTS when no
// I18nProvider is mounted. This used to be a hardcoded Chinese
// literal (Commandment #-1); Chinese now lives in the zh pack only.
// Shown only while the list is empty — once a tag exists the input is
// a small continuation strip and the hint would be noise.
placeholder={tags.length === 0 ? field?.placeholder || t('fields.tags.placeholder') : ''}
className="h-7 flex-1 border-0 bg-transparent p-0 px-1 shadow-none focus-visible:ring-0 min-w-[8ch]"
// AFTER the spread so this widget's own computation wins (the #3222
// discipline): `error` is the published validation slot, and
Expand Down
3 changes: 3 additions & 0 deletions packages/fields/src/widgets/useFieldTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ const FIELD_DEFAULTS: Record<string, string> = {
'fields.filterCondition.jsonOnly': 'This criteria can only be edited as JSON',
'fields.filterCondition.editAsJson': 'Edit as JSON',
'fields.filterCondition.useVisualBuilder': 'Use visual builder',
// objectui#3342 — the tags widget's input hint. Used only when the field
// author declared no `placeholder` of their own (author declaration wins).
'fields.tags.placeholder': 'Type and press Enter to add…',
// objectui#2600 B5 — capability picker scope group headers.
'capability.group.platform': 'Platform',
'capability.group.org': 'Organization',
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ const ar = {
editAsJson: "التحرير بصيغة JSON",
useVisualBuilder: "استخدام المُنشئ المرئي",
},
tags: {
placeholder: "اكتب واضغط Enter للإضافة…",
},
},
table: {
rowsPerPage: "صفوف في الصفحة",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ const de = {
editAsJson: "Als JSON bearbeiten",
useVisualBuilder: "Visuellen Builder verwenden",
},
tags: {
placeholder: "Tippen und mit der Eingabetaste hinzufügen…",
},
},
table: {
rowsPerPage: "Zeilen pro Seite",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ const en = {
editAsJson: 'Edit as JSON',
useVisualBuilder: 'Use visual builder',
},
// objectui#3342 — the tags widget's input hint, shown while the tag list
// is empty. The author-declared `field.placeholder` always wins over this.
tags: {
placeholder: 'Type and press Enter to add…',
},
},
table: {
rowsPerPage: 'Rows per page',
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ const es = {
editAsJson: "Editar como JSON",
useVisualBuilder: "Usar el constructor visual",
},
tags: {
placeholder: "Escriba y pulse Intro para añadir…",
},
},
table: {
rowsPerPage: "Filas por página",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ const fr = {
editAsJson: "Modifier en JSON",
useVisualBuilder: "Utiliser le constructeur visuel",
},
tags: {
placeholder: "Saisissez puis appuyez sur Entrée pour ajouter…",
},
},
table: {
rowsPerPage: "Lignes par page",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ const ja = {
editAsJson: "JSON で編集",
useVisualBuilder: "ビジュアルビルダーを使用",
},
tags: {
placeholder: "入力してEnterキーで追加…",
},
},
table: {
rowsPerPage: "1ページの行数",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ const ko = {
editAsJson: "JSON으로 편집",
useVisualBuilder: "비주얼 빌더 사용",
},
tags: {
placeholder: "입력 후 Enter 키로 추가…",
},
},
table: {
rowsPerPage: "페이지당 행 수",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ const pt = {
editAsJson: "Editar como JSON",
useVisualBuilder: "Usar o construtor visual",
},
tags: {
placeholder: "Digite e pressione Enter para adicionar…",
},
},
table: {
rowsPerPage: "Linhas por página",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ const ru = {
editAsJson: "Редактировать как JSON",
useVisualBuilder: "Использовать визуальный конструктор",
},
tags: {
placeholder: "Введите и нажмите Enter, чтобы добавить…",
},
},
table: {
rowsPerPage: "Строк на странице",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ const zh = {
editAsJson: '以 JSON 编辑',
useVisualBuilder: '使用可视化构建器',
},
tags: {
placeholder: '输入后回车添加…',
},
},
table: {
rowsPerPage: '每页行数',
Expand Down
Loading