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:
parent
ac95eb3ffa
commit
20418dc4f9
36 changed files with 292 additions and 204 deletions
33
packages/console/jest.config.ts
Normal file
33
packages/console/jest.config.ts
Normal 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;
|
9
packages/console/jest.setup.ts
Normal file
9
packages/console/jest.setup.ts
Normal 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 },
|
||||||
|
});
|
|
@ -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}",
|
"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": "eslint --ext .ts --ext .tsx src",
|
||||||
"lint:report": "pnpm lint --format json --output-file report.json",
|
"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": {
|
"devDependencies": {
|
||||||
"@fontsource/roboto-mono": "^4.5.7",
|
"@fontsource/roboto-mono": "^4.5.7",
|
||||||
|
"@jest/types": "^29.5.0",
|
||||||
"@logto/app-insights": "workspace:^1.2.0",
|
"@logto/app-insights": "workspace:^1.2.0",
|
||||||
"@logto/connector-kit": "workspace:^1.1.1",
|
"@logto/connector-kit": "workspace:^1.1.1",
|
||||||
"@logto/core-kit": "workspace:^2.0.0",
|
"@logto/core-kit": "workspace:^2.0.0",
|
||||||
|
@ -43,8 +46,12 @@
|
||||||
"@silverhand/essentials": "^2.5.0",
|
"@silverhand/essentials": "^2.5.0",
|
||||||
"@silverhand/ts-config": "3.0.0",
|
"@silverhand/ts-config": "3.0.0",
|
||||||
"@silverhand/ts-config-react": "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",
|
"@tsconfig/docusaurus": "^1.0.5",
|
||||||
"@types/color": "^3.0.3",
|
"@types/color": "^3.0.3",
|
||||||
|
"@types/jest": "^29.4.0",
|
||||||
"@types/mdx": "^2.0.1",
|
"@types/mdx": "^2.0.1",
|
||||||
"@types/mdx-js__react": "^1.5.5",
|
"@types/mdx-js__react": "^1.5.5",
|
||||||
"@types/react": "^18.0.31",
|
"@types/react": "^18.0.31",
|
||||||
|
@ -67,6 +74,11 @@
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"i18next": "^22.4.15",
|
"i18next": "^22.4.15",
|
||||||
"i18next-browser-languagedetector": "^7.0.1",
|
"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",
|
"just-kebab-case": "^4.2.0",
|
||||||
"ky": "^0.33.0",
|
"ky": "^0.33.0",
|
||||||
"lint-staged": "^13.0.0",
|
"lint-staged": "^13.0.0",
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import type { AdminConsoleKey } from '@logto/phrases';
|
import type { AdminConsoleKey } from '@logto/phrases';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import Info from '@/assets/images/info.svg';
|
import Info from '@/assets/images/info.svg';
|
||||||
|
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
|
import DynamicT from '../DynamicT';
|
||||||
import TextLink from '../TextLink';
|
import TextLink from '../TextLink';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
@ -29,15 +29,17 @@ function Alert({
|
||||||
variant = 'plain',
|
variant = 'plain',
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.alert, styles[severity], styles[variant], className)}>
|
<div className={classNames(styles.alert, styles[severity], styles[variant], className)}>
|
||||||
<div className={styles.icon}>
|
<div className={styles.icon}>
|
||||||
<Info />
|
<Info />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.content}>{children}</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 && (
|
{action && onClick && (
|
||||||
<div className={styles.action}>
|
<div className={styles.action}>
|
||||||
<Button title={action} type="text" size="small" onClick={onClick} />
|
<Button title={action} type="text" size="small" onClick={onClick} />
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { Ring as Spinner } from '@/components/Spinner';
|
import { Ring as Spinner } from '@/components/Spinner';
|
||||||
|
|
||||||
import type DangerousRaw from '../DangerousRaw';
|
import type DangerousRaw from '../DangerousRaw';
|
||||||
|
import DynamicT from '../DynamicT';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -96,7 +97,14 @@ function Button({
|
||||||
>
|
>
|
||||||
{showSpinner && <Spinner className={styles.spinner} />}
|
{showSpinner && <Spinner className={styles.spinner} />}
|
||||||
{icon && <span className={styles.icon}>{icon}</span>}
|
{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>}
|
{trailingIcon && <span className={styles.trailingIcon}>{trailingIcon}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { ReactElement } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type DangerousRaw from '../DangerousRaw';
|
import type DangerousRaw from '../DangerousRaw';
|
||||||
|
import DynamicT from '../DynamicT';
|
||||||
import TextLink from '../TextLink';
|
import TextLink from '../TextLink';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
@ -33,11 +34,13 @@ function CardTitle({
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.container, styles[size], className)}>
|
<div className={classNames(styles.container, styles[size], className)}>
|
||||||
<div className={classNames(styles.title, !isWordWrapEnabled && styles.titleEllipsis)}>
|
<div className={classNames(styles.title, !isWordWrapEnabled && styles.titleEllipsis)}>
|
||||||
{typeof title === 'string' ? t(title) : title}
|
{typeof title === 'string' ? <DynamicT forKey={title} /> : title}
|
||||||
</div>
|
</div>
|
||||||
{Boolean(subtitle ?? learnMoreLink) && (
|
{Boolean(subtitle ?? learnMoreLink) && (
|
||||||
<div className={styles.subtitle}>
|
<div className={styles.subtitle}>
|
||||||
{subtitle && <span>{typeof subtitle === 'string' ? t(subtitle) : subtitle}</span>}
|
{subtitle && (
|
||||||
|
<span>{typeof subtitle === 'string' ? <DynamicT forKey={subtitle} /> : subtitle}</span>
|
||||||
|
)}
|
||||||
{learnMoreLink && (
|
{learnMoreLink && (
|
||||||
<TextLink href={learnMoreLink} target="_blank" className={styles.learnMore}>
|
<TextLink href={learnMoreLink} target="_blank" className={styles.learnMore}>
|
||||||
{t('general.learn_more')}
|
{t('general.learn_more')}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import type { AdminConsoleKey } from '@logto/phrases';
|
import type { AdminConsoleKey } from '@logto/phrases';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import Back from '@/assets/images/back.svg';
|
import Back from '@/assets/images/back.svg';
|
||||||
import type { RequestError } from '@/hooks/use-api';
|
import type { RequestError } from '@/hooks/use-api';
|
||||||
|
|
||||||
import type DangerousRaw from '../DangerousRaw';
|
import type DangerousRaw from '../DangerousRaw';
|
||||||
import DetailsSkeleton from '../DetailsSkeleton';
|
import DetailsSkeleton from '../DetailsSkeleton';
|
||||||
|
import DynamicT from '../DynamicT';
|
||||||
import RequestDataError from '../RequestDataError';
|
import RequestDataError from '../RequestDataError';
|
||||||
import TextLink from '../TextLink';
|
import TextLink from '../TextLink';
|
||||||
|
|
||||||
|
@ -32,12 +32,10 @@ function DetailsPage({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.container, className)}>
|
<div className={classNames(styles.container, className)}>
|
||||||
<TextLink to={backLink} icon={<Back />} className={styles.backLink}>
|
<TextLink to={backLink} icon={<Back />} className={styles.backLink}>
|
||||||
{typeof backLinkTitle === 'string' ? t(backLinkTitle) : backLinkTitle}
|
{typeof backLinkTitle === 'string' ? <DynamicT forKey={backLinkTitle} /> : backLinkTitle}
|
||||||
</TextLink>
|
</TextLink>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<DetailsSkeleton />
|
<DetailsSkeleton />
|
||||||
|
|
22
packages/console/src/components/DynamicT/index.test.tsx
Normal file
22
packages/console/src/components/DynamicT/index.test.tsx
Normal 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.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,16 +3,29 @@ import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
forKey: AdminConsoleKey;
|
forKey: AdminConsoleKey;
|
||||||
|
interpolation?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A component to render a dynamic translation key.
|
* 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' });
|
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 ?? {})}</>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,9 @@ function FormCard({ title, description, learnMoreLink, children }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className={styles.container}>
|
<Card className={styles.container}>
|
||||||
<div className={styles.introduction}>
|
<div className={styles.introduction}>
|
||||||
<div className={styles.title}>{t(title)}</div>
|
<div className={styles.title}>
|
||||||
|
<DynamicT forKey={title} />
|
||||||
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<div className={styles.description}>
|
<div className={styles.description}>
|
||||||
<DynamicT forKey={description} />
|
<DynamicT forKey={description} />
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { Tooltip } from '@/components/Tip';
|
||||||
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
|
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
|
||||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||||
|
|
||||||
|
import DynamicT from '../DynamicT';
|
||||||
import EmptyDataPlaceholder from '../EmptyDataPlaceholder';
|
import EmptyDataPlaceholder from '../EmptyDataPlaceholder';
|
||||||
import type { Props as PaginationProps } from '../Pagination';
|
import type { Props as PaginationProps } from '../Pagination';
|
||||||
import TablePlaceholder from '../Table/TablePlaceholder';
|
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.
|
* When the table is read-only, hide the delete button rather than the whole column to keep the table column spaces.
|
||||||
*/
|
*/
|
||||||
isReadOnly ? null : (
|
isReadOnly ? null : (
|
||||||
<Tooltip content={<div>{t(deleteButtonTitle)}</div>}>
|
<Tooltip content={<DynamicT forKey={deleteButtonTitle} />}>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
deleteHandler(scope);
|
deleteHandler(scope);
|
||||||
|
|
|
@ -2,7 +2,6 @@ import type { AdminConsoleKey } from '@logto/phrases';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { KeyboardEventHandler, ReactElement, ReactNode } from 'react';
|
import type { KeyboardEventHandler, ReactElement, ReactNode } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import type DangerousRaw from '../DangerousRaw';
|
import type DangerousRaw from '../DangerousRaw';
|
||||||
import DynamicT from '../DynamicT';
|
import DynamicT from '../DynamicT';
|
||||||
|
@ -49,8 +48,6 @@ function Radio({
|
||||||
disabledLabel,
|
disabledLabel,
|
||||||
icon,
|
icon,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
|
||||||
|
|
||||||
const handleKeyPress: KeyboardEventHandler<HTMLDivElement> = useCallback(
|
const handleKeyPress: KeyboardEventHandler<HTMLDivElement> = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
|
@ -93,7 +90,7 @@ function Radio({
|
||||||
{title && (typeof title === 'string' ? <DynamicT forKey={title} /> : title)}
|
{title && (typeof title === 'string' ? <DynamicT forKey={title} /> : title)}
|
||||||
{isDisabled && disabledLabel && (
|
{isDisabled && disabledLabel && (
|
||||||
<div className={classNames(styles.indicator, styles.disabledLabel)}>
|
<div className={classNames(styles.indicator, styles.disabledLabel)}>
|
||||||
{t(disabledLabel)}
|
<DynamicT forKey={disabledLabel} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,9 @@ function TablePlaceholder({ image, imageDark, title, description, learnMoreLink,
|
||||||
return (
|
return (
|
||||||
<div className={styles.placeholder}>
|
<div className={styles.placeholder}>
|
||||||
<div className={styles.image}>{theme === Theme.Light ? image : imageDark}</div>
|
<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}>
|
<div className={styles.description}>
|
||||||
<DynamicT forKey={description} />
|
<DynamicT forKey={description} />
|
||||||
{learnMoreLink && (
|
{learnMoreLink && (
|
||||||
|
|
|
@ -46,7 +46,7 @@ function UserAccountInformation({
|
||||||
primaryEmail && `${t('user_details.created_email')} ${primaryEmail}`,
|
primaryEmail && `${t('user_details.created_email')} ${primaryEmail}`,
|
||||||
primaryPhone && `${t('user_details.created_phone')} ${primaryPhone}`,
|
primaryPhone && `${t('user_details.created_phone')} ${primaryPhone}`,
|
||||||
username && `${t('user_details.created_username')} ${username}`,
|
username && `${t('user_details.created_username')} ${username}`,
|
||||||
`${passwordLabel ?? t('user_details.created_username')} ${password}`
|
`${passwordLabel ?? t('user_details.created_password')} ${password}`
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
await navigator.clipboard.writeText(content);
|
await navigator.clipboard.writeText(content);
|
||||||
|
|
|
@ -2,11 +2,11 @@ import type { AdminConsoleKey } from '@logto/phrases';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import ArrowRight from '@/assets/images/arrow-right.svg';
|
import ArrowRight from '@/assets/images/arrow-right.svg';
|
||||||
import Tick from '@/assets/images/tick.svg';
|
import Tick from '@/assets/images/tick.svg';
|
||||||
import { DropdownItem } from '@/components/Dropdown';
|
import { DropdownItem } from '@/components/Dropdown';
|
||||||
|
import DynamicT from '@/components/DynamicT';
|
||||||
import type { Option } from '@/components/Select';
|
import type { Option } from '@/components/Select';
|
||||||
import Spacer from '@/components/Spacer';
|
import Spacer from '@/components/Spacer';
|
||||||
import { onKeyDownHandler } from '@/utils/a11y';
|
import { onKeyDownHandler } from '@/utils/a11y';
|
||||||
|
@ -32,7 +32,6 @@ function SubMenu<T extends string>({
|
||||||
selectedOption,
|
selectedOption,
|
||||||
onItemClick,
|
onItemClick,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
|
||||||
const anchorRef = useRef<HTMLDivElement>(null);
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const mouseEnterTimeoutRef = useRef(0);
|
const mouseEnterTimeoutRef = useRef(0);
|
||||||
|
@ -67,7 +66,9 @@ function SubMenu<T extends string>({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon && <span className={styles.icon}>{icon}</span>}
|
{icon && <span className={styles.icon}>{icon}</span>}
|
||||||
<span className={styles.title}>{t(title)}</span>
|
<span className={styles.title}>
|
||||||
|
<DynamicT forKey={title} />
|
||||||
|
</span>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<ArrowRight className={styles.icon} />
|
<ArrowRight className={styles.icon} />
|
||||||
<div className={classNames(styles.menu, showMenu && styles.visible)}>
|
<div className={classNames(styles.menu, showMenu && styles.visible)}>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import ReactModal from 'react-modal';
|
import ReactModal from 'react-modal';
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
|
import DynamicT from '@/components/DynamicT';
|
||||||
import ModalLayout from '@/components/ModalLayout';
|
import ModalLayout from '@/components/ModalLayout';
|
||||||
import * as modalStyles from '@/scss/modal.module.scss';
|
import * as modalStyles from '@/scss/modal.module.scss';
|
||||||
|
|
||||||
|
@ -14,7 +14,6 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function Contact({ isOpen, onCancel }: Props) {
|
function Contact({ isOpen, onCancel }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
|
||||||
const contacts = useContacts();
|
const contacts = useContacts();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -33,8 +32,12 @@ function Contact({ isOpen, onCancel }: Props) {
|
||||||
<ContactIcon />
|
<ContactIcon />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<div className={styles.title}>{t(title)}</div>
|
<div className={styles.title}>
|
||||||
<div className={styles.description}>{t(description)}</div>
|
<DynamicT forKey={title} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.description}>
|
||||||
|
<DynamicT forKey={description} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
|
2
packages/console/src/include.d/i18next.d.ts
vendored
2
packages/console/src/include.d/i18next.d.ts
vendored
|
@ -4,8 +4,6 @@ import type { LocalePhrase } from '@logto/phrases';
|
||||||
|
|
||||||
declare module 'i18next' {
|
declare module 'i18next' {
|
||||||
interface CustomTypeOptions {
|
interface CustomTypeOptions {
|
||||||
returnNull: false;
|
|
||||||
allowObjectInHTMLChildren: true;
|
|
||||||
resources: LocalePhrase;
|
resources: LocalePhrase;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import DynamicT from '@/components/DynamicT';
|
import DynamicT from '@/components/DynamicT';
|
||||||
import { Tooltip } from '@/components/Tip';
|
import { Tooltip } from '@/components/Tip';
|
||||||
|
@ -23,10 +22,8 @@ function CardItem({
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={conditional(isDisabled && disabledTip && <>{t(disabledTip)}</>)}>
|
<Tooltip content={conditional(isDisabled && disabledTip && <DynamicT forKey={disabledTip} />)}>
|
||||||
<div
|
<div
|
||||||
key={value}
|
key={value}
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -55,10 +52,16 @@ function CardItem({
|
||||||
<div>
|
<div>
|
||||||
{typeof title === 'string' ? <DynamicT forKey={title} /> : title}
|
{typeof title === 'string' ? <DynamicT forKey={title} /> : title}
|
||||||
{trailingTag && (
|
{trailingTag && (
|
||||||
<span className={classNames(styles.tag, styles.trailingTag)}>{t(trailingTag)}</span>
|
<span className={classNames(styles.tag, styles.trailingTag)}>
|
||||||
|
<DynamicT forKey={trailingTag} />
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tag && <span className={styles.tag}>{t(tag)}</span>}
|
{tag && (
|
||||||
|
<span className={styles.tag}>
|
||||||
|
<DynamicT forKey={tag} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
@ -2,9 +2,9 @@ import type { AdminConsoleKey } from '@logto/phrases';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { cloneElement } from 'react';
|
import { cloneElement } from 'react';
|
||||||
import type { ReactNode, ReactElement } from 'react';
|
import type { ReactNode, ReactElement } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
|
import DynamicT from '@/components/DynamicT';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -19,15 +19,17 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function ReachLogto({ title, description, icon, buttonTitle, buttonIcon, link, className }: Props) {
|
function ReachLogto({ title, description, icon, buttonTitle, buttonIcon, link, className }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.reachLogto, className)}>
|
<div className={classNames(styles.reachLogto, className)}>
|
||||||
<div className={styles.reachLogtoInfo}>
|
<div className={styles.reachLogtoInfo}>
|
||||||
{cloneElement(icon, { className: styles.reachLogtoIcon })}
|
{cloneElement(icon, { className: styles.reachLogtoIcon })}
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.reachLogtoTitle}>{t(title)}</div>
|
<div className={styles.reachLogtoTitle}>
|
||||||
<div className={styles.reachLogtoDescription}>{t(description)}</div>
|
<DynamicT forKey={title} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.reachLogtoDescription}>
|
||||||
|
<DynamicT forKey={description} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -26,7 +26,7 @@ function ConnectorTypeColumn({ connectorGroup: { type, connectors } }: Props) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!firstStandardConnector) {
|
if (!firstStandardConnector) {
|
||||||
return <>{t(connectorTitlePlaceHolder[type])}</>;
|
return <span>{t(connectorTitlePlaceHolder[type])}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!connectorFactory) {
|
if (!connectorFactory) {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import CompleteIndicator from '@/assets/images/task-complete.svg';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import ConfirmModal from '@/components/ConfirmModal';
|
import ConfirmModal from '@/components/ConfirmModal';
|
||||||
|
import DynamicT from '@/components/DynamicT';
|
||||||
import PageMeta from '@/components/PageMeta';
|
import PageMeta from '@/components/PageMeta';
|
||||||
import Spacer from '@/components/Spacer';
|
import Spacer from '@/components/Spacer';
|
||||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||||
|
@ -73,8 +74,12 @@ function GetStarted() {
|
||||||
{!isComplete && <CardIcon className={styles.icon} />}
|
{!isComplete && <CardIcon className={styles.icon} />}
|
||||||
{isComplete && <CompleteIndicator className={styles.icon} />}
|
{isComplete && <CompleteIndicator className={styles.icon} />}
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<div className={styles.title}>{t(title)}</div>
|
<div className={styles.title}>
|
||||||
<div className={styles.subtitle}>{t(subtitle)}</div>
|
<DynamicT forKey={title} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.subtitle}>
|
||||||
|
<DynamicT forKey={subtitle} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
|
|
|
@ -2,7 +2,6 @@ import type { AdminConsoleKey } from '@logto/phrases';
|
||||||
import type { Nullable } from '@silverhand/essentials';
|
import type { Nullable } from '@silverhand/essentials';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { cloneElement } from 'react';
|
import { cloneElement } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import DynamicT from '@/components/DynamicT';
|
import DynamicT from '@/components/DynamicT';
|
||||||
|
@ -34,7 +33,6 @@ function CardContent<T extends Nullable<boolean | string | Record<string, unknow
|
||||||
title,
|
title,
|
||||||
data,
|
data,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
|
||||||
const defaultRenderer = (value: unknown) => (value ? <span>{String(value)}</span> : <NotSet />);
|
const defaultRenderer = (value: unknown) => (value ? <span>{String(value)}</span> : <NotSet />);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
|
@ -43,7 +41,9 @@ function CardContent<T extends Nullable<boolean | string | Record<string, unknow
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.title}>{t(title)}</div>
|
<div className={styles.title}>
|
||||||
|
<DynamicT forKey={title} />
|
||||||
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map(({ key, icon, label, value, renderer = defaultRenderer, action }) => {
|
{data.map(({ key, icon, label, value, renderer = defaultRenderer, action }) => {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import ReactModal from 'react-modal';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import Arrow from '@/assets/images/arrow-left.svg';
|
import Arrow from '@/assets/images/arrow-left.svg';
|
||||||
|
import DynamicT from '@/components/DynamicT';
|
||||||
import TextLink from '@/components/TextLink';
|
import TextLink from '@/components/TextLink';
|
||||||
import * as modalStyles from '@/scss/modal.module.scss';
|
import * as modalStyles from '@/scss/modal.module.scss';
|
||||||
|
|
||||||
|
@ -40,7 +41,9 @@ function MainFlowLikeModal({ title, subtitle, subtitleProps, children, onClose,
|
||||||
>
|
>
|
||||||
{t('general.back')}
|
{t('general.back')}
|
||||||
</TextLink>
|
</TextLink>
|
||||||
<span className={styles.title}>{t(title)}</span>
|
<span className={styles.title}>
|
||||||
|
<DynamicT forKey={title} />
|
||||||
|
</span>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<span className={styles.subtitle}>
|
<span className={styles.subtitle}>
|
||||||
<Trans components={{ strong: <span className={styles.strong} /> }}>
|
<Trans components={{ strong: <span className={styles.strong} /> }}>
|
||||||
|
|
|
@ -10,6 +10,6 @@
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src",
|
"src",
|
||||||
"jest.config.ts"
|
"jest.*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ import { CustomTypeOptions } from 'react-i18next';
|
||||||
|
|
||||||
declare module 'react-i18next' {
|
declare module 'react-i18next' {
|
||||||
interface CustomTypeOptions {
|
interface CustomTypeOptions {
|
||||||
allowObjectInHTMLChildren: true;
|
|
||||||
resources: LocalePhrase;
|
resources: LocalePhrase;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { TFuncKey } from 'i18next';
|
import type { TFuncKey } from 'i18next';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
|
import DynamicT from '@/components/DynamicT';
|
||||||
import NavBar from '@/components/NavBar';
|
import NavBar from '@/components/NavBar';
|
||||||
import PageMeta from '@/components/PageMeta';
|
import PageMeta from '@/components/PageMeta';
|
||||||
import usePlatform from '@/hooks/use-platform';
|
import usePlatform from '@/hooks/use-platform';
|
||||||
|
@ -26,7 +26,6 @@ const SecondaryPageLayout = ({
|
||||||
notification,
|
notification,
|
||||||
children,
|
children,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isMobile } = usePlatform();
|
const { isMobile } = usePlatform();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -38,9 +37,13 @@ const SecondaryPageLayout = ({
|
||||||
)}
|
)}
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.title}>{t(title, titleProps ?? {})}</div>
|
<div className={styles.title}>
|
||||||
|
<DynamicT forKey={title} interpolation={titleProps} />
|
||||||
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<div className={styles.description}>{t(description, descriptionProps ?? {})}</div>
|
<div className={styles.description}>
|
||||||
|
<DynamicT forKey={description} interpolation={descriptionProps} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import type { Nullable } from '@silverhand/essentials';
|
import type { Nullable } from '@silverhand/essentials';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { TFuncKey } from 'i18next';
|
import type { TFuncKey } from 'i18next';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
import DynamicT from '../DynamicT';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -12,12 +13,14 @@ export type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const BrandingHeader = ({ logo, headline, className }: Props) => {
|
const BrandingHeader = ({ logo, headline, className }: Props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.container, className)}>
|
<div className={classNames(styles.container, className)}>
|
||||||
{logo && <img className={styles.logo} alt="app logo" src={logo} crossOrigin="anonymous" />}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { TFuncKey } from 'i18next';
|
import type { TFuncKey } from 'i18next';
|
||||||
import type { HTMLProps } from 'react';
|
import type { HTMLProps } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
import DynamicT from '../DynamicT';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -32,8 +33,6 @@ const Button = ({
|
||||||
onClick,
|
onClick,
|
||||||
...rest
|
...rest
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
@ -48,7 +47,7 @@ const Button = ({
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{t(title, { ...i18nProps })}
|
<DynamicT forKey={title} interpolation={i18nProps} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
26
packages/ui/src/components/DynamicT/index.test.tsx
Normal file
26
packages/ui/src/components/DynamicT/index.test.tsx
Normal 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.`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,21 +3,29 @@ import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
forKey?: TFuncKey;
|
forKey?: TFuncKey;
|
||||||
|
interpolation?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A component to render a dynamic translation key.
|
* 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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!forKey) {
|
if (!forKey) {
|
||||||
return null;
|
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;
|
export default DynamicT;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { TFuncKey } from 'i18next';
|
import type { TFuncKey } from 'i18next';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
import DynamicT from '@/components/DynamicT';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -10,9 +11,11 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const InlineNotification = ({ className, message }: Props) => {
|
const InlineNotification = ({ className, message }: Props) => {
|
||||||
const { t } = useTranslation();
|
return (
|
||||||
|
<div className={classNames(styles.notification, className)}>
|
||||||
return <div className={classNames(styles.notification, className)}>{t(message)}</div>;
|
<DynamicT forKey={message} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default InlineNotification;
|
export default InlineNotification;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Divider from '@/components/Divider';
|
import Divider from '@/components/Divider';
|
||||||
|
import DynamicT from '@/components/DynamicT';
|
||||||
import { useSieMethods } from '@/hooks/use-sie';
|
import { useSieMethods } from '@/hooks/use-sie';
|
||||||
import useBindSocialRelatedUser from '@/hooks/use-social-link-related-user';
|
import useBindSocialRelatedUser from '@/hooks/use-social-link-related-user';
|
||||||
import useSocialRegister from '@/hooks/use-social-register';
|
import useSocialRegister from '@/hooks/use-social-register';
|
||||||
|
@ -80,7 +81,9 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
|
||||||
|
|
||||||
<Divider label="description.or" className={styles.divider} />
|
<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
|
<Button
|
||||||
title={content.buttonText}
|
title={content.buttonText}
|
||||||
|
|
1
packages/ui/src/include.d/i18next.d.ts
vendored
1
packages/ui/src/include.d/i18next.d.ts
vendored
|
@ -4,7 +4,6 @@ import type { LocalePhrase } from '@logto/phrases-ui';
|
||||||
|
|
||||||
declare module 'i18next' {
|
declare module 'i18next' {
|
||||||
interface CustomTypeOptions {
|
interface CustomTypeOptions {
|
||||||
allowObjectInHTMLChildren: true;
|
|
||||||
resources: LocalePhrase;
|
resources: LocalePhrase;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
|
// 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
|
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
writable: true,
|
writable: true,
|
||||||
|
@ -15,15 +19,9 @@ Object.defineProperty(window, 'matchMedia', {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const translation = (key: string) => key;
|
void i18next.use(initReactI18next).init({
|
||||||
|
// Simple resources for testing
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
resources: { en: { translation: { action: { agree: 'Agree' } } } },
|
||||||
jest.mock('react-i18next', () => ({
|
lng: 'en',
|
||||||
...jest.requireActual('react-i18next'),
|
react: { useSuspense: false },
|
||||||
useTranslation: () => ({
|
});
|
||||||
t: translation,
|
|
||||||
i18n: {
|
|
||||||
t: translation,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { Theme } from '@logto/schemas';
|
import { Theme } from '@logto/schemas';
|
||||||
import type { TFuncKey } from 'i18next';
|
import type { TFuncKey } from 'i18next';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import StaticPageLayout from '@/Layout/StaticPageLayout';
|
import StaticPageLayout from '@/Layout/StaticPageLayout';
|
||||||
import PageContext from '@/Providers/PageContextProvider/PageContext';
|
import PageContext from '@/Providers/PageContextProvider/PageContext';
|
||||||
import EmptyStateDark from '@/assets/icons/empty-state-dark.svg';
|
import EmptyStateDark from '@/assets/icons/empty-state-dark.svg';
|
||||||
import EmptyState from '@/assets/icons/empty-state.svg';
|
import EmptyState from '@/assets/icons/empty-state.svg';
|
||||||
|
import DynamicT from '@/components/DynamicT';
|
||||||
import NavBar from '@/components/NavBar';
|
import NavBar from '@/components/NavBar';
|
||||||
import PageMeta from '@/components/PageMeta';
|
import PageMeta from '@/components/PageMeta';
|
||||||
|
|
||||||
|
@ -19,11 +19,8 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ErrorPage = ({ title = 'description.not_found', message, rawMessage }: Props) => {
|
const ErrorPage = ({ title = 'description.not_found', message, rawMessage }: Props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { theme } = useContext(PageContext);
|
const { theme } = useContext(PageContext);
|
||||||
|
const errorMessage = Boolean(rawMessage ?? message);
|
||||||
const errorMessage = rawMessage ?? (message && t(message));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StaticPageLayout>
|
<StaticPageLayout>
|
||||||
|
@ -31,8 +28,12 @@ const ErrorPage = ({ title = 'description.not_found', message, rawMessage }: Pro
|
||||||
{history.length > 1 && <NavBar />}
|
{history.length > 1 && <NavBar />}
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{theme === Theme.Light ? <EmptyState /> : <EmptyStateDark />}
|
{theme === Theme.Light ? <EmptyState /> : <EmptyStateDark />}
|
||||||
<div className={styles.title}>{t(title)}</div>
|
<div className={styles.title}>
|
||||||
{errorMessage && <div className={styles.message}>{String(errorMessage)}</div>}
|
<DynamicT forKey={title} />
|
||||||
|
</div>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className={styles.message}>{rawMessage ?? <DynamicT forKey={message} />}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</StaticPageLayout>
|
</StaticPageLayout>
|
||||||
);
|
);
|
||||||
|
|
145
pnpm-lock.yaml
145
pnpm-lock.yaml
|
@ -2765,6 +2765,9 @@ importers:
|
||||||
'@fontsource/roboto-mono':
|
'@fontsource/roboto-mono':
|
||||||
specifier: ^4.5.7
|
specifier: ^4.5.7
|
||||||
version: 4.5.7
|
version: 4.5.7
|
||||||
|
'@jest/types':
|
||||||
|
specifier: ^29.5.0
|
||||||
|
version: 29.5.0
|
||||||
'@logto/app-insights':
|
'@logto/app-insights':
|
||||||
specifier: workspace:^1.2.0
|
specifier: workspace:^1.2.0
|
||||||
version: link:../app-insights
|
version: link:../app-insights
|
||||||
|
@ -2828,12 +2831,24 @@ importers:
|
||||||
'@silverhand/ts-config-react':
|
'@silverhand/ts-config-react':
|
||||||
specifier: 3.0.0
|
specifier: 3.0.0
|
||||||
version: 3.0.0(typescript@5.0.2)
|
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':
|
'@tsconfig/docusaurus':
|
||||||
specifier: ^1.0.5
|
specifier: ^1.0.5
|
||||||
version: 1.0.5
|
version: 1.0.5
|
||||||
'@types/color':
|
'@types/color':
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
|
'@types/jest':
|
||||||
|
specifier: ^29.4.0
|
||||||
|
version: 29.4.0
|
||||||
'@types/mdx':
|
'@types/mdx':
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
|
@ -2900,6 +2915,21 @@ importers:
|
||||||
i18next-browser-languagedetector:
|
i18next-browser-languagedetector:
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 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:
|
just-kebab-case:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
|
@ -6762,26 +6792,6 @@ packages:
|
||||||
jest-mock: 27.5.1
|
jest-mock: 27.5.1
|
||||||
dev: true
|
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:
|
/@jest/environment@29.5.0:
|
||||||
resolution: {integrity: sha512-5FXw2+wD29YU1d4I2htpRX7jYnAyTRjP2CsXQdo9SAM8g3ifxWPSV0HnClSn71xwctr0U3oZIIH+dtbfmnbXVQ==}
|
resolution: {integrity: sha512-5FXw2+wD29YU1d4I2htpRX7jYnAyTRjP2CsXQdo9SAM8g3ifxWPSV0HnClSn71xwctr0U3oZIIH+dtbfmnbXVQ==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
|
@ -6821,30 +6831,6 @@ packages:
|
||||||
jest-util: 27.5.1
|
jest-util: 27.5.1
|
||||||
dev: true
|
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:
|
/@jest/fake-timers@29.5.0:
|
||||||
resolution: {integrity: sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg==}
|
resolution: {integrity: sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
|
@ -14021,13 +14007,13 @@ packages:
|
||||||
canvas:
|
canvas:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/environment': 29.2.2
|
'@jest/environment': 29.5.0
|
||||||
'@jest/fake-timers': 29.2.2
|
'@jest/fake-timers': 29.5.0
|
||||||
'@jest/types': 29.5.0
|
'@jest/types': 29.5.0
|
||||||
'@types/jsdom': 20.0.0
|
'@types/jsdom': 20.0.0
|
||||||
'@types/node': 18.11.18
|
'@types/node': 18.11.18
|
||||||
jest-mock: 29.2.2
|
jest-mock: 29.5.0
|
||||||
jest-util: 29.2.1
|
jest-util: 29.5.0
|
||||||
jsdom: 20.0.2
|
jsdom: 20.0.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
|
@ -14051,11 +14037,11 @@ packages:
|
||||||
resolution: {integrity: sha512-x/H2kdVgxSkxWAIlIh9MfMuBa0hZySmfsC5lCsWmWr6tZySP44ediRKDUiNggX/eHLH7Cd5ZN10Rw+XF5tXsqg==}
|
resolution: {integrity: sha512-x/H2kdVgxSkxWAIlIh9MfMuBa0hZySmfsC5lCsWmWr6tZySP44ediRKDUiNggX/eHLH7Cd5ZN10Rw+XF5tXsqg==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/environment': 29.4.1
|
'@jest/environment': 29.5.0
|
||||||
'@jest/fake-timers': 29.4.1
|
'@jest/fake-timers': 29.5.0
|
||||||
'@jest/types': 29.5.0
|
'@jest/types': 29.5.0
|
||||||
'@types/node': 18.11.18
|
'@types/node': 18.11.18
|
||||||
jest-mock: 29.4.1
|
jest-mock: 29.5.0
|
||||||
jest-util: 29.5.0
|
jest-util: 29.5.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
@ -14146,21 +14132,6 @@ packages:
|
||||||
stack-utils: 2.0.5
|
stack-utils: 2.0.5
|
||||||
dev: true
|
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:
|
/jest-message-util@29.5.0:
|
||||||
resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==}
|
resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
|
@ -14184,24 +14155,6 @@ packages:
|
||||||
'@types/node': 18.11.18
|
'@types/node': 18.11.18
|
||||||
dev: true
|
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:
|
/jest-mock@29.5.0:
|
||||||
resolution: {integrity: sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw==}
|
resolution: {integrity: sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
|
@ -14383,30 +14336,6 @@ packages:
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
dev: true
|
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:
|
/jest-util@29.5.0:
|
||||||
resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==}
|
resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
|
|
Loading…
Reference in a new issue