@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.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +2 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.js +2 -0
  5. package/dist/locales/ar.d.ts +360 -0
  6. package/dist/locales/ar.js +360 -0
  7. package/dist/locales/de.d.ts +360 -0
  8. package/dist/locales/de.js +360 -0
  9. package/dist/locales/en.d.ts +429 -0
  10. package/dist/locales/en.js +429 -0
  11. package/dist/locales/es.d.ts +360 -0
  12. package/dist/locales/es.js +360 -0
  13. package/dist/locales/fr.d.ts +360 -0
  14. package/dist/locales/fr.js +360 -0
  15. package/dist/locales/index.d.ts +3738 -0
  16. package/dist/locales/ja.d.ts +360 -0
  17. package/dist/locales/ja.js +360 -0
  18. package/dist/locales/ko.d.ts +360 -0
  19. package/dist/locales/ko.js +360 -0
  20. package/dist/locales/pt.d.ts +360 -0
  21. package/dist/locales/pt.js +360 -0
  22. package/dist/locales/ru.d.ts +360 -0
  23. package/dist/locales/ru.js +360 -0
  24. package/dist/locales/zh.d.ts +429 -0
  25. package/dist/locales/zh.js +429 -0
  26. package/dist/provider.js +34 -4
  27. package/dist/useObjectLabel.d.ts +61 -0
  28. package/dist/useObjectLabel.js +114 -0
  29. package/package.json +7 -5
  30. package/src/__tests__/useObjectLabel.test.tsx +131 -0
  31. package/src/index.ts +3 -0
  32. package/src/locales/ar.ts +360 -0
  33. package/src/locales/de.ts +360 -0
  34. package/src/locales/en.ts +429 -0
  35. package/src/locales/es.ts +360 -0
  36. package/src/locales/fr.ts +360 -0
  37. package/src/locales/ja.ts +360 -0
  38. package/src/locales/ko.ts +360 -0
  39. package/src/locales/pt.ts +360 -0
  40. package/src/locales/ru.ts +360 -0
  41. package/src/locales/zh.ts +429 -0
  42. package/src/provider.tsx +33 -4
  43. package/src/useObjectLabel.ts +122 -0
  44. 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.0.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.7",
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.13",
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';