Skip to content

Commit 2c18c66

Browse files
committed
feat: add auto-transformation for TranslationData format in loadLanguage
1 parent 3649479 commit 2c18c66

File tree

2 files changed

+154
-3
lines changed

2 files changed

+154
-3
lines changed

apps/console/src/__tests__/LoadLanguage.test.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,73 @@ describe('loadLanguage', () => {
8181
const result = await loadLanguage('en');
8282
expect(result).toEqual(flat);
8383
});
84+
85+
it('auto-transforms TranslationData format (objects with nested fields)', async () => {
86+
// Simulate @objectstack/spec TranslationData format returned by I18nServicePlugin
87+
const specData = {
88+
objects: {
89+
account: {
90+
label: 'Account',
91+
pluralLabel: 'Accounts',
92+
fields: {
93+
name: { label: 'Account Name' },
94+
industry: { label: 'Industry', options: { Technology: 'Technology', Finance: 'Finance' } },
95+
},
96+
},
97+
contact: {
98+
label: 'Contact',
99+
fields: {
100+
email: { label: 'Email' },
101+
},
102+
},
103+
},
104+
apps: { crm: { label: 'CRM' } },
105+
messages: { 'common.save': 'Save' },
106+
};
107+
108+
fetchSpy.mockResolvedValue({
109+
ok: true,
110+
json: async () => ({ data: { locale: 'en', translations: specData } }),
111+
} as Response);
112+
113+
const result = await loadLanguage('en');
114+
115+
// Should be wrapped under 'app' namespace with flat fields
116+
expect(result).toEqual({
117+
app: {
118+
objects: {
119+
account: { label: 'Account', pluralLabel: 'Accounts' },
120+
contact: { label: 'Contact' },
121+
},
122+
fields: {
123+
account: { name: 'Account Name', industry: 'Industry' },
124+
contact: { email: 'Email' },
125+
},
126+
fieldOptions: {
127+
account: { industry: { Technology: 'Technology', Finance: 'Finance' } },
128+
},
129+
apps: { crm: { label: 'CRM' } },
130+
messages: { 'common.save': 'Save' },
131+
},
132+
});
133+
});
134+
135+
it('does NOT transform data that is already in namespace-wrapped format', async () => {
136+
// Already-transformed data (from objectstack.shared.ts or CRM example)
137+
const alreadyFlat = {
138+
crm: {
139+
objects: { account: { label: 'Account' } },
140+
fields: { account: { name: 'Account Name' } },
141+
},
142+
};
143+
144+
fetchSpy.mockResolvedValue({
145+
ok: true,
146+
json: async () => ({ data: { locale: 'en', translations: alreadyFlat } }),
147+
} as Response);
148+
149+
const result = await loadLanguage('en');
150+
// Should pass through unchanged
151+
expect(result).toEqual(alreadyFlat);
152+
});
84153
});

apps/console/src/loadLanguage.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
* its response in the standard envelope: `{ data: { locale, translations } }`.
66
* We extract `data.translations` when present, and fall back to the raw JSON
77
* for mock / local-dev environments that may return flat translation objects.
8+
*
9+
* When the response uses the @objectstack/spec `TranslationData` format
10+
* (objects with nested fields), we automatically transform it into the flat
11+
* format expected by @object-ui/i18n's `useObjectLabel` hook:
12+
* - `objects.{name}.fields.{field}.label` → `fields.{name}.{field}` (string)
13+
* - `objects.{name}.fields.{field}.options` → `fieldOptions.{name}.{field}`
14+
* - Wrapped under an `app` namespace key for hook discovery
815
*/
916
export async function loadLanguage(lang: string): Promise<Record<string, unknown>> {
1017
try {
@@ -15,13 +22,88 @@ export async function loadLanguage(lang: string): Promise<Record<string, unknown
1522
}
1623
const json = await res.json();
1724
// Unwrap the spec REST API envelope when present
25+
let translations: Record<string, unknown>;
1826
if (json?.data?.translations && typeof json.data.translations === 'object') {
19-
return json.data.translations as Record<string, unknown>;
27+
translations = json.data.translations as Record<string, unknown>;
28+
} else {
29+
// Fallback: mock server / local dev returns flat translation objects
30+
translations = json;
31+
}
32+
// Auto-transform @objectstack/spec TranslationData format when detected
33+
if (isSpecTranslationData(translations)) {
34+
return transformSpecTranslations(translations);
2035
}
21-
// Fallback: mock server / local dev returns flat translation objects
22-
return json;
36+
return translations;
2337
} catch (err) {
2438
console.warn(`[i18n] Failed to load translations for '${lang}':`, err);
2539
return {};
2640
}
2741
}
42+
43+
/**
44+
* Detect whether the data uses the @objectstack/spec `TranslationData` format.
45+
*
46+
* TranslationData has: `objects.{name}.fields.{field}.label` (nested objects).
47+
* The flat format has: `{namespace}.objects.{name}.label` + `{namespace}.fields.{name}.{field}` (string).
48+
*
49+
* We check if `data.objects` exists AND at least one object has a nested `fields`
50+
* key whose values are objects (not strings).
51+
*/
52+
function isSpecTranslationData(data: Record<string, unknown>): boolean {
53+
const objects = data.objects;
54+
if (!objects || typeof objects !== 'object' || Array.isArray(objects)) return false;
55+
for (const obj of Object.values(objects as Record<string, unknown>)) {
56+
if (obj && typeof obj === 'object' && !Array.isArray(obj) && 'fields' in obj) {
57+
return true;
58+
}
59+
}
60+
return false;
61+
}
62+
63+
/**
64+
* Transform `TranslationData` (objectstack/spec) into the flat format
65+
* expected by `useObjectLabel` (object-ui/i18n).
66+
*
67+
* The result is wrapped under an `app` namespace key so that
68+
* `useObjectLabel.getAppNamespaces()` discovers it via the presence
69+
* of `objects` and `fields` sub-keys.
70+
*/
71+
function transformSpecTranslations(data: Record<string, unknown>): Record<string, unknown> {
72+
const objects: Record<string, unknown> = {};
73+
const fields: Record<string, Record<string, string>> = {};
74+
const fieldOptions: Record<string, Record<string, Record<string, string>>> = {};
75+
76+
const srcObjects = data.objects as Record<string, any> | undefined;
77+
if (srcObjects) {
78+
for (const [objName, objData] of Object.entries(srcObjects)) {
79+
if (!objData || typeof objData !== 'object') continue;
80+
81+
// Object-level metadata
82+
const obj: Record<string, unknown> = { label: objData.label };
83+
if (objData.pluralLabel) obj.pluralLabel = objData.pluralLabel;
84+
objects[objName] = obj;
85+
86+
// Flatten fields: objects.X.fields.Y.label → fields.X.Y = string
87+
if (objData.fields && typeof objData.fields === 'object') {
88+
fields[objName] = {};
89+
for (const [fieldName, fieldData] of Object.entries(objData.fields as Record<string, any>)) {
90+
if (fieldData?.label) fields[objName][fieldName] = fieldData.label;
91+
if (fieldData?.options && typeof fieldData.options === 'object' && Object.keys(fieldData.options).length > 0) {
92+
if (!fieldOptions[objName]) fieldOptions[objName] = {};
93+
fieldOptions[objName][fieldName] = fieldData.options;
94+
}
95+
}
96+
}
97+
}
98+
}
99+
100+
const appNs: Record<string, unknown> = {};
101+
if (Object.keys(objects).length > 0) appNs.objects = objects;
102+
if (Object.keys(fields).length > 0) appNs.fields = fields;
103+
if (Object.keys(fieldOptions).length > 0) appNs.fieldOptions = fieldOptions;
104+
if (data.apps) appNs.apps = data.apps;
105+
if (data.messages) appNs.messages = data.messages;
106+
if (data.validationMessages) appNs.validationMessages = data.validationMessages;
107+
108+
return { app: appNs };
109+
}

0 commit comments

Comments
 (0)