diff --git a/jest.config.js b/jest.config.js index cf9c4a1707..58605dd824 100644 --- a/jest.config.js +++ b/jest.config.js @@ -33,11 +33,14 @@ module.exports = { }, setupFiles: ['./jest/stylelint-rule-test.js', './jest/polyfills.js'], moduleNameMapper: { + // Jest can't resolve CSS imports + '^.+\\.css$': '/jest/emptyModule.js', // TODO we need to allow Jest to resolve core Webpack aliases automatically - '@docusaurus/router': 'react-router-dom', - '@docusaurus/Translate': '@docusaurus/core/lib/client/exports/Translate', - '@docusaurus/Interpolate': - '@docusaurus/core/lib/client/exports/Interpolate', - '@generated/codeTranslations': '/jest/emptyModule.js', + '@docusaurus/(browserContext|BrowserOnly|ComponentCreator|constants|docusaurusContext|ExecutionEnvironment|Head|Interpolate|isInternalUrl|Link|Noop|renderRoutes|router|Translate|use.*)': + '@docusaurus/core/lib/client/exports/$1', + // Maybe point to a fixture? + '@generated/.*': '/jest/emptyModule.js', + // TODO maybe use "projects" + multiple configs if we plan to add tests to another theme? + '@theme/(.*)': '@docusaurus/theme-classic/lib-next/theme/$1', }, }; diff --git a/package.json b/package.json index 1df9b693a6..fa46ef2703 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@types/react-helmet": "^6.0.0", "@types/react-loadable": "^5.5.3", "@types/react-router-config": "^5.0.1", + "@types/react-test-renderer": "^17.0.1", "@types/semver": "^7.1.0", "@types/shelljs": "^0.8.6", "@types/wait-on": "^5.2.0", @@ -110,6 +111,7 @@ "prettier": "^2.4.1", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", "serve": "^12.0.1", "stylelint": "^13.10.0", diff --git a/packages/docusaurus-theme-classic/package.json b/packages/docusaurus-theme-classic/package.json index 0758caac76..5133cc6fa5 100644 --- a/packages/docusaurus-theme-classic/package.json +++ b/packages/docusaurus-theme-classic/package.json @@ -54,6 +54,7 @@ "@types/mdx-js__react": "^1.5.4", "@types/parse-numeric-range": "^0.0.1", "@types/rtlcss": "^3.1.1", + "react-test-renderer": "^17.0.2", "utility-types": "^3.10.0" }, "peerDependencies": { diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx new file mode 100644 index 0000000000..48cede2080 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx @@ -0,0 +1,106 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import renderer from 'react-test-renderer'; +import Tabs from '../index'; +import TabItem from '../../TabItem'; + +describe('Tabs', () => { + test('Should reject bad Tabs child', () => { + expect(() => { + renderer.create( + +
Naughty
+ Good +
, + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Docusaurus error: Bad child
: all children of the component should be , and every should have a unique \\"value\\" prop."`, + ); + }); + test('Should reject bad Tabs defaultValue', () => { + expect(() => { + renderer.create( + + Tab 1 + Tab 2 + , + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Docusaurus error: The has a defaultValue \\"bad\\" but none of its children has the corresponding value. Available values are: v1, v2. If you intend to show no default tab, use defaultValue={null} instead."`, + ); + }); + test('Should reject duplicate values', () => { + expect(() => { + renderer.create( + + Tab 1 + Tab 2 + Tab 3 + Tab 4 + Tab 5 + Tab 6 + , + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Docusaurus error: Duplicate values \\"v1, v2\\" found in . Every value needs to be unique."`, + ); + }); + test('Should accept valid Tabs config', () => { + expect(() => { + renderer.create( + <> + + Tab 1 + Tab 2 + + + Tab 1 + + Tab 2 + + + + + Tab 1 + + + Tab 2 + + + + Tab 1 + Tab 2 + + + Tab 1 + Tab 2 + + + + Tab 1 + + + Tab 2 + + + , + ); + }).toMatchInlineSnapshot(`[Function]`); // This is just a check that the function returns. We don't care about the actual DOM. + }); +}); diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 9c58869c4b..91871dae86 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -5,10 +5,16 @@ * LICENSE file in the root directory of this source tree. */ -import React, {useState, cloneElement, Children, ReactElement} from 'react'; +import React, { + useState, + cloneElement, + Children, + isValidElement, + ReactElement, +} from 'react'; import useIsBrowser from '@docusaurus/useIsBrowser'; import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext'; -import {useScrollPositionBlocker} from '@docusaurus/theme-common'; +import {useScrollPositionBlocker, duplicates} from '@docusaurus/theme-common'; import type {Props} from '@theme/Tabs'; import type {Props as TabItemProps} from '@theme/TabItem'; @@ -16,6 +22,12 @@ import clsx from 'clsx'; import styles from './styles.module.css'; +// A very rough duck type, but good enough to guard against mistakes while +// allowing customization +function isTabItem(comp: ReactElement): comp is ReactElement { + return typeof comp.props.value === 'string'; +} + function TabsComponent(props: Props): JSX.Element { const { lazy, @@ -25,21 +37,48 @@ function TabsComponent(props: Props): JSX.Element { groupId, className, } = props; - const children = Children.toArray( - props.children, - ) as ReactElement[]; + const children = Children.map(props.children, (child) => { + if (isValidElement(child) && isTabItem(child)) { + return child; + } + // child.type.name will give non-sensical values in prod because of + // minification, but we assume it won't throw in prod. + throw new Error( + `Docusaurus error: Bad child <${ + // @ts-expect-error: guarding against unexpected cases + typeof child.type === 'string' ? child.type : child.type.name + }>: all children of the component should be , and every should have a unique "value" prop.`, + ); + }); const values = valuesProp ?? - children.map((child) => { - return { - value: child.props.value, - label: child.props.label, - }; + children.map(({props: {value, label}}) => { + return {value, label}; }); + const dup = duplicates(values, (a, b) => a.value === b.value); + if (dup.length > 0) { + throw new Error( + `Docusaurus error: Duplicate values "${dup + .map((a) => a.value) + .join(', ')}" found in . Every value needs to be unique.`, + ); + } + // When defaultValueProp is null, don't show a default tab const defaultValue = - defaultValueProp ?? - children.find((child) => child.props.default)?.props.value ?? - children[0]?.props.value; + defaultValueProp === null + ? defaultValueProp + : defaultValueProp ?? + children.find((child) => child.props.default)?.props.value ?? + children[0]?.props.value; + if (defaultValue !== null && !values.some((a) => a.value === defaultValue)) { + throw new Error( + `Docusaurus error: The has a defaultValue "${defaultValue}" but none of its children has the corresponding value. Available values are: ${values + .map((a) => a.value) + .join( + ', ', + )}. If you intend to show no default tab, use defaultValue={null} instead.`, + ); + } const {tabGroupChoices, setTabGroupChoices} = useUserPreferencesContext(); const [selectedValue, setSelectedValue] = useState(defaultValue); @@ -80,12 +119,12 @@ function TabsComponent(props: Props): JSX.Element { switch (event.key) { case 'ArrowRight': { - const nextTab = tabRefs.indexOf(event.target as HTMLLIElement) + 1; + const nextTab = tabRefs.indexOf(event.currentTarget) + 1; focusElement = tabRefs[nextTab] || tabRefs[0]; break; } case 'ArrowLeft': { - const prevTab = tabRefs.indexOf(event.target as HTMLLIElement) - 1; + const prevTab = tabRefs.indexOf(event.currentTarget) - 1; focusElement = tabRefs[prevTab] || tabRefs[tabRefs.length - 1]; break; } diff --git a/packages/docusaurus-theme-classic/src/types.d.ts b/packages/docusaurus-theme-classic/src/types.d.ts index a59ecc3791..d2c8d415c8 100644 --- a/packages/docusaurus-theme-classic/src/types.d.ts +++ b/packages/docusaurus-theme-classic/src/types.d.ts @@ -529,7 +529,7 @@ declare module '@theme/Tabs' { readonly lazy?: boolean; readonly block?: boolean; readonly children: readonly ReactElement[]; - readonly defaultValue?: string; + readonly defaultValue?: string | null; readonly values?: readonly {value: string; label?: string}[]; readonly groupId?: string; readonly className?: string; diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 03f349a9a0..f5c9ab1cdd 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -59,6 +59,8 @@ export { useDocsPreferredVersionByPluginId, } from './utils/docsPreferredVersion/useDocsPreferredVersion'; +export {duplicates} from './utils/jsUtils'; + export {DocsPreferredVersionContextProvider} from './utils/docsPreferredVersion/DocsPreferredVersionProvider'; export {ThemeClassNames} from './utils/ThemeClassNames'; diff --git a/packages/docusaurus-theme-common/src/utils/jsUtils.ts b/packages/docusaurus-theme-common/src/utils/jsUtils.ts new file mode 100644 index 0000000000..bbb3e58919 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/jsUtils.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// A replacement of lodash in client code + +/** + * Gets the duplicate values in an array. + * @param arr The array. + * @param comparator Compares two values and returns `true` if they are equal (duplicated). + * @returns Value of the elements `v` that have a preceding element `u` where `comparator(u, v) === true`. Values within the returned array are not guaranteed to be unique. + */ +export function duplicates( + arr: readonly T[], + comparator: (a: T, b: T) => boolean = (a, b) => a === b, +): T[] { + return arr.filter( + (v, vIndex) => arr.findIndex((u) => comparator(u, v)) !== vIndex, + ); +} diff --git a/website/docs/guides/markdown-features/markdown-features-tabs.mdx b/website/docs/guides/markdown-features/markdown-features-tabs.mdx index d2f2e186f8..5fc9d5acd1 100644 --- a/website/docs/guides/markdown-features/markdown-features-tabs.mdx +++ b/website/docs/guides/markdown-features/markdown-features-tabs.mdx @@ -131,7 +131,7 @@ It is possible to only render the default tab with ``. The first tab is displayed by default, and to override this behavior, you can specify a default tab by adding `default` to one of the tab items. You can also set the `defaultValue` prop of the `Tabs` component to the label value of your choice. For example, in the example above, either setting `default` for the `value="apple"` tab or setting `defaultValue="apple"` for the tabs forces the "Apple" tab to be open by default. -If `defaultValue` is provided for the `Tabs`, but it refers to an non-existing value, only the tab headings will appear until the user clicks on a tab. +Docusaurus will throw an error if a `defaultValue` is provided for the `Tabs` but it refers to an non-existing value. If you want none of the tabs to be shown by default, use `defaultValue={null}`. ## Syncing tab choices {#syncing-tab-choices} diff --git a/yarn.lock b/yarn.lock index d9cd6b6be9..c73b6d0f17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4465,6 +4465,13 @@ "@types/history" "*" "@types/react" "*" +"@types/react-test-renderer@^17.0.1": + version "17.0.1" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz#3120f7d1c157fba9df0118dae20cb0297ee0e06b" + integrity sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^17.0.2": version "17.0.24" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.24.tgz#7e1b3f78d0fc53782543f9bce6d949959a5880bd" @@ -16741,7 +16748,7 @@ react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-i resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: +"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== @@ -16824,6 +16831,14 @@ react-router@5.2.1, react-router@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-shallow-renderer@^16.13.1: + version "16.14.1" + resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.14.1.tgz#bf0d02df8a519a558fd9b8215442efa5c840e124" + integrity sha512-rkIMcQi01/+kxiTE9D3fdS959U1g7gs+/rborw++42m1O9FAQiNI/UNRZExVUoAOprn4umcXf+pFRou8i4zuBg== + dependencies: + object-assign "^4.1.1" + react-is "^16.12.0 || ^17.0.0" + react-side-effect@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3" @@ -16834,6 +16849,16 @@ react-simple-code-editor@^0.10.0: resolved "https://registry.yarnpkg.com/react-simple-code-editor/-/react-simple-code-editor-0.10.0.tgz#73e7ac550a928069715482aeb33ccba36efe2373" integrity sha512-bL5W5mAxSW6+cLwqqVWY47Silqgy2DKDTR4hDBrLrUqC5BXc29YVx17l2IZk5v36VcDEq1Bszu2oHm1qBwKqBA== +react-test-renderer@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-17.0.2.tgz#4cd4ae5ef1ad5670fc0ef776e8cc7e1231d9866c" + integrity sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ== + dependencies: + object-assign "^4.1.1" + react-is "^17.0.2" + react-shallow-renderer "^16.13.1" + scheduler "^0.20.2" + react-textarea-autosize@^8.3.2: version "8.3.3" resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.3.tgz#f70913945369da453fd554c168f6baacd1fa04d8"