Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gen3: Use i18next instead of loc #3689

Open
wants to merge 34 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
51d69a0
poc use i18next
denysoblohin-okta Aug 9, 2024
564a821
lint fix
denysoblohin-okta Aug 9, 2024
4a07d89
set lang
denysoblohin-okta Aug 9, 2024
fc43412
don't conflict with odyssey
denysoblohin-okta Aug 9, 2024
74f3664
lint fix, no escape
denysoblohin-okta Aug 9, 2024
b03b8c7
lint fix
denysoblohin-okta Aug 9, 2024
56e7bec
fix fallback
denysoblohin-okta Aug 9, 2024
ff70cc8
fix transl
denysoblohin-okta Aug 9, 2024
ae2fb11
fix undefined bundle; emit lion error
denysoblohin-okta Aug 9, 2024
5f880af
lint fix
denysoblohin-okta Aug 9, 2024
2036015
jest mock fix
denysoblohin-okta Aug 9, 2024
628337d
lint fix
denysoblohin-okta Aug 9, 2024
d9acbfd
fix jest?
denysoblohin-okta Aug 9, 2024
df454f1
logic fix
denysoblohin-okta Aug 9, 2024
03259a5
fix for default language
denysoblohin-okta Aug 23, 2024
ee7ceb4
fix lint, plural
denysoblohin-okta Aug 23, 2024
2f2ea39
use own i18next instance
denysoblohin-okta Aug 23, 2024
dedc21f
Override global `loc` util
denysoblohin-okta Aug 26, 2024
1d1a12d
ini i18next instance automatically
denysoblohin-okta Aug 26, 2024
603f8ba
don't remove resources @ dev
denysoblohin-okta Aug 26, 2024
02ed5a4
fix tests
denysoblohin-okta Aug 26, 2024
913dfc0
fix test locUtil
denysoblohin-okta Aug 26, 2024
092db8b
.
denysoblohin-okta Aug 26, 2024
bcb74d6
fix test
denysoblohin-okta Aug 26, 2024
530dfbe
nit
denysoblohin-okta Aug 26, 2024
7d18b19
lint fix
denysoblohin-okta Aug 26, 2024
f662523
upd sizes
denysoblohin-okta Aug 27, 2024
62404a8
remove plural forms
denysoblohin-okta Aug 27, 2024
a73b129
remove getLocUtil
denysoblohin-okta Aug 27, 2024
bb4163a
move initDefaultLanguage into effect
denysoblohin-okta Aug 27, 2024
4153b35
move initDefaultLanguage to useOnce
denysoblohin-okta Aug 27, 2024
5e35244
nit
denysoblohin-okta Aug 27, 2024
c26d3b3
added tests
denysoblohin-okta Aug 27, 2024
118d13c
add test, lint fix
denysoblohin-okta Aug 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/buildtools/commands/verify-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const EXPECTED_BUNDLE_SIZES = {
'okta-sign-in.oie.min.js': 1.3 * MB,
'okta-sign-in.polyfill.js': 504 * KB,
'okta-sign-in.polyfill.min.js': 108 * KB,
'okta-sign-in.next.js': 1.7 * MB,
'okta-sign-in.next.no-polyfill.js': 1.8 * MB,
'okta-sign-in.next.js': 1.8 * MB,
'okta-sign-in.next.no-polyfill.js': 1.7 * MB,
'okta-sign-in.debugger.min.js': 170 * KB,
};

Expand Down
8 changes: 6 additions & 2 deletions src/util/Bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,17 @@ export default {
this.currentLanguage = null;
},

loadLanguage: async function(language: string, overrides: i18nOptions, assets: Assets, supportedLanguages: string[]): Promise<void> {
loadLanguage: async function(
language: string, overrides: i18nOptions, assets: Assets, supportedLanguages: string[],
omitDefaultKeys?: (key: string) => boolean
): Promise<void> {
const parsedOverrides = parseOverrides(overrides);
const lowerCaseLanguage = language.toLowerCase();
const bundles = await getBundles(language, assets, supportedLanguages);
// Always extend from the built in defaults in the event that some
// properties are not translated
this.login = _.extend({}, login, bundles.login);
const loginFiltered = omitDefaultKeys ? _.omit(login, (_, k) => omitDefaultKeys(k)) : login;
this.login = _.extend({}, loginFiltered, bundles.login);
this.country = _.extend({}, country, bundles.country);
this.courage = _.extend({}, login, bundles.login);
if (parsedOverrides[lowerCaseLanguage]) {
Expand Down
21 changes: 19 additions & 2 deletions src/util/loc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ declare global {
* @param {Boolean} [ignoreIncorrectParams] If true, a custom 'okta-i18n-error' event would not be dispatched
* @return {String} The localized value
*/
export const loc = function (
const localize = function (
key: string,
bundleName: BundleName = 'login',
params: Array<string | number | boolean | unknown> = [],
Expand Down Expand Up @@ -128,7 +128,7 @@ function getRawLocale() {
* @param {String} bundleName The i18n bundle name
* @param {String} reason Could be 'bundle' (Bundle not found), 'key' (Key not found) or 'parameters' (Parameters mismatch).
*/
function emitL10nError(key: string, bundleName: string, reason: string) {
export function emitL10nError(key: string, bundleName: string, reason: string) {
const event = createCustomEvent('okta-i18n-error', {
detail: {
type: 'l10n-error',
Expand Down Expand Up @@ -193,3 +193,20 @@ function sprintf(value: string, params: Array<string | number | boolean | unknow

return newValue;
}

/**
* Add ability to override global `loc` util used in `v2/ion/*`, `util/*` etc.
*/
type Loc = (
key: string,
bundleName?: string,
params?: Array<string | number | boolean | undefined>
) => string;

let loc: Loc = localize;

export const setLocUtil = (newLoc: Loc) => {
loc = newLoc;
};

export { loc };
12 changes: 8 additions & 4 deletions src/v3/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
toHaveBeenCalledBefore,
} from 'jest-extended';
import mockBundles from '../util/Bundles.ts';
import { setLocUtil } from '../util/loc.ts';

expect.extend({
toBeFalse,
Expand All @@ -40,9 +41,12 @@ global.DEBUG = false;

expect.addSnapshotSerializer(createSerializer({ includeStyles: false }));

jest.mock('util/loc', () => ({
loc: jest.fn().mockImplementation(
jest.mock('src/util/locUtil', () => {
const originalModule = jest.requireActual('src/util/locUtil');
const loc = jest.fn().mockImplementation(
// eslint-disable-next-line no-unused-vars
(key, bundle, params) => (mockBundles.login[key] ? key : new Error(`Invalid i18n key: ${key}`)),
),
}));
);
setLocUtil(loc);
return { ...originalModule, loc };
});
1 change: 1 addition & 0 deletions src/v3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"dompurify": "^2.5.5",
"duo_web_sdk": "https://github.com/duosecurity/duo_web_sdk.git",
"html-react-parser": "^3.0.9",
"i18next": "^23.14.0",
"js-cookie": "^3.0.1",
"lodash": "^4.17.21",
"preact": "^10.20.1",
Expand Down
3 changes: 1 addition & 2 deletions src/v3/src/components/AuthHeader/AuthHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ import Image from '../Image';

// TODO: maybe extract to util class if used reused
const shouldRenderAuthCoin = (props?: AuthCoinProps): boolean => {
const authCoinConfiguration = getAuthCoinConfiguration();
const authCoinConfigByAuthKey = props?.authenticatorKey
&& authCoinConfiguration[props?.authenticatorKey];
&& getAuthCoinConfiguration()[props.authenticatorKey];
Comment on lines -25 to +26
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a necessary change, just small optimisation.
Noticed that for pages that don't have auth coin there will be a lot of unnecessary loc calls inside getAuthCoinConfiguration()


return typeof authCoinConfigByAuthKey !== 'undefined'
|| typeof props?.url !== 'undefined';
Expand Down
9 changes: 9 additions & 0 deletions src/v3/src/components/Widget/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
getLanguageCode,
getLanguageDirection,
getOdysseyTranslationOverrides,
initDefaultLanguage,
isAndroidOrIOS,
isAuthClientSet,
isConfigRegisterFlow,
Expand All @@ -72,6 +73,7 @@ import {
loadLanguage,
SessionStorage,
triggerEmailVerifyCallback,
unloadLanguage,
} from '../../util';
import { getEventContext } from '../../util/getEventContext';
import { stylisPlugins } from '../../util/stylisPlugins';
Expand Down Expand Up @@ -149,10 +151,17 @@ export const Widget: FunctionComponent<WidgetProps> = (widgetProps) => {
};
}, [brandColors, customTheme, languageDirection]);

useOnce(() => {
// Load default language.
// Should be called before initial render
initDefaultLanguage();
});

// on unmount, remove the language
useEffect(() => () => {
if (Bundles.isLoaded(languageCode)) {
Bundles.remove();
unloadLanguage(languageCode);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { PASSWORD_REQUIREMENTS_KEYS } from '../../constants';
import { ComplexityRequirements } from '../../types';
import { getComplexityItems } from './passwordSettingsUtils';

jest.mock('util/loc', () => ({
jest.mock('../../util/locUtil', () => ({
loc: jest.fn().mockImplementation(
(key) => key,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { loc } from './locUtil';

export const getPasswordExpiryContentTitleAndParams = (daysToExpiry = -1): TitleElement['options'] => {
if (daysToExpiry > 0) {
return { content: loc('password.expiring.title', 'login', [`${daysToExpiry}`]) };
return { content: loc('password.expiring.title', 'login', [daysToExpiry]) };
}

if (daysToExpiry === 0) {
Expand Down
40 changes: 40 additions & 0 deletions src/v3/src/util/i18next.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2022-present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/

import i18next from 'i18next';

import config from '../../../config/config.json';

const i18ni = i18next.createInstance();

const ns = ['login', 'country'];
const defaultNS = 'login';

i18ni.init({
defaultNS,
ns,
fallbackLng: config.defaultLanguage,
load: 'currentOnly',
keySeparator: false,
nsSeparator: ':',
interpolation: {
prefix: '{',
suffix: '}',
// No need to escape
// Need to use raw value for phone numbers containing `&lrm;`
// React is already safe from XSS
escapeValue: false,
skipOnVariables: false, // to handle translations that use nesting
},
});

export { i18ni as i18next };
39 changes: 37 additions & 2 deletions src/v3/src/util/languageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,24 @@

import { OdysseyI18nResourceKeys, odysseyI18nResourceKeysList } from '@okta/odyssey-react-mui';

import config from '../../../config/config.json';
import { LanguageCode } from '../../../types';
import Bundles from '../../../util/Bundles';
import { WidgetProps } from '../types';
import { isDevelopmentEnvironment } from './environmentUtils';
import { i18next } from './i18next';
import { getLanguageCode, getSupportedLanguages } from './settingsUtils';

export const initDefaultLanguage = () => {
// Load translations for default language from Bundles to i18next
const languageCode = Bundles.currentLanguage ?? config.defaultLanguage;
const isDefaultLanguage = !Bundles.currentLanguage;
if (isDefaultLanguage && !i18next.hasResourceBundle(languageCode, 'login')) {
i18next.addResourceBundle(languageCode, 'login', Bundles.login);
i18next.addResourceBundle(languageCode, 'country', Bundles.country);
}
};

export const loadLanguage = async (widgetProps: WidgetProps): Promise<void> => {
const { i18n = {}, assets: { baseUrl, rewrite } = {} } = widgetProps;
const languageCode = getLanguageCode(widgetProps);
Expand All @@ -29,10 +43,31 @@ export const loadLanguage = async (widgetProps: WidgetProps): Promise<void> => {
assetsBaseUrl = assetsBaseUrl.substring(0, assetsBaseUrl.length - 1);
}

return Bundles.loadLanguage(languageCode, i18n, {
// Don't reuse plural forms in English for other languages.
// See https://www.i18next.com/translation-function/plurals
const pluralSuffixes = ['_one', '_other'];
const omitDefaultKeys = (key: string) => pluralSuffixes.some((s) => key.endsWith(s));
await Bundles.loadLanguage(languageCode, i18n, {
baseUrl: assetsBaseUrl,
rewrite: rewrite ?? ((val) => val),
}, supportedLanguages);
}, supportedLanguages, omitDefaultKeys);

// Load translations from Bundles to i18next and change language
i18next.addResourceBundle(languageCode, 'login', Bundles.login);
i18next.addResourceBundle(languageCode, 'country', Bundles.country);
i18next.changeLanguage(languageCode);
};

export const unloadLanguage = (languageCode: LanguageCode) => {
// Remove translations from i18next
if (languageCode !== config.defaultLanguage && !isDevelopmentEnvironment()) {
// For dev environment with HMR don't clear translations.
// Otherwise during HMR widget language will be reset to default one.
// `Bundles.remove()` also doesn't reset bundles to default language.
i18next.removeResourceBundle(languageCode, 'login');
i18next.removeResourceBundle(languageCode, 'country');
}
i18next.changeLanguage(undefined);
};

export const getOdysseyTranslationOverrides = (): Partial<OdysseyI18nResourceKeys> => (
Expand Down
Loading