Skip to content

Commit e3a4404

Browse files
Copilothotlong
andcommitted
fix(i18n): replace hardcoded English with i18n in platform UI components
- App.tsx: Use t() for modal dialog title, description, submit/cancel text - SelectField: Use safe i18n hook for "Select an option" placeholder - LookupField: Use safe i18n hook for "Select...", "Search..." placeholders - FieldFactory: Use safe i18n hook for select option placeholder - form.tsx renderer: Use safe i18n hook for select placeholder - Add useFieldTranslation safe hook with English fallbacks - Add @object-ui/i18n dependency to fields and components packages - Add test coverage for new i18n keys (en/zh) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/1e02cef1-b595-4316-86d4-24d7df245f95
1 parent 66da1ac commit e3a4404

10 files changed

Lines changed: 162 additions & 12 deletions

File tree

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: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,39 @@ 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 { useObjectTranslation } from '@object-ui/i18n';
32+
33+
/**
34+
* Safe translation helper that falls back to English defaults when
35+
* no I18nProvider is available.
36+
*/
37+
function useSafeFormTranslation() {
38+
try {
39+
const result = useObjectTranslation();
40+
const testValue = result.t('common.selectOption');
41+
if (testValue === 'common.selectOption') {
42+
return { t: (key: string) => {
43+
const defaults: Record<string, string> = {
44+
'common.selectOption': 'Select an option',
45+
};
46+
return defaults[key] || key;
47+
}};
48+
}
49+
return { t: result.t };
50+
} catch {
51+
return { t: (key: string) => {
52+
const defaults: Record<string, string> = {
53+
'common.selectOption': 'Select an option',
54+
};
55+
return defaults[key] || key;
56+
}};
57+
}
58+
}
3159

3260
// Form renderer component - Airtable-style feature-complete form
3361
ComponentRegistry.register('form',
3462
({ schema, className, onAction, ...props }: { schema: FormSchema; className?: string; onAction?: (action: any) => void; [key: string]: any }) => {
63+
const { t } = useSafeFormTranslation();
3564
const {
3665
defaultValues = {},
3766
fields = [],
@@ -317,7 +346,7 @@ ComponentRegistry.register('form',
317346
...formField,
318347
inputType: fieldProps.inputType,
319348
options: fieldProps.options,
320-
placeholder: fieldProps.placeholder,
349+
placeholder: fieldProps.placeholder ?? (resolvedType === 'select' ? t('common.selectOption') : undefined),
321350
disabled: disabled || fieldDisabled || readonly || isSubmitting,
322351
dataSource: contextDataSource,
323352
})}
@@ -514,7 +543,7 @@ function renderFieldComponent(type: string, props: RenderFieldProps) {
514543
return (
515544
<Select value={selectValue} onValueChange={selectOnChange} {...selectProps}>
516545
<SelectTrigger className="min-h-[44px] sm:min-h-0">
517-
<SelectValue placeholder={placeholder ?? 'Select an option'} />
546+
<SelectValue placeholder={placeholder} />
518547
</SelectTrigger>
519548
<SelectContent>
520549
{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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Safe translation hook for field widgets.
3+
* Falls back to English defaults when no I18nProvider is available.
4+
*/
5+
import { useObjectTranslation } 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 function useFieldTranslation() {
15+
try {
16+
const result = useObjectTranslation();
17+
// Test if i18n is properly configured
18+
const testValue = result.t('common.selectOption');
19+
if (testValue === 'common.selectOption') {
20+
// i18n not configured — use defaults
21+
return {
22+
t: (key: string, options?: Record<string, unknown>) => {
23+
let value = FIELD_DEFAULTS[key] || key;
24+
if (options) {
25+
for (const [k, v] of Object.entries(options)) {
26+
value = value.replace(`{{${k}}}`, String(v));
27+
}
28+
}
29+
return value;
30+
},
31+
};
32+
}
33+
return { t: result.t };
34+
} catch {
35+
return {
36+
t: (key: string, options?: Record<string, unknown>) => {
37+
let value = FIELD_DEFAULTS[key] || key;
38+
if (options) {
39+
for (const [k, v] of Object.entries(options)) {
40+
value = value.replace(`{{${k}}}`, String(v));
41+
}
42+
}
43+
return value;
44+
},
45+
};
46+
}
47+
}

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/react/src/components/form/FieldFactory.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,34 @@ import React from 'react';
1010
import { UseFormReturn } from 'react-hook-form';
1111
import type { FormField } from '@objectstack/spec/ui';
1212
import { resolveI18nLabel } from '../../utils/i18n';
13+
import { useObjectTranslation } from '@object-ui/i18n';
14+
15+
/**
16+
* Safe translation helper that falls back to English defaults when
17+
* no I18nProvider is available.
18+
*/
19+
function useSafeTranslation() {
20+
try {
21+
const result = useObjectTranslation();
22+
const testValue = result.t('common.selectOption');
23+
if (testValue === 'common.selectOption') {
24+
return { t: (key: string) => {
25+
const defaults: Record<string, string> = {
26+
'common.selectOption': 'Select an option',
27+
};
28+
return defaults[key] || key;
29+
}};
30+
}
31+
return { t: result.t };
32+
} catch {
33+
return { t: (key: string) => {
34+
const defaults: Record<string, string> = {
35+
'common.selectOption': 'Select an option',
36+
};
37+
return defaults[key] || key;
38+
}};
39+
}
40+
}
1341

1442
/**
1543
* Extended form field with additional properties for complex widgets
@@ -51,6 +79,7 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
5179
disabled = false,
5280
}) => {
5381
const { register, formState: { errors } } = methods;
82+
const { t } = useSafeTranslation();
5483

5584
// Cast to extended field for properties not in base schema
5685
const extendedField = field as ExtendedFormField;
@@ -193,7 +222,7 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
193222
setValueAs: handleMultipleSelectValue,
194223
})}
195224
>
196-
{!extendedField.multiple && <option value="">{fieldPlaceholder || 'Select an option'}</option>}
225+
{!extendedField.multiple && <option value="">{fieldPlaceholder || t('common.selectOption')}</option>}
197226
{extendedField.options?.map((option) => (
198227
<option key={option.value} value={option.value} disabled={option.disabled}>
199228
{option.label}
@@ -400,7 +429,7 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
400429
setValueAs: handleMultipleSelectValue,
401430
})}
402431
>
403-
{!extendedField.multiple && <option value="">{fieldPlaceholder || 'Select an option'}</option>}
432+
{!extendedField.multiple && <option value="">{fieldPlaceholder || t('common.selectOption')}</option>}
404433
{extendedField.options?.map((option) => (
405434
<option key={option.value} value={option.value} disabled={option.disabled}>
406435
{option.label}

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)