From 20418dc4f9581204513199f388f7c9afee56cb3d Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 24 Apr 2023 16:05:26 +0800 Subject: [PATCH] refactor(console,ui,demo-app): fix i18next types (#3743) * refactor(console,ui,demo-app): fix i18next types disable allowObjectInHTMLChildren for safer typing. use for all dynamic translations. * fix(console): i18n key in UserAccountInformation * refactor(console,ui): update and add tests --- packages/console/jest.config.ts | 33 ++++ packages/console/jest.setup.ts | 9 ++ packages/console/package.json | 14 +- .../console/src/components/Alert/index.tsx | 10 +- .../console/src/components/Button/index.tsx | 10 +- .../src/components/CardTitle/index.tsx | 7 +- .../src/components/DetailsPage/index.tsx | 6 +- .../src/components/DynamicT/index.test.tsx | 22 +++ .../console/src/components/DynamicT/index.tsx | 23 ++- .../console/src/components/FormCard/index.tsx | 4 +- .../src/components/PermissionsTable/index.tsx | 3 +- .../src/components/RadioGroup/Radio.tsx | 5 +- .../src/components/Table/TablePlaceholder.tsx | 4 +- .../UserAccountInformation/index.tsx | 2 +- .../AppContent/components/SubMenu/index.tsx | 7 +- .../Sidebar/components/Contact/index.tsx | 11 +- packages/console/src/include.d/i18next.d.ts | 2 - .../MultiCardSelector/CardItem.tsx | 15 +- .../components/ReachLogto/index.tsx | 12 +- .../components/ConnectorTypeColumn/index.tsx | 2 +- .../console/src/pages/GetStarted/index.tsx | 9 +- .../Profile/components/CardContent/index.tsx | 6 +- .../components/MainFlowLikeModal/index.tsx | 5 +- packages/console/tsconfig.json | 2 +- .../demo-app/src/include.d/react-i18next.d.ts | 1 - .../src/Layout/SecondaryPageLayout/index.tsx | 11 +- .../src/components/BrandingHeader/index.tsx | 11 +- packages/ui/src/components/Button/index.tsx | 7 +- .../ui/src/components/DynamicT/index.test.tsx | 26 ++++ packages/ui/src/components/DynamicT/index.tsx | 18 ++- .../Notification/InlineNotification/index.tsx | 11 +- .../containers/SocialLinkAccount/index.tsx | 5 +- packages/ui/src/include.d/i18next.d.ts | 1 - packages/ui/src/jest.setup.ts | 22 ++- packages/ui/src/pages/ErrorPage/index.tsx | 15 +- pnpm-lock.yaml | 145 +++++------------- 36 files changed, 292 insertions(+), 204 deletions(-) create mode 100644 packages/console/jest.config.ts create mode 100644 packages/console/jest.setup.ts create mode 100644 packages/console/src/components/DynamicT/index.test.tsx create mode 100644 packages/ui/src/components/DynamicT/index.test.tsx diff --git a/packages/console/jest.config.ts b/packages/console/jest.config.ts new file mode 100644 index 000000000..2b732771d --- /dev/null +++ b/packages/console/jest.config.ts @@ -0,0 +1,33 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + roots: ['/src'], + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/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: { + '^@/(.*)$': '/src/$1', + '^@logto/app-insights/(.*)$': '/../app-insights/lib/$1', + '^@logto/shared/(.*)$': '/../shared/lib/$1', + '\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', + }, + transformIgnorePatterns: ['node_modules/(?!(.*(nanoid|jose|ky|@logto|@silverhand))/)'], +}; + +export default config; diff --git a/packages/console/jest.setup.ts b/packages/console/jest.setup.ts new file mode 100644 index 000000000..a14c86087 --- /dev/null +++ b/packages/console/jest.setup.ts @@ -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 }, +}); diff --git a/packages/console/package.json b/packages/console/package.json index 2c3ad960f..fe87bb1b7 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -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", diff --git a/packages/console/src/components/Alert/index.tsx b/packages/console/src/components/Alert/index.tsx index 08f62b4ca..2b2368861 100644 --- a/packages/console/src/components/Alert/index.tsx +++ b/packages/console/src/components/Alert/index.tsx @@ -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 (
{children}
- {action && href && {t(action)}} + {action && href && ( + + + + )} {action && onClick && (
); diff --git a/packages/console/src/components/CardTitle/index.tsx b/packages/console/src/components/CardTitle/index.tsx index f7f9af9f4..1c20e3c32 100644 --- a/packages/console/src/components/CardTitle/index.tsx +++ b/packages/console/src/components/CardTitle/index.tsx @@ -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 (
- {typeof title === 'string' ? t(title) : title} + {typeof title === 'string' ? : title}
{Boolean(subtitle ?? learnMoreLink) && (
- {subtitle && {typeof subtitle === 'string' ? t(subtitle) : subtitle}} + {subtitle && ( + {typeof subtitle === 'string' ? : subtitle} + )} {learnMoreLink && ( {t('general.learn_more')} diff --git a/packages/console/src/components/DetailsPage/index.tsx b/packages/console/src/components/DetailsPage/index.tsx index 3a4f3f041..969fb7ddf 100644 --- a/packages/console/src/components/DetailsPage/index.tsx +++ b/packages/console/src/components/DetailsPage/index.tsx @@ -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 (
} className={styles.backLink}> - {typeof backLinkTitle === 'string' ? t(backLinkTitle) : backLinkTitle} + {typeof backLinkTitle === 'string' ? : backLinkTitle} {isLoading ? ( diff --git a/packages/console/src/components/DynamicT/index.test.tsx b/packages/console/src/components/DynamicT/index.test.tsx new file mode 100644 index 000000000..10a51ace3 --- /dev/null +++ b/packages/console/src/components/DynamicT/index.test.tsx @@ -0,0 +1,22 @@ +import { render } from '@testing-library/react'; +import { t, type TFuncKey, type TypeOptions } from 'i18next'; + +import DynamicT from '.'; + +describe('', () => { + it('should render a correct key', () => { + const key: TFuncKey = 'general.add'; + const { container } = render(); + + expect(container.innerHTML).toBe(t(`admin_console.${key}`)); + }); + + it('should render an error message for a non-leaf key', () => { + const key: TFuncKey = 'general'; + const { container } = render(); + + expect(container.innerHTML).toBe( + `key 'admin_console.${key} (en)' returned an object instead of string.` + ); + }); +}); diff --git a/packages/console/src/components/DynamicT/index.tsx b/packages/console/src/components/DynamicT/index.tsx index 06269645e..98f9b54c3 100644 --- a/packages/console/src/components/DynamicT/index.tsx +++ b/packages/console/src/components/DynamicT/index.tsx @@ -3,16 +3,29 @@ import { useTranslation } from 'react-i18next'; type Props = { forKey: AdminConsoleKey; + interpolation?: Record; }; /** * 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 ?? {})}; } diff --git a/packages/console/src/components/FormCard/index.tsx b/packages/console/src/components/FormCard/index.tsx index f5589fcf9..3138d7e99 100644 --- a/packages/console/src/components/FormCard/index.tsx +++ b/packages/console/src/components/FormCard/index.tsx @@ -21,7 +21,9 @@ function FormCard({ title, description, learnMoreLink, children }: Props) { return (
-
{t(title)}
+
+ +
{description && (
diff --git a/packages/console/src/components/PermissionsTable/index.tsx b/packages/console/src/components/PermissionsTable/index.tsx index d1d9224e5..2a81d4458 100644 --- a/packages/console/src/components/PermissionsTable/index.tsx +++ b/packages/console/src/components/PermissionsTable/index.tsx @@ -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 : ( - {t(deleteButtonTitle)}
}> + }> { deleteHandler(scope); diff --git a/packages/console/src/components/RadioGroup/Radio.tsx b/packages/console/src/components/RadioGroup/Radio.tsx index 03414cffc..187cefb42 100644 --- a/packages/console/src/components/RadioGroup/Radio.tsx +++ b/packages/console/src/components/RadioGroup/Radio.tsx @@ -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 = useCallback( (event) => { if (isDisabled) { @@ -93,7 +90,7 @@ function Radio({ {title && (typeof title === 'string' ? : title)} {isDisabled && disabledLabel && (
- {t(disabledLabel)} +
)}
diff --git a/packages/console/src/components/Table/TablePlaceholder.tsx b/packages/console/src/components/Table/TablePlaceholder.tsx index 8fa5b1760..0f01851a8 100644 --- a/packages/console/src/components/Table/TablePlaceholder.tsx +++ b/packages/console/src/components/Table/TablePlaceholder.tsx @@ -26,7 +26,9 @@ function TablePlaceholder({ image, imageDark, title, description, learnMoreLink, return (
{theme === Theme.Light ? image : imageDark}
-
{t(title)}
+
+ +
{learnMoreLink && ( diff --git a/packages/console/src/components/UserAccountInformation/index.tsx b/packages/console/src/components/UserAccountInformation/index.tsx index 3ef25c509..f6f02ad02 100644 --- a/packages/console/src/components/UserAccountInformation/index.tsx +++ b/packages/console/src/components/UserAccountInformation/index.tsx @@ -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); diff --git a/packages/console/src/containers/AppContent/components/SubMenu/index.tsx b/packages/console/src/containers/AppContent/components/SubMenu/index.tsx index 8fb8c1762..1a5303466 100644 --- a/packages/console/src/containers/AppContent/components/SubMenu/index.tsx +++ b/packages/console/src/containers/AppContent/components/SubMenu/index.tsx @@ -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({ selectedOption, onItemClick, }: Props) { - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const anchorRef = useRef(null); const [showMenu, setShowMenu] = useState(false); const mouseEnterTimeoutRef = useRef(0); @@ -67,7 +66,9 @@ function SubMenu({ }} > {icon && {icon}} - {t(title)} + + +
diff --git a/packages/console/src/containers/ConsoleContent/Sidebar/components/Contact/index.tsx b/packages/console/src/containers/ConsoleContent/Sidebar/components/Contact/index.tsx index 1c790aee8..5f2676d9e 100644 --- a/packages/console/src/containers/ConsoleContent/Sidebar/components/Contact/index.tsx +++ b/packages/console/src/containers/ConsoleContent/Sidebar/components/Contact/index.tsx @@ -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) {
-
{t(title)}
-
{t(description)}
+
+ +
+
+ +
diff --git a/packages/console/src/onboarding/components/ReachLogto/index.tsx b/packages/console/src/onboarding/components/ReachLogto/index.tsx index 0f988b1c0..850fc2317 100644 --- a/packages/console/src/onboarding/components/ReachLogto/index.tsx +++ b/packages/console/src/onboarding/components/ReachLogto/index.tsx @@ -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 (
{cloneElement(icon, { className: styles.reachLogtoIcon })}
-
{t(title)}
-
{t(description)}
+
+ +
+
+ +