Skip to content

Commit 093bde7

Browse files
committed
Add preferences support for i18n and normalize translations in components
1 parent bc2a8ac commit 093bde7

20 files changed

Lines changed: 272 additions & 42 deletions

File tree

packages/i18n/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export * from './translations';
2424

2525
// Utils
2626
export {default as getDefaultI18nBundles} from './utils/getDefaultI18nBundles';
27+
export {default as normalizeTranslations} from './utils/normalizeTranslations';
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {I18nTranslations} from '../models/i18n';
20+
21+
/**
22+
* Accepts translations in either flat or namespaced format and normalizes them
23+
* to the flat format required by the SDK.
24+
*
25+
* Flat format (already correct):
26+
* ```ts
27+
* { "signin.heading": "Sign In" }
28+
* ```
29+
*
30+
* Namespaced format (auto-converted):
31+
* ```ts
32+
* { signin: { heading: "Sign In" } }
33+
* ```
34+
*
35+
* Both formats can be mixed within the same object — a top-level string value
36+
* is kept as-is, while a top-level object value is flattened one level deep
37+
* using `"namespace.key"` concatenation.
38+
*
39+
* @param translations - Translations in flat or namespaced format.
40+
* @returns Normalized flat translations compatible with `I18nTranslations`.
41+
*/
42+
const normalizeTranslations = (
43+
translations: Record<string, string | Record<string, string>> | null | undefined,
44+
): I18nTranslations => {
45+
if (!translations || typeof translations !== 'object') {
46+
return {} as unknown as I18nTranslations;
47+
}
48+
49+
const result: Record<string, string> = {};
50+
51+
Object.entries(translations).forEach(([topKey, value]: [string, string | Record<string, string>]) => {
52+
if (typeof value === 'string') {
53+
// Already flat — keep as-is (e.g., "signin.heading": "Sign In")
54+
result[topKey] = value;
55+
} else if (value !== null && typeof value === 'object') {
56+
// Namespaced — flatten one level (e.g., signin: { heading: "Sign In" } → "signin.heading": "Sign In")
57+
Object.entries(value).forEach(([subKey, subValue]: [string, string]) => {
58+
if (typeof subValue === 'string') {
59+
result[`${topKey}.${subKey}`] = subValue;
60+
}
61+
});
62+
}
63+
});
64+
65+
return result as unknown as I18nTranslations;
66+
};
67+
68+
export default normalizeTranslations;

packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* under the License.
1717
*/
1818

19-
import {CreateOrganizationPayload, createPackageComponentLogger} from '@asgardeo/browser';
19+
import {CreateOrganizationPayload, createPackageComponentLogger, Preferences} from '@asgardeo/browser';
2020
import {cx} from '@emotion/css';
2121
import {ChangeEvent, CSSProperties, FC, FormEvent, ReactElement, ReactNode, useState} from 'react';
2222
import useStyles from './BaseCreateOrganization.styles';
@@ -62,6 +62,13 @@ export interface BaseCreateOrganizationProps {
6262
renderAdditionalFields?: () => ReactNode;
6363
style?: CSSProperties;
6464
title?: string;
65+
66+
/**
67+
* Component-level preferences to override global i18n and theme settings.
68+
* Preferences are deep-merged with global ones, with component preferences
69+
* taking precedence. Affects this component and all its descendants.
70+
*/
71+
preferences?: Preferences;
6572
}
6673

6774
/**
@@ -95,13 +102,14 @@ export const BaseCreateOrganization: FC<BaseCreateOrganizationProps> = ({
95102
onSubmit,
96103
onSuccess,
97104
open = false,
105+
preferences,
98106
renderAdditionalFields,
99107
style,
100108
title = 'Create Organization',
101109
}: BaseCreateOrganizationProps): ReactElement => {
102110
const {theme, colorScheme} = useTheme();
103111
const styles: ReturnType<typeof useStyles> = useStyles(theme, colorScheme);
104-
const {t} = useTranslation();
112+
const {t} = useTranslation(preferences?.i18n);
105113
const [formData, setFormData] = useState<OrganizationFormData>({
106114
description: '',
107115
handle: '',

packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* under the License.
1717
*/
1818

19-
import {AllOrganizationsApiResponse, Organization} from '@asgardeo/browser';
19+
import {AllOrganizationsApiResponse, Organization, Preferences} from '@asgardeo/browser';
2020
import {cx} from '@emotion/css';
2121
import {CSSProperties, FC, MouseEvent, ReactElement, ReactNode, useMemo} from 'react';
2222
import useStyles from './BaseOrganizationList.styles';
@@ -120,6 +120,13 @@ export interface BaseOrganizationListProps {
120120
* Title for the popup dialog (only used in popup mode)
121121
*/
122122
title?: string;
123+
124+
/**
125+
* Component-level preferences to override global i18n and theme settings.
126+
* Preferences are deep-merged with global ones, with component preferences
127+
* taking precedence. Affects this component and all its descendants.
128+
*/
129+
preferences?: Preferences;
123130
}
124131

125132
/**
@@ -269,10 +276,11 @@ export const BaseOrganizationList: FC<BaseOrganizationListProps> = ({
269276
style,
270277
title = 'Organizations',
271278
showStatus,
279+
preferences,
272280
}: BaseOrganizationListProps): ReactElement => {
273281
const {theme, colorScheme} = useTheme();
274282
const styles: ReturnType<typeof useStyles> = useStyles(theme, colorScheme);
275-
const {t} = useTranslation();
283+
const {t} = useTranslation(preferences?.i18n);
276284

277285
const organizationsWithSwitchAccess: OrganizationWithSwitchAccess[] = useMemo(() => {
278286
if (!allOrganizations?.organizations) {

packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* under the License.
1717
*/
1818

19-
import {OrganizationDetails, formatDate} from '@asgardeo/browser';
19+
import {OrganizationDetails, formatDate, Preferences} from '@asgardeo/browser';
2020
import {cx} from '@emotion/css';
2121
import {FC, ReactElement, ReactNode, useState, useCallback} from 'react';
2222
import useStyles from './BaseOrganizationProfile.styles';
@@ -108,6 +108,13 @@ export interface BaseOrganizationProfileProps {
108108
* Custom title for the profile.
109109
*/
110110
title?: string;
111+
112+
/**
113+
* Component-level preferences to override global i18n and theme settings.
114+
* Preferences are deep-merged with global ones, with component preferences
115+
* taking precedence. Affects this component and all its descendants.
116+
*/
117+
preferences?: Preferences;
111118
}
112119

113120
/**

packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* under the License.
1717
*/
1818

19-
import {OrganizationDetails, createPackageComponentLogger} from '@asgardeo/browser';
19+
import {OrganizationDetails, createPackageComponentLogger, Preferences} from '@asgardeo/browser';
2020
import {FC, ReactElement, useEffect, useState} from 'react';
2121
import BaseOrganizationProfile, {BaseOrganizationProfileProps} from './BaseOrganizationProfile';
2222
import getOrganization from '../../../api/getOrganization';
@@ -144,10 +144,11 @@ const OrganizationProfile: FC<OrganizationProfileProps> = ({
144144
popupTitle,
145145
loadingFallback,
146146
errorFallback,
147+
preferences,
147148
...rest
148149
}: OrganizationProfileProps): ReactElement => {
149150
const {baseUrl, instanceId} = useAsgardeo();
150-
const {t} = useTranslation();
151+
const {t} = useTranslation(preferences?.i18n);
151152
const [organization, setOrganization] = useState<OrganizationDetails | null>(null);
152153

153154
const fetchOrganization = async (): Promise<void> => {
@@ -206,6 +207,7 @@ const OrganizationProfile: FC<OrganizationProfileProps> = ({
206207
open={open}
207208
onOpenChange={onOpenChange}
208209
title={popupTitle || t('organization.profile.heading')}
210+
preferences={preferences}
209211
{...rest}
210212
/>
211213
);

packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
*/
1818

1919
// Removed BEM and vendor prefix utilities
20+
import {Preferences} from '@asgardeo/browser';
2021
import {cx} from '@emotion/css';
2122
import {
2223
useFloating,
@@ -159,6 +160,13 @@ export interface BaseOrganizationSwitcherProps {
159160
* Custom styles for the component.
160161
*/
161162
style?: CSSProperties;
163+
164+
/**
165+
* Component-level preferences to override global i18n and theme settings.
166+
* Preferences are deep-merged with global ones, with component preferences
167+
* taking precedence. Affects this component and all its descendants.
168+
*/
169+
preferences?: Preferences;
162170
}
163171

164172
/**
@@ -185,12 +193,13 @@ export const BaseOrganizationSwitcher: FC<BaseOrganizationSwitcherProps> = ({
185193
showTriggerLabel = true,
186194
avatarSize = 24,
187195
fallback = null,
196+
preferences,
188197
}: BaseOrganizationSwitcherProps): ReactElement => {
189198
const {theme, colorScheme, direction} = useTheme();
190199
const styles: Record<string, string> = useStyles(theme, colorScheme);
191200
const [isOpen, setIsOpen] = useState(false);
192201
const [hoveredItemIndex, setHoveredItemIndex] = useState<number | null>(null);
193-
const {t} = useTranslation();
202+
const {t} = useTranslation(preferences?.i18n);
194203
const isRTL: boolean = direction === 'rtl';
195204

196205
const {refs, floatingStyles, context} = useFloating({

packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* under the License.
1717
*/
1818

19+
import {Preferences} from '@asgardeo/browser';
1920
import {FC, ReactElement, useState} from 'react';
2021

2122
import {
@@ -87,6 +88,7 @@ export const OrganizationSwitcher: FC<OrganizationSwitcherProps> = ({
8788
fallback = null,
8889
onOrganizationSwitch: propOnOrganizationSwitch,
8990
organizations: propOrganizations,
91+
preferences,
9092
...props
9193
}: OrganizationSwitcherProps): ReactElement => {
9294
const {isSignedIn} = useAsgardeo();
@@ -100,7 +102,7 @@ export const OrganizationSwitcher: FC<OrganizationSwitcherProps> = ({
100102
const [isCreateOrgOpen, setIsCreateOrgOpen] = useState(false);
101103
const [isProfileOpen, setIsProfileOpen] = useState(false);
102104
const [isOrganizationListOpen, setIsOrganizationListOpen] = useState(false);
103-
const {t} = useTranslation();
105+
const {t} = useTranslation(preferences?.i18n);
104106

105107
if (!isSignedIn && fallback) {
106108
return fallback;
@@ -155,6 +157,7 @@ export const OrganizationSwitcher: FC<OrganizationSwitcherProps> = ({
155157
error={error}
156158
menuItems={menuItems}
157159
onManageProfile={handleManageOrganization}
160+
preferences={preferences}
158161
{...props}
159162
/>
160163
<CreateOrganization

packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* under the License.
1717
*/
1818

19-
import {User, withVendorCSSClassPrefix, WellKnownSchemaIds, bem} from '@asgardeo/browser';
19+
import {User, withVendorCSSClassPrefix, WellKnownSchemaIds, bem, Preferences} from '@asgardeo/browser';
2020
import {cx} from '@emotion/css';
2121
import {FC, ReactElement, useState, useCallback} from 'react';
2222
import useStyles from './BaseUserProfile.styles';
@@ -82,6 +82,13 @@ export interface BaseUserProfileProps {
8282
schemas?: Schema[];
8383
showFields?: string[];
8484
title?: string;
85+
86+
/**
87+
* Component-level preferences to override global i18n and theme settings.
88+
* Preferences are deep-merged with global ones, with component preferences
89+
* taking precedence. Affects this component and all its descendants.
90+
*/
91+
preferences?: Preferences;
8592
}
8693

8794
// Fields to skip based on schema.name
@@ -125,14 +132,15 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
125132
open = false,
126133
error = null,
127134
isLoading = false,
135+
preferences,
128136
showFields = [],
129137
hideFields = [],
130138
displayNameAttributes = [],
131139
}: BaseUserProfileProps): ReactElement => {
132140
const {theme, colorScheme} = useTheme();
133141
const [editedUser, setEditedUser] = useState(flattenedProfile || profile);
134142
const [editingFields, setEditingFields] = useState<Record<string, boolean>>({});
135-
const {t} = useTranslation();
143+
const {t} = useTranslation(preferences?.i18n);
136144

137145
/**
138146
* Determines if a field should be visible based on showFields, hideFields, and fieldsToSkip arrays.

packages/react/src/components/presentation/UserProfile/UserProfile.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* under the License.
1717
*/
1818

19-
import {AsgardeoError, User} from '@asgardeo/browser';
19+
import {AsgardeoError, User, Preferences} from '@asgardeo/browser';
2020
import {FC, ReactElement, useState} from 'react';
2121
// eslint-disable-next-line import/no-named-as-default
2222
import BaseUserProfile, {BaseUserProfileProps} from './BaseUserProfile';
@@ -64,10 +64,10 @@ export type UserProfileProps = Omit<BaseUserProfileProps, 'user' | 'profile' | '
6464
* />
6565
* ```
6666
*/
67-
const UserProfile: FC<UserProfileProps> = ({...rest}: UserProfileProps): ReactElement => {
67+
const UserProfile: FC<UserProfileProps> = ({preferences, ...rest}: UserProfileProps): ReactElement => {
6868
const {baseUrl, instanceId} = useAsgardeo();
6969
const {profile, flattenedProfile, schemas, onUpdateProfile} = useUser();
70-
const {t} = useTranslation();
70+
const {t} = useTranslation(preferences?.i18n);
7171

7272
const [error, setError] = useState<string | null>(null);
7373

@@ -95,6 +95,7 @@ const UserProfile: FC<UserProfileProps> = ({...rest}: UserProfileProps): ReactEl
9595
schemas={schemas}
9696
onUpdate={handleProfileUpdate}
9797
error={error}
98+
preferences={preferences}
9899
{...rest}
99100
/>
100101
);

0 commit comments

Comments
 (0)