Skip to content

Commit 38fde39

Browse files
authored
Merge pull request #1131 from objectstack-ai/copilot/fix-chinese-language-pack-issues
2 parents 92eb366 + 2a2ca18 commit 38fde39

File tree

23 files changed

+243
-21
lines changed

23 files changed

+243
-21
lines changed

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- **Chinese language pack (zh.ts) untranslated key** (`@object-ui/i18n`): Fixed `console.objectView.toolbarEnabledCount` which was still in English (`'{{count}} of {{total}} enabled'`) — now properly translated to `'已启用 {{count}}/{{total}} 项'`. Also fixed the same untranslated key in all other 8 non-English locales (ja, ko, de, fr, es, pt, ru, ar).
13+
14+
- **Hardcoded English strings in platform UI** (`apps/console`, `@object-ui/fields`, `@object-ui/react`, `@object-ui/components`): Replaced hardcoded English strings with i18n `t()` calls:
15+
- Console `App.tsx`: Modal dialog title (`"Create/Edit Contact"`), description (`"Add a new ... to your database."`), submitText (`"Save Record"`), and cancelText (`"Cancel"`) now use i18n keys.
16+
- `SelectField`: Default placeholder `"Select an option"` now uses `t('common.selectOption')`.
17+
- `LookupField`: Default placeholder `"Select..."` and search placeholder `"Search..."` now use i18n keys.
18+
- `FieldFactory`: Default select option placeholder now uses `t('common.selectOption')`.
19+
- Form renderer: Default select placeholder now uses `t('common.selectOption')`.
20+
21+
### Added
22+
23+
- **New i18n keys for select/form components** (`@object-ui/i18n`): Added new translation keys across all 10 locale packs:
24+
- `common.selectOption` — Default select field placeholder (e.g., '请选择' in Chinese)
25+
- `common.select` — Short select placeholder (e.g., '选择...' in Chinese)
26+
- `form.createTitle` — Form dialog create title with interpolation (e.g., '新建{{object}}' in Chinese)
27+
- `form.editTitle` — Form dialog edit title with interpolation
28+
- `form.createDescription` — Form dialog create description with interpolation
29+
- `form.editDescription` — Form dialog edit description with interpolation
30+
- `form.saveRecord` — Save record button text
31+
32+
- **Safe translation hook for field widgets** (`@object-ui/fields`): Added `useFieldTranslation` hook that provides fallback to English defaults when no `I18nProvider` is available, following the same pattern as `useDetailTranslation` in `@object-ui/plugin-detail`.
33+
34+
### Fixed
35+
1236
- **ObjectView ChatterPanel test assertion mismatch** (`apps/console`): Fixed the failing CI test in `ObjectView.test.tsx` where the RecordChatterPanel drawer test used `getByLabelText('Show discussion')` but the actual aria-label rendered by the component is `'Show Discussion (0)'` (from the i18n default translation `detail.showDiscussion` with count parameter). Updated the test assertion to match the correct aria-label.
1337

1438
### Changed

apps/console/src/App.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ModalForm } from '@object-ui/plugin-form';
44
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
55
import { toast } from 'sonner';
66
import { SchemaRendererProvider, useActionRunner, useGlobalUndo } from '@object-ui/react';
7+
import { useObjectTranslation } from '@object-ui/i18n';
78
import type { ConnectionState } from './dataSource';
89
import { AuthGuard, useAuth, PreviewBanner } from '@object-ui/auth';
910
import { MetadataProvider, useMetadata } from './context/MetadataProvider';
@@ -92,6 +93,7 @@ export function AppContent() {
9293
const location = useLocation();
9394
const { appName } = useParams();
9495
const { apps, objects: allObjects, loading: metadataLoading } = useMetadata();
96+
const { t } = useObjectTranslation();
9597

9698
// Determine active app based on URL
9799
const activeApps = apps.filter((a: any) => a.active !== false);
@@ -396,8 +398,12 @@ export function AppContent() {
396398
objectName: currentObjectDef.name,
397399
mode: editingRecord ? 'edit' : 'create',
398400
recordId: editingRecord?.id,
399-
title: `${editingRecord ? 'Edit' : 'Create'} ${currentObjectDef?.label}`,
400-
description: editingRecord ? `Update details for ${currentObjectDef?.label}` : `Add a new ${currentObjectDef?.label} to your database.`,
401+
title: editingRecord
402+
? t('form.editTitle', { object: currentObjectDef?.label })
403+
: t('form.createTitle', { object: currentObjectDef?.label }),
404+
description: editingRecord
405+
? t('form.editDescription', { object: currentObjectDef?.label })
406+
: t('form.createDescription', { object: currentObjectDef?.label }),
401407
open: isDialogOpen,
402408
onOpenChange: setIsDialogOpen,
403409
layout: 'vertical',
@@ -417,8 +423,8 @@ export function AppContent() {
417423
onCancel: handleDialogCancel,
418424
showSubmit: true,
419425
showCancel: true,
420-
submitText: 'Save Record',
421-
cancelText: 'Cancel',
426+
submitText: t('form.saveRecord'),
427+
cancelText: t('common.cancel'),
422428
}}
423429
dataSource={dataSource}
424430
/>

packages/components/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
},
3737
"dependencies": {
3838
"@object-ui/core": "workspace:*",
39+
"@object-ui/i18n": "workspace:*",
3940
"@object-ui/react": "workspace:*",
4041
"@object-ui/types": "workspace:*",
4142
"class-variance-authority": "^0.7.1",

packages/components/src/renderers/form/form.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,17 @@ import { AlertCircle, Loader2 } from 'lucide-react';
2828
import { cn } from '../../lib/utils';
2929
import React from 'react';
3030
import { SchemaRendererContext } from '@object-ui/react';
31+
import { createSafeTranslation } from '@object-ui/i18n';
32+
33+
const useSafeFormTranslation = createSafeTranslation(
34+
{ 'common.selectOption': 'Select an option' },
35+
'common.selectOption',
36+
);
3137

3238
// Form renderer component - Airtable-style feature-complete form
3339
ComponentRegistry.register('form',
3440
({ schema, className, onAction, ...props }: { schema: FormSchema; className?: string; onAction?: (action: any) => void; [key: string]: any }) => {
41+
const { t } = useSafeFormTranslation();
3542
const {
3643
defaultValues = {},
3744
fields = [],
@@ -317,7 +324,7 @@ ComponentRegistry.register('form',
317324
...formField,
318325
inputType: fieldProps.inputType,
319326
options: fieldProps.options,
320-
placeholder: fieldProps.placeholder,
327+
placeholder: fieldProps.placeholder ?? (resolvedType === 'select' ? t('common.selectOption') : undefined),
321328
disabled: disabled || fieldDisabled || readonly || isSubmitting,
322329
dataSource: contextDataSource,
323330
})}
@@ -514,7 +521,7 @@ function renderFieldComponent(type: string, props: RenderFieldProps) {
514521
return (
515522
<Select value={selectValue} onValueChange={selectOnChange} {...selectProps}>
516523
<SelectTrigger className="min-h-[44px] sm:min-h-0">
517-
<SelectValue placeholder={placeholder ?? 'Select an option'} />
524+
<SelectValue placeholder={placeholder || 'Select an option'} />
518525
</SelectTrigger>
519526
<SelectContent>
520527
{options.map((opt: SelectOption) => (

packages/fields/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"dependencies": {
3232
"@object-ui/components": "workspace:*",
3333
"@object-ui/core": "workspace:*",
34+
"@object-ui/i18n": "workspace:*",
3435
"@object-ui/react": "workspace:*",
3536
"@object-ui/types": "workspace:*",
3637
"clsx": "^2.1.1",

packages/fields/src/widgets/LookupField.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { RecordPickerDialog } from './RecordPickerDialog';
1414
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
1515
import { getCellRendererResolver } from './_cell-renderer-bridge';
1616
import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react';
17+
import { useFieldTranslation } from './useFieldTranslation';
1718

1819
export interface LookupOption {
1920
value: string | number;
@@ -79,6 +80,7 @@ function mapFieldTypeToFilterType(
7980
export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
8081
const [isOpen, setIsOpen] = useState(false);
8182
const [searchQuery, setSearchQuery] = useState('');
83+
const { t } = useFieldTranslation();
8284

8385
// Dynamic data loading state
8486
const [fetchedOptions, setFetchedOptions] = useState<LookupOption[]>([]);
@@ -393,8 +395,8 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
393395
>
394396
<Search className="mr-2 size-4" />
395397
{selectedOptions.length === 0
396-
? lookupField?.placeholder || 'Select...'
397-
: multiple ? `${selectedOptions.length} selected` : 'Change selection'
398+
? lookupField?.placeholder || t('common.select')
399+
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
398400
}
399401
</Button>
400402
</PopoverTrigger>
@@ -404,7 +406,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
404406
<div className="relative">
405407
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
406408
<Input
407-
placeholder="Search..."
409+
placeholder={t('common.search') + '...'}
408410
value={searchQuery}
409411
onChange={(e) => handleSearchChange(e.target.value)}
410412
onKeyDown={handleSearchKeyDown}

packages/fields/src/widgets/SelectField.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
SelectValue
88
} from '@object-ui/components';
99
import { SelectFieldMetadata } from '@object-ui/types';
10+
import { useFieldTranslation } from './useFieldTranslation';
1011
import { FieldWidgetProps } from './types';
1112

1213
/**
@@ -16,6 +17,7 @@ import { FieldWidgetProps } from './types';
1617
export function SelectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
1718
const config = (field || (props as any).schema) as SelectFieldMetadata;
1819
const options = config?.options || [];
20+
const { t } = useFieldTranslation();
1921

2022
if (readonly) {
2123
const option = options.find((o) => o.value === value);
@@ -30,7 +32,7 @@ export function SelectField({ value, onChange, field, readonly, ...props }: Fiel
3032
disabled={readonly || props.disabled}
3133
>
3234
<SelectTrigger className={props.className} id={props.id}>
33-
<SelectValue placeholder={config?.placeholder || "Select an option"} />
35+
<SelectValue placeholder={config?.placeholder || t('common.selectOption')} />
3436
</SelectTrigger>
3537
<SelectContent position="popper">
3638
{options.map((option) => (
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Safe translation hook for field widgets.
3+
* Falls back to English defaults when no I18nProvider is available.
4+
*/
5+
import { createSafeTranslation } from '@object-ui/i18n';
6+
7+
const FIELD_DEFAULTS: Record<string, string> = {
8+
'common.selectOption': 'Select an option',
9+
'common.select': 'Select...',
10+
'common.search': 'Search',
11+
'table.selected': '{{count}} selected',
12+
};
13+
14+
export const useFieldTranslation = createSafeTranslation(
15+
FIELD_DEFAULTS,
16+
'common.selectOption',
17+
);

packages/i18n/src/__tests__/i18n.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ describe('@object-ui/i18n', () => {
4747
expect(i18n.t('common.cancel')).toBe('Cancel');
4848
expect(i18n.t('common.delete')).toBe('Delete');
4949
expect(i18n.t('common.loading')).toBe('Loading...');
50+
expect(i18n.t('common.selectOption')).toBe('Select an option');
51+
expect(i18n.t('common.select')).toBe('Select...');
52+
});
53+
54+
it('translates new form keys in English', () => {
55+
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
56+
expect(i18n.t('form.createTitle', { object: 'Contact' })).toBe('Create Contact');
57+
expect(i18n.t('form.editTitle', { object: 'Contact' })).toBe('Edit Contact');
58+
expect(i18n.t('form.createDescription', { object: 'Contact' })).toBe('Add a new Contact to your database.');
59+
expect(i18n.t('form.editDescription', { object: 'Contact' })).toBe('Update details for Contact');
60+
expect(i18n.t('form.saveRecord')).toBe('Save Record');
5061
});
5162

5263
it('translates common keys in Chinese', () => {
@@ -55,6 +66,22 @@ describe('@object-ui/i18n', () => {
5566
expect(i18n.t('common.cancel')).toBe('取消');
5667
expect(i18n.t('common.delete')).toBe('删除');
5768
expect(i18n.t('common.loading')).toBe('加载中...');
69+
expect(i18n.t('common.selectOption')).toBe('请选择');
70+
expect(i18n.t('common.select')).toBe('选择...');
71+
});
72+
73+
it('translates new form keys in Chinese', () => {
74+
const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
75+
expect(i18n.t('form.createTitle', { object: '联系人' })).toBe('新建联系人');
76+
expect(i18n.t('form.editTitle', { object: '联系人' })).toBe('编辑联系人');
77+
expect(i18n.t('form.createDescription', { object: '联系人' })).toBe('向数据库添加新的联系人。');
78+
expect(i18n.t('form.editDescription', { object: '联系人' })).toBe('更新联系人的详情');
79+
expect(i18n.t('form.saveRecord')).toBe('保存记录');
80+
});
81+
82+
it('translates toolbarEnabledCount in Chinese (not English fallback)', () => {
83+
const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
84+
expect(i18n.t('console.objectView.toolbarEnabledCount', { count: 3, total: 5 })).toBe('已启用 3/5 项');
5885
});
5986

6087
it('translates common keys in Japanese', () => {

packages/i18n/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export { createI18n, getDirection, getAvailableLanguages, type I18nConfig, type
3737
// React integration
3838
export { I18nProvider, useObjectTranslation, useI18nContext, type I18nProviderProps } from './provider';
3939

40+
// Safe translation hook factory
41+
export { createSafeTranslation } from './useSafeTranslation';
42+
4043
// Convention-based object/field label i18n
4144
export { useObjectLabel, useSafeFieldLabel } from './useObjectLabel';
4245

0 commit comments

Comments
 (0)