@object-ui/i18n 3.0.3 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/locales/ar.d.ts +360 -0
- package/dist/locales/ar.js +360 -0
- package/dist/locales/de.d.ts +360 -0
- package/dist/locales/de.js +360 -0
- package/dist/locales/en.d.ts +429 -0
- package/dist/locales/en.js +429 -0
- package/dist/locales/es.d.ts +360 -0
- package/dist/locales/es.js +360 -0
- package/dist/locales/fr.d.ts +360 -0
- package/dist/locales/fr.js +360 -0
- package/dist/locales/index.d.ts +3738 -0
- package/dist/locales/ja.d.ts +360 -0
- package/dist/locales/ja.js +360 -0
- package/dist/locales/ko.d.ts +360 -0
- package/dist/locales/ko.js +360 -0
- package/dist/locales/pt.d.ts +360 -0
- package/dist/locales/pt.js +360 -0
- package/dist/locales/ru.d.ts +360 -0
- package/dist/locales/ru.js +360 -0
- package/dist/locales/zh.d.ts +429 -0
- package/dist/locales/zh.js +429 -0
- package/dist/provider.js +34 -4
- package/dist/useObjectLabel.d.ts +61 -0
- package/dist/useObjectLabel.js +114 -0
- package/package.json +7 -5
- package/src/__tests__/useObjectLabel.test.tsx +131 -0
- package/src/index.ts +3 -0
- package/src/locales/ar.ts +360 -0
- package/src/locales/de.ts +360 -0
- package/src/locales/en.ts +429 -0
- package/src/locales/es.ts +360 -0
- package/src/locales/fr.ts +360 -0
- package/src/locales/ja.ts +360 -0
- package/src/locales/ko.ts +360 -0
- package/src/locales/pt.ts +360 -0
- package/src/locales/ru.ts +360 -0
- package/src/locales/zh.ts +429 -0
- package/src/provider.tsx +33 -4
- package/src/useObjectLabel.ts +122 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @object-ui/i18n - Convention-based Object & Field Label i18n
|
|
3
|
+
*
|
|
4
|
+
* Provides automatic translation resolution for object metadata labels
|
|
5
|
+
* using Salesforce-style convention-based key generation.
|
|
6
|
+
*
|
|
7
|
+
* The app namespace (e.g. "crm") is discovered dynamically from loaded
|
|
8
|
+
* i18next resources — no hardcoded app names in platform code.
|
|
9
|
+
*
|
|
10
|
+
* Convention Rules:
|
|
11
|
+
* | What | Auto-generated key | Fallback |
|
|
12
|
+
* |--------------------|-------------------------------------------------|-----------------------|
|
|
13
|
+
* | Object label | {ns}.objects.{objectName}.label | objectDef.label |
|
|
14
|
+
* | Object description | {ns}.objects.{objectName}.description | objectDef.description |
|
|
15
|
+
* | Field label | {ns}.fields.{objectName}.{fieldName} | field.label |
|
|
16
|
+
*
|
|
17
|
+
* @module useObjectLabel
|
|
18
|
+
*/
|
|
19
|
+
import { useObjectTranslation } from './provider';
|
|
20
|
+
/**
|
|
21
|
+
* Built-in Object UI top-level locale keys — not app namespaces.
|
|
22
|
+
* Update this set when new top-level platform translation keys are added
|
|
23
|
+
* to `packages/i18n/src/locales/en.ts` to prevent them from being treated
|
|
24
|
+
* as app namespaces during dynamic namespace discovery.
|
|
25
|
+
*/
|
|
26
|
+
const BUILTIN_KEYS = new Set([
|
|
27
|
+
'common', 'validation', 'form', 'table', 'grid', 'calendar',
|
|
28
|
+
'list', 'kanban', 'chart', 'dashboard', 'configPanel',
|
|
29
|
+
'appDesigner', 'console', 'errors', 'detail',
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Hook for convention-based auto-resolution of object and field labels.
|
|
33
|
+
*
|
|
34
|
+
* Automatically constructs i18n keys from object/field names and looks up
|
|
35
|
+
* translations, falling back to the plain-string label when no translation exists.
|
|
36
|
+
*
|
|
37
|
+
* The app namespace is discovered dynamically from loaded i18next resources
|
|
38
|
+
* by finding top-level keys that contain an `objects` sub-key.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* const { objectLabel, fieldLabel } = useObjectLabel();
|
|
43
|
+
* <h1>{objectLabel(objectDef)}</h1>
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function useObjectLabel() {
|
|
47
|
+
const { t, i18n } = useObjectTranslation();
|
|
48
|
+
/**
|
|
49
|
+
* Discover app namespace(s) from loaded i18next resources.
|
|
50
|
+
* Returns top-level keys (outside built-in Object UI keys) that contain
|
|
51
|
+
* an `objects` or `fields` sub-key — e.g. "crm" when resources include crm.objects.*.
|
|
52
|
+
*/
|
|
53
|
+
const getAppNamespaces = () => {
|
|
54
|
+
if (!i18n || typeof i18n.getResourceBundle !== 'function')
|
|
55
|
+
return [];
|
|
56
|
+
const lang = i18n.language || 'en';
|
|
57
|
+
const bundle = i18n.getResourceBundle(lang, 'translation');
|
|
58
|
+
if (!bundle)
|
|
59
|
+
return [];
|
|
60
|
+
return Object.keys(bundle).filter((key) => !BUILTIN_KEYS.has(key) && bundle[key] && typeof bundle[key] === 'object'
|
|
61
|
+
&& (bundle[key].objects || bundle[key].fields));
|
|
62
|
+
};
|
|
63
|
+
/** Try resolving a key across all discovered app namespaces. */
|
|
64
|
+
const resolve = (suffix, fallback) => {
|
|
65
|
+
try {
|
|
66
|
+
const namespaces = getAppNamespaces();
|
|
67
|
+
for (const ns of namespaces) {
|
|
68
|
+
const key = `${ns}.${suffix}`;
|
|
69
|
+
const translated = t(key, { defaultValue: '' });
|
|
70
|
+
if (translated && translated !== key && translated !== '') {
|
|
71
|
+
return translated;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Graceful degradation when i18n provider is not available
|
|
77
|
+
}
|
|
78
|
+
return fallback;
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
/**
|
|
82
|
+
* Resolve translated object label, falling back to objectDef.label.
|
|
83
|
+
*/
|
|
84
|
+
objectLabel: (objectDef) => resolve(`objects.${objectDef.name}.label`, objectDef.label),
|
|
85
|
+
/**
|
|
86
|
+
* Resolve translated object description, falling back to objectDef.description.
|
|
87
|
+
*/
|
|
88
|
+
objectDescription: (objectDef) => {
|
|
89
|
+
if (!objectDef.description)
|
|
90
|
+
return undefined;
|
|
91
|
+
return resolve(`objects.${objectDef.name}.description`, objectDef.description);
|
|
92
|
+
},
|
|
93
|
+
/**
|
|
94
|
+
* Resolve translated field label, falling back to the provided fallback string.
|
|
95
|
+
*/
|
|
96
|
+
fieldLabel: (objectName, fieldName, fallback) => resolve(`fields.${objectName}.${fieldName}`, fallback),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Safe wrapper for useObjectLabel that falls back to identity functions
|
|
101
|
+
* when no I18nProvider is available. Suitable for plugin components that
|
|
102
|
+
* may be rendered outside an i18n context.
|
|
103
|
+
*/
|
|
104
|
+
export function useSafeFieldLabel() {
|
|
105
|
+
try {
|
|
106
|
+
const { fieldLabel } = useObjectLabel();
|
|
107
|
+
return { fieldLabel };
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return {
|
|
111
|
+
fieldLabel: (_objectName, _fieldName, fallback) => fallback,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/i18n",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,22 +20,24 @@
|
|
|
20
20
|
"exports": {
|
|
21
21
|
".": {
|
|
22
22
|
"types": "./dist/index.d.ts",
|
|
23
|
-
"import": "./dist/index.js"
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"default": "./dist/index.js"
|
|
24
25
|
},
|
|
25
26
|
"./locales/*": {
|
|
26
27
|
"types": "./dist/locales/*.d.ts",
|
|
27
|
-
"import": "./dist/locales/*.js"
|
|
28
|
+
"import": "./dist/locales/*.js",
|
|
29
|
+
"default": "./dist/locales/*.js"
|
|
28
30
|
}
|
|
29
31
|
},
|
|
30
32
|
"dependencies": {
|
|
31
|
-
"i18next": "^25.8.
|
|
33
|
+
"i18next": "^25.8.13",
|
|
32
34
|
"react-i18next": "^16.5.4"
|
|
33
35
|
},
|
|
34
36
|
"peerDependencies": {
|
|
35
37
|
"react": "^18.0.0 || ^19.0.0"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
38
|
-
"@types/react": "19.2.
|
|
40
|
+
"@types/react": "19.2.14",
|
|
39
41
|
"react": "19.2.4",
|
|
40
42
|
"typescript": "^5.9.3",
|
|
41
43
|
"vitest": "^4.0.18"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { I18nProvider } from '../provider';
|
|
5
|
+
import { createI18n } from '../i18n';
|
|
6
|
+
import { useObjectLabel } from '../useObjectLabel';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a wrapper with custom translations to simulate CRM locale bundles.
|
|
10
|
+
*/
|
|
11
|
+
function createWrapper(lang: string, translations?: Record<string, unknown>) {
|
|
12
|
+
const instance = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
|
|
13
|
+
if (translations) {
|
|
14
|
+
instance.addResourceBundle(lang, 'translation', translations, true, true);
|
|
15
|
+
}
|
|
16
|
+
return ({ children }: { children: React.ReactNode }) =>
|
|
17
|
+
React.createElement(I18nProvider, { instance }, children);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('useObjectLabel', () => {
|
|
21
|
+
describe('objectLabel', () => {
|
|
22
|
+
it('returns the plain label when no translation exists', () => {
|
|
23
|
+
const wrapper = createWrapper('en');
|
|
24
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
25
|
+
|
|
26
|
+
const label = result.current.objectLabel({ name: 'order_item', label: 'Order Item' });
|
|
27
|
+
expect(label).toBe('Order Item');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns the translated label when a translation exists', () => {
|
|
31
|
+
const wrapper = createWrapper('zh', {
|
|
32
|
+
crm: { objects: { order_item: { label: '订单明细' } } },
|
|
33
|
+
});
|
|
34
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
35
|
+
|
|
36
|
+
const label = result.current.objectLabel({ name: 'order_item', label: 'Order Item' });
|
|
37
|
+
expect(label).toBe('订单明细');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('falls back to plain label when translation key returns empty string', () => {
|
|
41
|
+
const wrapper = createWrapper('zh', {
|
|
42
|
+
crm: { objects: { order_item: { label: '' } } },
|
|
43
|
+
});
|
|
44
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
45
|
+
|
|
46
|
+
const label = result.current.objectLabel({ name: 'order_item', label: 'Order Item' });
|
|
47
|
+
expect(label).toBe('Order Item');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('objectDescription', () => {
|
|
52
|
+
it('returns undefined when objectDef has no description', () => {
|
|
53
|
+
const wrapper = createWrapper('en');
|
|
54
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
55
|
+
|
|
56
|
+
const desc = result.current.objectDescription({ name: 'order_item' });
|
|
57
|
+
expect(desc).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns the plain description when no translation exists', () => {
|
|
61
|
+
const wrapper = createWrapper('en');
|
|
62
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
63
|
+
|
|
64
|
+
const desc = result.current.objectDescription({ name: 'order_item', description: 'Line items in an order' });
|
|
65
|
+
expect(desc).toBe('Line items in an order');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns the translated description when a translation exists', () => {
|
|
69
|
+
const wrapper = createWrapper('zh', {
|
|
70
|
+
crm: { objects: { order_item: { description: '订单中的商品条目' } } },
|
|
71
|
+
});
|
|
72
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
73
|
+
|
|
74
|
+
const desc = result.current.objectDescription({ name: 'order_item', description: 'Line items in an order' });
|
|
75
|
+
expect(desc).toBe('订单中的商品条目');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('fieldLabel', () => {
|
|
80
|
+
it('returns the fallback label when no translation exists', () => {
|
|
81
|
+
const wrapper = createWrapper('en');
|
|
82
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
83
|
+
|
|
84
|
+
const label = result.current.fieldLabel('order_item', 'name', 'Line Item');
|
|
85
|
+
expect(label).toBe('Line Item');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns the translated label when a translation exists', () => {
|
|
89
|
+
const wrapper = createWrapper('zh', {
|
|
90
|
+
crm: { fields: { order_item: { name: '订单项' } } },
|
|
91
|
+
});
|
|
92
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
93
|
+
|
|
94
|
+
const label = result.current.fieldLabel('order_item', 'name', 'Line Item');
|
|
95
|
+
expect(label).toBe('订单项');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('falls back to plain label when translation key returns empty string', () => {
|
|
99
|
+
const wrapper = createWrapper('zh', {
|
|
100
|
+
crm: { fields: { order_item: { name: '' } } },
|
|
101
|
+
});
|
|
102
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
103
|
+
|
|
104
|
+
const label = result.current.fieldLabel('order_item', 'name', 'Line Item');
|
|
105
|
+
expect(label).toBe('Line Item');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('multiple objects', () => {
|
|
110
|
+
it('resolves labels for different objects independently', () => {
|
|
111
|
+
const wrapper = createWrapper('zh', {
|
|
112
|
+
crm: {
|
|
113
|
+
objects: {
|
|
114
|
+
contact: { label: '联系人' },
|
|
115
|
+
opportunity: { label: '商机' },
|
|
116
|
+
},
|
|
117
|
+
fields: {
|
|
118
|
+
contact: { email: '邮箱' },
|
|
119
|
+
opportunity: { stage: '阶段' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
|
|
124
|
+
|
|
125
|
+
expect(result.current.objectLabel({ name: 'contact', label: 'Contact' })).toBe('联系人');
|
|
126
|
+
expect(result.current.objectLabel({ name: 'opportunity', label: 'Opportunity' })).toBe('商机');
|
|
127
|
+
expect(result.current.fieldLabel('contact', 'email', 'Email')).toBe('邮箱');
|
|
128
|
+
expect(result.current.fieldLabel('opportunity', 'stage', 'Stage')).toBe('阶段');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,9 @@ export { createI18n, getDirection, getAvailableLanguages, type I18nConfig, type
|
|
|
37
37
|
// React integration
|
|
38
38
|
export { I18nProvider, useObjectTranslation, useI18nContext, type I18nProviderProps } from './provider';
|
|
39
39
|
|
|
40
|
+
// Convention-based object/field label i18n
|
|
41
|
+
export { useObjectLabel, useSafeFieldLabel } from './useObjectLabel';
|
|
42
|
+
|
|
40
43
|
// Locale packs
|
|
41
44
|
export { builtInLocales, isRTL, RTL_LANGUAGES } from './locales/index';
|
|
42
45
|
export { default as en } from './locales/en';
|