Skip to content

Commit f4be7ea

Browse files
committed
refactor: extract i18n loader with spec format detection
1 parent fc73358 commit f4be7ea

3 files changed

Lines changed: 129 additions & 12 deletions

File tree

apps/dashboard/src/index.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,20 @@
121121
color: hsl(var(--foreground));
122122
}
123123
}
124+
125+
/*
126+
* Compatibility shim: @object-ui/* packages are built with Tailwind v3 and
127+
* emit important utilities using the legacy `!` prefix (e.g. `!flex-col`).
128+
* Tailwind v4 expects the suffix syntax (`flex-col!`) and silently drops the
129+
* v3 form, leaving the SidebarProvider wrapper as `flex-direction: row` and
130+
* pushing the dashboard main content off-screen. Re-declare the handful of
131+
* legacy classes we depend on until the upstream packages migrate.
132+
*/
133+
@layer utilities {
134+
.\!flex-col {
135+
flex-direction: column !important;
136+
}
137+
.\!m-0 {
138+
margin: 0 !important;
139+
}
140+
}

apps/dashboard/src/loadLanguage.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Load application-specific translations for a given language from the API.
3+
*
4+
* The @objectstack/spec REST API (`/api/v1/i18n/translations/:locale`) wraps
5+
* its response in the standard envelope: `{ data: { locale, translations } }`.
6+
* We extract `data.translations` when present, and fall back to the raw JSON
7+
* 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
15+
*/
16+
export async function loadLanguage(lang: string): Promise<Record<string, unknown>> {
17+
try {
18+
const serverUrl = import.meta.env.VITE_SERVER_URL || '';
19+
const res = await fetch(`${serverUrl}/api/v1/i18n/translations/${lang}`);
20+
if (!res.ok) {
21+
console.warn(`[i18n] Failed to load translations for '${lang}': HTTP ${res.status}`);
22+
return {};
23+
}
24+
const json = await res.json();
25+
// Unwrap the spec REST API envelope when present
26+
let translations: Record<string, unknown>;
27+
if (json?.data?.translations && typeof json.data.translations === 'object') {
28+
translations = json.data.translations as Record<string, unknown>;
29+
} else {
30+
// Fallback: mock server / local dev returns flat translation objects
31+
translations = json;
32+
}
33+
// Auto-transform @objectstack/spec TranslationData format when detected
34+
if (isSpecTranslationData(translations)) {
35+
return transformSpecTranslations(translations);
36+
}
37+
return translations;
38+
} catch (err) {
39+
console.warn(`[i18n] Failed to load translations for '${lang}':`, err);
40+
return {};
41+
}
42+
}
43+
44+
/**
45+
* Detect whether the data uses the @objectstack/spec `TranslationData` format.
46+
*
47+
* TranslationData has: `objects.{name}.fields.{field}.label` (nested objects).
48+
* The flat format has: `{namespace}.objects.{name}.label` + `{namespace}.fields.{name}.{field}` (string).
49+
*
50+
* We check if `data.objects` exists AND at least one object has a nested `fields`
51+
* key whose values are objects (not strings).
52+
*/
53+
function isSpecTranslationData(data: Record<string, unknown>): boolean {
54+
const objects = data.objects;
55+
if (!objects || typeof objects !== 'object' || Array.isArray(objects)) return false;
56+
for (const obj of Object.values(objects as Record<string, unknown>)) {
57+
if (obj && typeof obj === 'object' && !Array.isArray(obj) && 'fields' in obj) {
58+
return true;
59+
}
60+
}
61+
return false;
62+
}
63+
64+
/**
65+
* Transform `TranslationData` (objectstack/spec) into the flat format
66+
* expected by `useObjectLabel` (object-ui/i18n).
67+
*
68+
* The result is wrapped under an `app` namespace key so that
69+
* `useObjectLabel.getAppNamespaces()` discovers it via the presence
70+
* of `objects` and `fields` sub-keys.
71+
*/
72+
function transformSpecTranslations(data: Record<string, unknown>): Record<string, unknown> {
73+
const objects: Record<string, unknown> = {};
74+
const fields: Record<string, Record<string, string>> = {};
75+
const fieldOptions: Record<string, Record<string, Record<string, string>>> = {};
76+
77+
const srcObjects = data.objects as Record<string, any> | undefined;
78+
if (srcObjects) {
79+
for (const [objName, objData] of Object.entries(srcObjects)) {
80+
if (!objData || typeof objData !== 'object') continue;
81+
82+
// Object-level metadata
83+
const obj: Record<string, unknown> = { label: objData.label };
84+
if (objData.pluralLabel) obj.pluralLabel = objData.pluralLabel;
85+
if (objData.description) obj.description = objData.description;
86+
objects[objName] = obj;
87+
88+
// Flatten fields: objects.X.fields.Y.label → fields.X.Y = string
89+
if (objData.fields && typeof objData.fields === 'object') {
90+
fields[objName] = {};
91+
for (const [fieldName, fieldData] of Object.entries(objData.fields as Record<string, any>)) {
92+
if (fieldData?.label) fields[objName][fieldName] = fieldData.label;
93+
if (fieldData?.options && typeof fieldData.options === 'object' && Object.keys(fieldData.options).length > 0) {
94+
if (!fieldOptions[objName]) fieldOptions[objName] = {};
95+
fieldOptions[objName][fieldName] = fieldData.options;
96+
}
97+
}
98+
}
99+
}
100+
}
101+
102+
const appNs: Record<string, unknown> = {};
103+
if (Object.keys(objects).length > 0) appNs.objects = objects;
104+
if (Object.keys(fields).length > 0) appNs.fields = fields;
105+
if (Object.keys(fieldOptions).length > 0) appNs.fieldOptions = fieldOptions;
106+
if (data.apps) appNs.apps = data.apps;
107+
if (data.messages) appNs.messages = data.messages;
108+
if (data.validationMessages) appNs.validationMessages = data.validationMessages;
109+
110+
return { app: appNs };
111+
}

apps/dashboard/src/main.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,7 @@ import '@object-ui/plugin-report';
1818

1919
import './index.css';
2020
import { App } from './App';
21-
22-
async function loadLanguage(lang: string): Promise<Record<string, unknown>> {
23-
try {
24-
const serverUrl = import.meta.env.VITE_SERVER_URL || '';
25-
const res = await fetch(`${serverUrl}/api/v1/i18n/translations/${lang}`);
26-
if (!res.ok) return {};
27-
const json = await res.json();
28-
return json?.data?.translations ?? json ?? {};
29-
} catch {
30-
return {};
31-
}
32-
}
21+
import { loadLanguage } from './loadLanguage';
3322

3423
ReactDOM.createRoot(document.getElementById('root')!).render(
3524
<React.StrictMode>

0 commit comments

Comments
 (0)