0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(console,ui,demo-app): fix i18next types (#3743)

* refactor(console,ui,demo-app): fix i18next types

disable allowObjectInHTMLChildren for safer typing. use <DynamicT /> for
all dynamic translations.

* fix(console): i18n key in UserAccountInformation

* refactor(console,ui): update <DynamicT /> and add tests
This commit is contained in:
Gao Sun 2023-04-24 16:05:26 +08:00 committed by GitHub
parent ac95eb3ffa
commit 20418dc4f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 292 additions and 204 deletions

View file

@ -0,0 +1,33 @@
import type { Config } from '@jest/types';
const config: Config.InitialOptions = {
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
transform: {
'^.+\\.(t|j)sx?$': [
'@swc/jest',
{
sourceMaps: true,
jsc: {
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
'\\.(svg)$': 'jest-transformer-svg',
'\\.(png)$': 'jest-transform-stub',
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@logto/app-insights/(.*)$': '<rootDir>/../app-insights/lib/$1',
'^@logto/shared/(.*)$': '<rootDir>/../shared/lib/$1',
'\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
},
transformIgnorePatterns: ['node_modules/(?!(.*(nanoid|jose|ky|@logto|@silverhand))/)'],
};
export default config;

View file

@ -0,0 +1,9 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
void i18next.use(initReactI18next).init({
// Simple resources for testing
resources: { en: { translation: { admin_console: { general: { add: 'Add' } } } } },
lng: 'en',
react: { useSuspense: false },
});

View file

@ -18,10 +18,13 @@
"build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall --no-cache --public-url ${CONSOLE_PUBLIC_URL:-/console}",
"lint": "eslint --ext .ts --ext .tsx src",
"lint:report": "pnpm lint --format json --output-file report.json",
"stylelint": "stylelint \"src/**/*.scss\""
"stylelint": "stylelint \"src/**/*.scss\"",
"test:ci": "jest --coverage --silent",
"test": "jest"
},
"devDependencies": {
"@fontsource/roboto-mono": "^4.5.7",
"@jest/types": "^29.5.0",
"@logto/app-insights": "workspace:^1.2.0",
"@logto/connector-kit": "workspace:^1.1.1",
"@logto/core-kit": "workspace:^2.0.0",
@ -43,8 +46,12 @@
"@silverhand/essentials": "^2.5.0",
"@silverhand/ts-config": "3.0.0",
"@silverhand/ts-config-react": "3.0.0",
"@swc/core": "^1.3.52",
"@swc/jest": "^0.2.26",
"@testing-library/react": "^14.0.0",
"@tsconfig/docusaurus": "^1.0.5",
"@types/color": "^3.0.3",
"@types/jest": "^29.4.0",
"@types/mdx": "^2.0.1",
"@types/mdx-js__react": "^1.5.5",
"@types/react": "^18.0.31",
@ -67,6 +74,11 @@
"history": "^5.3.0",
"i18next": "^22.4.15",
"i18next-browser-languagedetector": "^7.0.1",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.0.0",
"jest-transform-stub": "^2.0.0",
"jest-transformer-svg": "^2.0.0",
"just-kebab-case": "^4.2.0",
"ky": "^0.33.0",
"lint-staged": "^13.0.0",

View file

@ -1,11 +1,11 @@
import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import Info from '@/assets/images/info.svg';
import Button from '../Button';
import DynamicT from '../DynamicT';
import TextLink from '../TextLink';
import * as styles from './index.module.scss';
@ -29,15 +29,17 @@ function Alert({
variant = 'plain',
className,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={classNames(styles.alert, styles[severity], styles[variant], className)}>
<div className={styles.icon}>
<Info />
</div>
<div className={styles.content}>{children}</div>
{action && href && <TextLink to={href}>{t(action)}</TextLink>}
{action && href && (
<TextLink to={href}>
<DynamicT forKey={action} />
</TextLink>
)}
{action && onClick && (
<div className={styles.action}>
<Button title={action} type="text" size="small" onClick={onClick} />

View file

@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { Ring as Spinner } from '@/components/Spinner';
import type DangerousRaw from '../DangerousRaw';
import DynamicT from '../DynamicT';
import * as styles from './index.module.scss';
@ -96,7 +97,14 @@ function Button({
>
{showSpinner && <Spinner className={styles.spinner} />}
{icon && <span className={styles.icon}>{icon}</span>}
{title && (typeof title === 'string' ? <span>{t(title)}</span> : title)}
{title &&
(typeof title === 'string' ? (
<span>
<DynamicT forKey={title} />
</span>
) : (
title
))}
{trailingIcon && <span className={styles.trailingIcon}>{trailingIcon}</span>}
</button>
);

View file

@ -4,6 +4,7 @@ import type { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import type DangerousRaw from '../DangerousRaw';
import DynamicT from '../DynamicT';
import TextLink from '../TextLink';
import * as styles from './index.module.scss';
@ -33,11 +34,13 @@ function CardTitle({
return (
<div className={classNames(styles.container, styles[size], className)}>
<div className={classNames(styles.title, !isWordWrapEnabled && styles.titleEllipsis)}>
{typeof title === 'string' ? t(title) : title}
{typeof title === 'string' ? <DynamicT forKey={title} /> : title}
</div>
{Boolean(subtitle ?? learnMoreLink) && (
<div className={styles.subtitle}>
{subtitle && <span>{typeof subtitle === 'string' ? t(subtitle) : subtitle}</span>}
{subtitle && (
<span>{typeof subtitle === 'string' ? <DynamicT forKey={subtitle} /> : subtitle}</span>
)}
{learnMoreLink && (
<TextLink href={learnMoreLink} target="_blank" className={styles.learnMore}>
{t('general.learn_more')}

View file

@ -1,13 +1,13 @@
import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { ReactElement, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import Back from '@/assets/images/back.svg';
import type { RequestError } from '@/hooks/use-api';
import type DangerousRaw from '../DangerousRaw';
import DetailsSkeleton from '../DetailsSkeleton';
import DynamicT from '../DynamicT';
import RequestDataError from '../RequestDataError';
import TextLink from '../TextLink';
@ -32,12 +32,10 @@ function DetailsPage({
children,
className,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={classNames(styles.container, className)}>
<TextLink to={backLink} icon={<Back />} className={styles.backLink}>
{typeof backLinkTitle === 'string' ? t(backLinkTitle) : backLinkTitle}
{typeof backLinkTitle === 'string' ? <DynamicT forKey={backLinkTitle} /> : backLinkTitle}
</TextLink>
{isLoading ? (
<DetailsSkeleton />

View file

@ -0,0 +1,22 @@
import { render } from '@testing-library/react';
import { t, type TFuncKey, type TypeOptions } from 'i18next';
import DynamicT from '.';
describe('<DynamicT />', () => {
it('should render a correct key', () => {
const key: TFuncKey<TypeOptions['defaultNS'], 'admin_console'> = 'general.add';
const { container } = render(<DynamicT forKey={key} />);
expect(container.innerHTML).toBe(t(`admin_console.${key}`));
});
it('should render an error message for a non-leaf key', () => {
const key: TFuncKey<TypeOptions['defaultNS'], 'admin_console'> = 'general';
const { container } = render(<DynamicT forKey={key} />);
expect(container.innerHTML).toBe(
`key 'admin_console.${key} (en)' returned an object instead of string.`
);
});
});

View file

@ -3,16 +3,29 @@ import { useTranslation } from 'react-i18next';
type Props = {
forKey: AdminConsoleKey;
interpolation?: Record<string, unknown>;
};
/**
* A component to render a dynamic translation key.
* Since `ReactNode` does not include vanilla objects while `JSX.Element` does. It's strange but no better way for now.
*
* @see https://github.com/i18next/i18next/issues/1852
*/
export default function DynamicT({ forKey }: Props) {
export default function DynamicT({ forKey, interpolation }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return <>{t(forKey)}</>;
/**
* The default return of `t()` is already string even if the given key is not a leaf key.
* For example:
*
* ```ts
* const translation = { foo: { bar: 'baz' } };
*
* t('foo.bar') // 'baz'
* t('foo') // 'key 'foo (en)' returned an object instead of string.'
* ```
*
* So actually there's no need to check key validity to make sure `t()` returns a string.
* But it seems the type definition is not correct for the function in `i18next`. Use this trick to
* bypass for now.
*/
return <>{t(forKey, interpolation ?? {})}</>;
}

View file

@ -21,7 +21,9 @@ function FormCard({ title, description, learnMoreLink, children }: Props) {
return (
<Card className={styles.container}>
<div className={styles.introduction}>
<div className={styles.title}>{t(title)}</div>
<div className={styles.title}>
<DynamicT forKey={title} />
</div>
{description && (
<div className={styles.description}>
<DynamicT forKey={description} />

View file

@ -17,6 +17,7 @@ import { Tooltip } from '@/components/Tip';
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import DynamicT from '../DynamicT';
import EmptyDataPlaceholder from '../EmptyDataPlaceholder';
import type { Props as PaginationProps } from '../Pagination';
import TablePlaceholder from '../Table/TablePlaceholder';
@ -101,7 +102,7 @@ function PermissionsTable({
* When the table is read-only, hide the delete button rather than the whole column to keep the table column spaces.
*/
isReadOnly ? null : (
<Tooltip content={<div>{t(deleteButtonTitle)}</div>}>
<Tooltip content={<DynamicT forKey={deleteButtonTitle} />}>
<IconButton
onClick={() => {
deleteHandler(scope);

View file

@ -2,7 +2,6 @@ import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { KeyboardEventHandler, ReactElement, ReactNode } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type DangerousRaw from '../DangerousRaw';
import DynamicT from '../DynamicT';
@ -49,8 +48,6 @@ function Radio({
disabledLabel,
icon,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const handleKeyPress: KeyboardEventHandler<HTMLDivElement> = useCallback(
(event) => {
if (isDisabled) {
@ -93,7 +90,7 @@ function Radio({
{title && (typeof title === 'string' ? <DynamicT forKey={title} /> : title)}
{isDisabled && disabledLabel && (
<div className={classNames(styles.indicator, styles.disabledLabel)}>
{t(disabledLabel)}
<DynamicT forKey={disabledLabel} />
</div>
)}
</div>

View file

@ -26,7 +26,9 @@ function TablePlaceholder({ image, imageDark, title, description, learnMoreLink,
return (
<div className={styles.placeholder}>
<div className={styles.image}>{theme === Theme.Light ? image : imageDark}</div>
<div className={styles.title}>{t(title)}</div>
<div className={styles.title}>
<DynamicT forKey={title} />
</div>
<div className={styles.description}>
<DynamicT forKey={description} />
{learnMoreLink && (

View file

@ -46,7 +46,7 @@ function UserAccountInformation({
primaryEmail && `${t('user_details.created_email')} ${primaryEmail}`,
primaryPhone && `${t('user_details.created_phone')} ${primaryPhone}`,
username && `${t('user_details.created_username')} ${username}`,
`${passwordLabel ?? t('user_details.created_username')} ${password}`
`${passwordLabel ?? t('user_details.created_password')} ${password}`
).join('\n');
await navigator.clipboard.writeText(content);

View file

@ -2,11 +2,11 @@ import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import ArrowRight from '@/assets/images/arrow-right.svg';
import Tick from '@/assets/images/tick.svg';
import { DropdownItem } from '@/components/Dropdown';
import DynamicT from '@/components/DynamicT';
import type { Option } from '@/components/Select';
import Spacer from '@/components/Spacer';
import { onKeyDownHandler } from '@/utils/a11y';
@ -32,7 +32,6 @@ function SubMenu<T extends string>({
selectedOption,
onItemClick,
}: Props<T>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const anchorRef = useRef<HTMLDivElement>(null);
const [showMenu, setShowMenu] = useState(false);
const mouseEnterTimeoutRef = useRef(0);
@ -67,7 +66,9 @@ function SubMenu<T extends string>({
}}
>
{icon && <span className={styles.icon}>{icon}</span>}
<span className={styles.title}>{t(title)}</span>
<span className={styles.title}>
<DynamicT forKey={title} />
</span>
<Spacer />
<ArrowRight className={styles.icon} />
<div className={classNames(styles.menu, showMenu && styles.visible)}>

View file

@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/components/Button';
import DynamicT from '@/components/DynamicT';
import ModalLayout from '@/components/ModalLayout';
import * as modalStyles from '@/scss/modal.module.scss';
@ -14,7 +14,6 @@ type Props = {
};
function Contact({ isOpen, onCancel }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const contacts = useContacts();
return (
@ -33,8 +32,12 @@ function Contact({ isOpen, onCancel }: Props) {
<ContactIcon />
</div>
<div className={styles.text}>
<div className={styles.title}>{t(title)}</div>
<div className={styles.description}>{t(description)}</div>
<div className={styles.title}>
<DynamicT forKey={title} />
</div>
<div className={styles.description}>
<DynamicT forKey={description} />
</div>
</div>
<div>
<Button

View file

@ -4,8 +4,6 @@ import type { LocalePhrase } from '@logto/phrases';
declare module 'i18next' {
interface CustomTypeOptions {
returnNull: false;
allowObjectInHTMLChildren: true;
resources: LocalePhrase;
}
}

View file

@ -1,6 +1,5 @@
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import DynamicT from '@/components/DynamicT';
import { Tooltip } from '@/components/Tip';
@ -23,10 +22,8 @@ function CardItem({
onClick,
className,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<Tooltip content={conditional(isDisabled && disabledTip && <>{t(disabledTip)}</>)}>
<Tooltip content={conditional(isDisabled && disabledTip && <DynamicT forKey={disabledTip} />)}>
<div
key={value}
role="button"
@ -55,10 +52,16 @@ function CardItem({
<div>
{typeof title === 'string' ? <DynamicT forKey={title} /> : title}
{trailingTag && (
<span className={classNames(styles.tag, styles.trailingTag)}>{t(trailingTag)}</span>
<span className={classNames(styles.tag, styles.trailingTag)}>
<DynamicT forKey={trailingTag} />
</span>
)}
</div>
{tag && <span className={styles.tag}>{t(tag)}</span>}
{tag && (
<span className={styles.tag}>
<DynamicT forKey={tag} />
</span>
)}
</div>
</div>
</Tooltip>

View file

@ -2,9 +2,9 @@ import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import { cloneElement } from 'react';
import type { ReactNode, ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import DynamicT from '@/components/DynamicT';
import * as styles from './index.module.scss';
@ -19,15 +19,17 @@ type Props = {
};
function ReachLogto({ title, description, icon, buttonTitle, buttonIcon, link, className }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={classNames(styles.reachLogto, className)}>
<div className={styles.reachLogtoInfo}>
{cloneElement(icon, { className: styles.reachLogtoIcon })}
<div>
<div className={styles.reachLogtoTitle}>{t(title)}</div>
<div className={styles.reachLogtoDescription}>{t(description)}</div>
<div className={styles.reachLogtoTitle}>
<DynamicT forKey={title} />
</div>
<div className={styles.reachLogtoDescription}>
<DynamicT forKey={description} />
</div>
</div>
</div>
<Button

View file

@ -26,7 +26,7 @@ function ConnectorTypeColumn({ connectorGroup: { type, connectors } }: Props) {
);
if (!firstStandardConnector) {
return <>{t(connectorTitlePlaceHolder[type])}</>;
return <span>{t(connectorTitlePlaceHolder[type])}</span>;
}
if (!connectorFactory) {

View file

@ -7,6 +7,7 @@ import CompleteIndicator from '@/assets/images/task-complete.svg';
import Button from '@/components/Button';
import Card from '@/components/Card';
import ConfirmModal from '@/components/ConfirmModal';
import DynamicT from '@/components/DynamicT';
import PageMeta from '@/components/PageMeta';
import Spacer from '@/components/Spacer';
import useUserPreferences from '@/hooks/use-user-preferences';
@ -73,8 +74,12 @@ function GetStarted() {
{!isComplete && <CardIcon className={styles.icon} />}
{isComplete && <CompleteIndicator className={styles.icon} />}
<div className={styles.wrapper}>
<div className={styles.title}>{t(title)}</div>
<div className={styles.subtitle}>{t(subtitle)}</div>
<div className={styles.title}>
<DynamicT forKey={title} />
</div>
<div className={styles.subtitle}>
<DynamicT forKey={subtitle} />
</div>
</div>
<Button
className={styles.button}

View file

@ -2,7 +2,6 @@ import type { AdminConsoleKey } from '@logto/phrases';
import type { Nullable } from '@silverhand/essentials';
import type { ReactElement } from 'react';
import { cloneElement } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import DynamicT from '@/components/DynamicT';
@ -34,7 +33,6 @@ function CardContent<T extends Nullable<boolean | string | Record<string, unknow
title,
data,
}: Props<T>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const defaultRenderer = (value: unknown) => (value ? <span>{String(value)}</span> : <NotSet />);
if (data.length === 0) {
@ -43,7 +41,9 @@ function CardContent<T extends Nullable<boolean | string | Record<string, unknow
return (
<div className={styles.container}>
<div className={styles.title}>{t(title)}</div>
<div className={styles.title}>
<DynamicT forKey={title} />
</div>
<table>
<tbody>
{data.map(({ key, icon, label, value, renderer = defaultRenderer, action }) => {

View file

@ -6,6 +6,7 @@ import ReactModal from 'react-modal';
import { useNavigate } from 'react-router-dom';
import Arrow from '@/assets/images/arrow-left.svg';
import DynamicT from '@/components/DynamicT';
import TextLink from '@/components/TextLink';
import * as modalStyles from '@/scss/modal.module.scss';
@ -40,7 +41,9 @@ function MainFlowLikeModal({ title, subtitle, subtitleProps, children, onClose,
>
{t('general.back')}
</TextLink>
<span className={styles.title}>{t(title)}</span>
<span className={styles.title}>
<DynamicT forKey={title} />
</span>
{subtitle && (
<span className={styles.subtitle}>
<Trans components={{ strong: <span className={styles.strong} /> }}>

View file

@ -10,6 +10,6 @@
},
"include": [
"src",
"jest.config.ts"
"jest.*.ts"
]
}

View file

@ -6,7 +6,6 @@ import { CustomTypeOptions } from 'react-i18next';
declare module 'react-i18next' {
interface CustomTypeOptions {
allowObjectInHTMLChildren: true;
resources: LocalePhrase;
}
}

View file

@ -1,6 +1,6 @@
import type { TFuncKey } from 'i18next';
import { useTranslation } from 'react-i18next';
import DynamicT from '@/components/DynamicT';
import NavBar from '@/components/NavBar';
import PageMeta from '@/components/PageMeta';
import usePlatform from '@/hooks/use-platform';
@ -26,7 +26,6 @@ const SecondaryPageLayout = ({
notification,
children,
}: Props) => {
const { t } = useTranslation();
const { isMobile } = usePlatform();
return (
@ -38,9 +37,13 @@ const SecondaryPageLayout = ({
)}
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.title}>{t(title, titleProps ?? {})}</div>
<div className={styles.title}>
<DynamicT forKey={title} interpolation={titleProps} />
</div>
{description && (
<div className={styles.description}>{t(description, descriptionProps ?? {})}</div>
<div className={styles.description}>
<DynamicT forKey={description} interpolation={descriptionProps} />
</div>
)}
</div>

View file

@ -1,7 +1,8 @@
import type { Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import type { TFuncKey } from 'i18next';
import { useTranslation } from 'react-i18next';
import DynamicT from '../DynamicT';
import * as styles from './index.module.scss';
@ -12,12 +13,14 @@ export type Props = {
};
const BrandingHeader = ({ logo, headline, className }: Props) => {
const { t } = useTranslation();
return (
<div className={classNames(styles.container, className)}>
{logo && <img className={styles.logo} alt="app logo" src={logo} crossOrigin="anonymous" />}
{headline && <div className={styles.headline}>{t(headline)}</div>}
{headline && (
<div className={styles.headline}>
<DynamicT forKey={headline} />
</div>
)}
</div>
);
};

View file

@ -1,7 +1,8 @@
import classNames from 'classnames';
import type { TFuncKey } from 'i18next';
import type { HTMLProps } from 'react';
import { useTranslation } from 'react-i18next';
import DynamicT from '../DynamicT';
import * as styles from './index.module.scss';
@ -32,8 +33,6 @@ const Button = ({
onClick,
...rest
}: Props) => {
const { t } = useTranslation();
return (
<button
disabled={isDisabled}
@ -48,7 +47,7 @@ const Button = ({
onClick={onClick}
{...rest}
>
{t(title, { ...i18nProps })}
<DynamicT forKey={title} interpolation={i18nProps} />
</button>
);
};

View file

@ -0,0 +1,26 @@
import { render } from '@testing-library/react';
import { t, type TFuncKey } from 'i18next';
import DynamicT from '.';
describe('<DynamicT />', () => {
it('should render empty string when no key passed', () => {
const { container } = render(<DynamicT />);
expect(container.innerHTML).toBe('');
});
it('should render a correct key', () => {
const key: TFuncKey = 'action.agree';
const { container } = render(<DynamicT forKey={key} />);
expect(container.innerHTML).toBe(t(key));
});
it('should render an error message for a non-leaf key', () => {
const key: TFuncKey = 'action';
const { container } = render(<DynamicT forKey={key} />);
expect(container.innerHTML).toBe(`key '${key} (en)' returned an object instead of string.`);
});
});

View file

@ -3,21 +3,29 @@ import { useTranslation } from 'react-i18next';
type Props = {
forKey?: TFuncKey;
interpolation?: Record<string, unknown>;
};
/**
* A component to render a dynamic translation key.
* Since `ReactNode` does not include vanilla objects while `JSX.Element` does. It's strange but no better way for now.
*
* @see https://github.com/i18next/i18next/issues/1852
*/
const DynamicT = ({ forKey }: Props) => {
const DynamicT = ({ forKey, interpolation }: Props) => {
const { t } = useTranslation();
if (!forKey) {
return null;
}
return <>{t(forKey)}</>;
const translated = t(forKey, interpolation ?? {});
if (typeof translated === 'string') {
// The fragment will ensure the component has the return type that is compatible with `JSX.Element`.
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{translated}</>;
}
// The fragment will ensure the component has the return type that is compatible with `JSX.Element`.
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{`Translation key ${forKey} is invalid.`}</>; // This would be great to have i18n as well. Not harmful for now.
};
export default DynamicT;

View file

@ -1,6 +1,7 @@
import classNames from 'classnames';
import type { TFuncKey } from 'i18next';
import { useTranslation } from 'react-i18next';
import DynamicT from '@/components/DynamicT';
import * as styles from './index.module.scss';
@ -10,9 +11,11 @@ type Props = {
};
const InlineNotification = ({ className, message }: Props) => {
const { t } = useTranslation();
return <div className={classNames(styles.notification, className)}>{t(message)}</div>;
return (
<div className={classNames(styles.notification, className)}>
<DynamicT forKey={message} />
</div>
);
};
export default InlineNotification;

View file

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import Divider from '@/components/Divider';
import DynamicT from '@/components/DynamicT';
import { useSieMethods } from '@/hooks/use-sie';
import useBindSocialRelatedUser from '@/hooks/use-social-link-related-user';
import useSocialRegister from '@/hooks/use-social-register';
@ -80,7 +81,9 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
<Divider label="description.or" className={styles.divider} />
<div className={styles.desc}>{t(content.desc)}</div>
<div className={styles.desc}>
<DynamicT forKey={content.desc} />
</div>
<Button
title={content.buttonText}

View file

@ -4,7 +4,6 @@ import type { LocalePhrase } from '@logto/phrases-ui';
declare module 'i18next' {
interface CustomTypeOptions {
allowObjectInHTMLChildren: true;
resources: LocalePhrase;
}
}

View file

@ -1,4 +1,8 @@
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
Object.defineProperty(window, 'matchMedia', {
writable: true,
@ -15,15 +19,9 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
const translation = (key: string) => key;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
jest.mock('react-i18next', () => ({
...jest.requireActual('react-i18next'),
useTranslation: () => ({
t: translation,
i18n: {
t: translation,
},
}),
}));
void i18next.use(initReactI18next).init({
// Simple resources for testing
resources: { en: { translation: { action: { agree: 'Agree' } } } },
lng: 'en',
react: { useSuspense: false },
});

View file

@ -1,12 +1,12 @@
import { Theme } from '@logto/schemas';
import type { TFuncKey } from 'i18next';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import StaticPageLayout from '@/Layout/StaticPageLayout';
import PageContext from '@/Providers/PageContextProvider/PageContext';
import EmptyStateDark from '@/assets/icons/empty-state-dark.svg';
import EmptyState from '@/assets/icons/empty-state.svg';
import DynamicT from '@/components/DynamicT';
import NavBar from '@/components/NavBar';
import PageMeta from '@/components/PageMeta';
@ -19,11 +19,8 @@ type Props = {
};
const ErrorPage = ({ title = 'description.not_found', message, rawMessage }: Props) => {
const { t } = useTranslation();
const { theme } = useContext(PageContext);
const errorMessage = rawMessage ?? (message && t(message));
const errorMessage = Boolean(rawMessage ?? message);
return (
<StaticPageLayout>
@ -31,8 +28,12 @@ const ErrorPage = ({ title = 'description.not_found', message, rawMessage }: Pro
{history.length > 1 && <NavBar />}
<div className={styles.container}>
{theme === Theme.Light ? <EmptyState /> : <EmptyStateDark />}
<div className={styles.title}>{t(title)}</div>
{errorMessage && <div className={styles.message}>{String(errorMessage)}</div>}
<div className={styles.title}>
<DynamicT forKey={title} />
</div>
{errorMessage && (
<div className={styles.message}>{rawMessage ?? <DynamicT forKey={message} />}</div>
)}
</div>
</StaticPageLayout>
);

View file

@ -2765,6 +2765,9 @@ importers:
'@fontsource/roboto-mono':
specifier: ^4.5.7
version: 4.5.7
'@jest/types':
specifier: ^29.5.0
version: 29.5.0
'@logto/app-insights':
specifier: workspace:^1.2.0
version: link:../app-insights
@ -2828,12 +2831,24 @@ importers:
'@silverhand/ts-config-react':
specifier: 3.0.0
version: 3.0.0(typescript@5.0.2)
'@swc/core':
specifier: ^1.3.52
version: 1.3.52
'@swc/jest':
specifier: ^0.2.26
version: 0.2.26(@swc/core@1.3.52)
'@testing-library/react':
specifier: ^14.0.0
version: 14.0.0(react-dom@18.2.0)(react@18.2.0)
'@tsconfig/docusaurus':
specifier: ^1.0.5
version: 1.0.5
'@types/color':
specifier: ^3.0.3
version: 3.0.3
'@types/jest':
specifier: ^29.4.0
version: 29.4.0
'@types/mdx':
specifier: ^2.0.1
version: 2.0.1
@ -2900,6 +2915,21 @@ importers:
i18next-browser-languagedetector:
specifier: ^7.0.1
version: 7.0.1
identity-obj-proxy:
specifier: ^3.0.0
version: 3.0.0
jest:
specifier: ^29.5.0
version: 29.5.0(@types/node@18.11.18)
jest-environment-jsdom:
specifier: ^29.0.0
version: 29.2.2
jest-transform-stub:
specifier: ^2.0.0
version: 2.0.0
jest-transformer-svg:
specifier: ^2.0.0
version: 2.0.0(jest@29.5.0)(react@18.2.0)
just-kebab-case:
specifier: ^4.2.0
version: 4.2.0
@ -6762,26 +6792,6 @@ packages:
jest-mock: 27.5.1
dev: true
/@jest/environment@29.2.2:
resolution: {integrity: sha512-OWn+Vhu0I1yxuGBJEFFekMYc8aGBGrY4rt47SOh/IFaI+D7ZHCk7pKRiSoZ2/Ml7b0Ony3ydmEHRx/tEOC7H1A==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/fake-timers': 29.4.1
'@jest/types': 29.5.0
'@types/node': 18.11.18
jest-mock: 29.4.1
dev: true
/@jest/environment@29.4.1:
resolution: {integrity: sha512-pJ14dHGSQke7Q3mkL/UZR9ZtTOxqskZaC91NzamEH4dlKRt42W+maRBXiw/LWkdJe+P0f/zDR37+SPMplMRlPg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/fake-timers': 29.4.1
'@jest/types': 29.5.0
'@types/node': 18.11.18
jest-mock: 29.4.1
dev: true
/@jest/environment@29.5.0:
resolution: {integrity: sha512-5FXw2+wD29YU1d4I2htpRX7jYnAyTRjP2CsXQdo9SAM8g3ifxWPSV0HnClSn71xwctr0U3oZIIH+dtbfmnbXVQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -6821,30 +6831,6 @@ packages:
jest-util: 27.5.1
dev: true
/@jest/fake-timers@29.2.2:
resolution: {integrity: sha512-nqaW3y2aSyZDl7zQ7t1XogsxeavNpH6kkdq+EpXncIDvAkjvFD7hmhcIs1nWloengEWUoWqkqSA6MSbf9w6DgA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@sinonjs/fake-timers': 9.1.2
'@types/node': 18.11.18
jest-message-util: 29.4.1
jest-mock: 29.4.1
jest-util: 29.4.1
dev: true
/@jest/fake-timers@29.4.1:
resolution: {integrity: sha512-/1joI6rfHFmmm39JxNfmNAO3Nwm6Y0VoL5fJDy7H1AtWrD1CgRtqJbN9Ld6rhAkGO76qqp4cwhhxJ9o9kYjQMw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@sinonjs/fake-timers': 10.0.2
'@types/node': 18.11.18
jest-message-util: 29.5.0
jest-mock: 29.4.1
jest-util: 29.5.0
dev: true
/@jest/fake-timers@29.5.0:
resolution: {integrity: sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -14021,13 +14007,13 @@ packages:
canvas:
optional: true
dependencies:
'@jest/environment': 29.2.2
'@jest/fake-timers': 29.2.2
'@jest/environment': 29.5.0
'@jest/fake-timers': 29.5.0
'@jest/types': 29.5.0
'@types/jsdom': 20.0.0
'@types/node': 18.11.18
jest-mock: 29.2.2
jest-util: 29.2.1
jest-mock: 29.5.0
jest-util: 29.5.0
jsdom: 20.0.2
transitivePeerDependencies:
- bufferutil
@ -14051,11 +14037,11 @@ packages:
resolution: {integrity: sha512-x/H2kdVgxSkxWAIlIh9MfMuBa0hZySmfsC5lCsWmWr6tZySP44ediRKDUiNggX/eHLH7Cd5ZN10Rw+XF5tXsqg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/environment': 29.4.1
'@jest/fake-timers': 29.4.1
'@jest/environment': 29.5.0
'@jest/fake-timers': 29.5.0
'@jest/types': 29.5.0
'@types/node': 18.11.18
jest-mock: 29.4.1
jest-mock: 29.5.0
jest-util: 29.5.0
dev: true
@ -14146,21 +14132,6 @@ packages:
stack-utils: 2.0.5
dev: true
/jest-message-util@29.4.1:
resolution: {integrity: sha512-H4/I0cXUaLeCw6FM+i4AwCnOwHRgitdaUFOdm49022YD5nfyr8C/DrbXOBEyJaj+w/y0gGJ57klssOaUiLLQGQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@babel/code-frame': 7.18.6
'@jest/types': 29.5.0
'@types/stack-utils': 2.0.1
chalk: 4.1.2
graceful-fs: 4.2.10
micromatch: 4.0.5
pretty-format: 29.5.0
slash: 3.0.0
stack-utils: 2.0.5
dev: true
/jest-message-util@29.5.0:
resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -14184,24 +14155,6 @@ packages:
'@types/node': 18.11.18
dev: true
/jest-mock@29.2.2:
resolution: {integrity: sha512-1leySQxNAnivvbcx0sCB37itu8f4OX2S/+gxLAV4Z62shT4r4dTG9tACDywUAEZoLSr36aYUTsVp3WKwWt4PMQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@types/node': 18.11.18
jest-util: 29.4.1
dev: true
/jest-mock@29.4.1:
resolution: {integrity: sha512-MwA4hQ7zBOcgVCVnsM8TzaFLVUD/pFWTfbkY953Y81L5ret3GFRZtmPmRFAjKQSdCKoJvvqOu6Bvfpqlwwb0dQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@types/node': 18.11.18
jest-util: 29.5.0
dev: true
/jest-mock@29.5.0:
resolution: {integrity: sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -14383,30 +14336,6 @@ packages:
picomatch: 2.3.1
dev: true
/jest-util@29.2.1:
resolution: {integrity: sha512-P5VWDj25r7kj7kl4pN2rG/RN2c1TLfYYYZYULnS/35nFDjBai+hBeo3MDrYZS7p6IoY3YHZnt2vq4L6mKnLk0g==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@types/node': 18.11.18
chalk: 4.1.2
ci-info: 3.5.0
graceful-fs: 4.2.10
picomatch: 2.3.1
dev: true
/jest-util@29.4.1:
resolution: {integrity: sha512-bQy9FPGxVutgpN4VRc0hk6w7Hx/m6L53QxpDreTZgJd9gfx/AV2MjyPde9tGyZRINAUrSv57p2inGBu2dRLmkQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@types/node': 18.11.18
chalk: 4.1.2
ci-info: 3.8.0
graceful-fs: 4.2.10
picomatch: 2.3.1
dev: true
/jest-util@29.5.0:
resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}