polish(theme-classic): guard against potential definition mistakes in Tabs (#5674)

This commit is contained in:
Joshua Chen 2021-10-14 21:47:03 +08:00 committed by GitHub
parent 8d92e9bcf5
commit c8739ec28e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 224 additions and 23 deletions

View file

@ -33,11 +33,14 @@ module.exports = {
},
setupFiles: ['./jest/stylelint-rule-test.js', './jest/polyfills.js'],
moduleNameMapper: {
// Jest can't resolve CSS imports
'^.+\\.css$': '<rootDir>/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': '<rootDir>/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/.*': '<rootDir>/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',
},
};

View file

@ -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",

View file

@ -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": {

View file

@ -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(
<Tabs>
<div>Naughty</div>
<TabItem value="good">Good</TabItem>
</Tabs>,
);
}).toThrowErrorMatchingInlineSnapshot(
`"Docusaurus error: Bad <Tabs> child <div>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique \\"value\\" prop."`,
);
});
test('Should reject bad Tabs defaultValue', () => {
expect(() => {
renderer.create(
<Tabs defaultValue="bad">
<TabItem value="v1">Tab 1</TabItem>
<TabItem value="v2">Tab 2</TabItem>
</Tabs>,
);
}).toThrowErrorMatchingInlineSnapshot(
`"Docusaurus error: The <Tabs> 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(
<Tabs>
<TabItem value="v1">Tab 1</TabItem>
<TabItem value="v2">Tab 2</TabItem>
<TabItem value="v3">Tab 3</TabItem>
<TabItem value="v4">Tab 4</TabItem>
<TabItem value="v1">Tab 5</TabItem>
<TabItem value="v2">Tab 6</TabItem>
</Tabs>,
);
}).toThrowErrorMatchingInlineSnapshot(
`"Docusaurus error: Duplicate values \\"v1, v2\\" found in <Tabs>. Every value needs to be unique."`,
);
});
test('Should accept valid Tabs config', () => {
expect(() => {
renderer.create(
<>
<Tabs>
<TabItem value="v1">Tab 1</TabItem>
<TabItem value="v2">Tab 2</TabItem>
</Tabs>
<Tabs>
<TabItem value="v1">Tab 1</TabItem>
<TabItem value="v2" default>
Tab 2
</TabItem>
</Tabs>
<Tabs defaultValue="v1">
<TabItem value="v1" label="V1">
Tab 1
</TabItem>
<TabItem value="v2" label="V2">
Tab 2
</TabItem>
</Tabs>
<Tabs
defaultValue="v1"
values={[
{value: 'v1', label: 'V1'},
{value: 'v2', label: 'V2'},
]}>
<TabItem>Tab 1</TabItem>
<TabItem>Tab 2</TabItem>
</Tabs>
<Tabs
defaultValue={null}
values={[
{value: 'v1', label: 'V1'},
{value: 'v2', label: 'V2'},
]}>
<TabItem>Tab 1</TabItem>
<TabItem>Tab 2</TabItem>
</Tabs>
<Tabs defaultValue={null}>
<TabItem value="v1" label="V1">
Tab 1
</TabItem>
<TabItem value="v2" label="V2">
Tab 2
</TabItem>
</Tabs>
</>,
);
}).toMatchInlineSnapshot(`[Function]`); // This is just a check that the function returns. We don't care about the actual DOM.
});
});

View file

@ -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<TabItemProps> {
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<TabItemProps>[];
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 <Tabs> child <${
// @ts-expect-error: guarding against unexpected cases
typeof child.type === 'string' ? child.type : child.type.name
}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> 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 <Tabs>. Every value needs to be unique.`,
);
}
// When defaultValueProp is null, don't show a default tab
const defaultValue =
defaultValueProp ??
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 <Tabs> 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;
}

View file

@ -529,7 +529,7 @@ declare module '@theme/Tabs' {
readonly lazy?: boolean;
readonly block?: boolean;
readonly children: readonly ReactElement<TabItemProps>[];
readonly defaultValue?: string;
readonly defaultValue?: string | null;
readonly values?: readonly {value: string; label?: string}[];
readonly groupId?: string;
readonly className?: string;

View file

@ -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';

View file

@ -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<T>(
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,
);
}

View file

@ -131,7 +131,7 @@ It is possible to only render the default tab with `<Tabs lazy />`.
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}

View file

@ -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"