mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-29 10:17:55 +02:00
test: improve test coverage; reorder theme-common files (#6956)
* test: improve test coverage; reorder theme-common files * no need for this
This commit is contained in:
parent
0a5354dc32
commit
948271a0ff
38 changed files with 555 additions and 317 deletions
|
@ -14,13 +14,16 @@ const ignorePatterns = [
|
|||
'__fixtures__',
|
||||
'/testUtils.ts',
|
||||
'/packages/docusaurus/lib',
|
||||
'/packages/docusaurus-logger/lib',
|
||||
'/packages/docusaurus-utils/lib',
|
||||
'/packages/docusaurus-utils-common/lib',
|
||||
'/packages/docusaurus-utils-validation/lib',
|
||||
'/packages/docusaurus-plugin-content-blog/lib',
|
||||
'/packages/docusaurus-plugin-content-docs/lib',
|
||||
'/packages/docusaurus-plugin-content-pages/lib',
|
||||
'/packages/docusaurus-theme-classic/lib',
|
||||
'/packages/docusaurus-theme-classic/lib-next',
|
||||
'/packages/docusaurus-theme-common/lib',
|
||||
'/packages/docusaurus-migrate/lib',
|
||||
];
|
||||
|
||||
|
@ -30,7 +33,11 @@ export default {
|
|||
testURL: 'https://docusaurus.io/',
|
||||
testEnvironment: 'node',
|
||||
testPathIgnorePatterns: ignorePatterns,
|
||||
coveragePathIgnorePatterns: ignorePatterns,
|
||||
coveragePathIgnorePatterns: [
|
||||
...ignorePatterns,
|
||||
// We also ignore all package entry points
|
||||
'/packages/docusaurus-utils/src/index.ts',
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.[jt]sx?$': '@swc/jest',
|
||||
},
|
||||
|
@ -54,7 +61,10 @@ export default {
|
|||
'@docusaurus/plugin-content-docs/client':
|
||||
'@docusaurus/plugin-content-docs/src/client/index.ts',
|
||||
},
|
||||
snapshotSerializers: ['<rootDir>/jest/snapshotPathNormalizer.ts'],
|
||||
snapshotSerializers: [
|
||||
'<rootDir>/jest/snapshotPathNormalizer.ts',
|
||||
'jest-serializer-react-helmet-async',
|
||||
],
|
||||
snapshotFormat: {
|
||||
printBasicPrototype: false,
|
||||
},
|
||||
|
|
|
@ -95,6 +95,7 @@
|
|||
"husky": "^7.0.4",
|
||||
"image-size": "^1.0.1",
|
||||
"jest": "^27.5.1",
|
||||
"jest-serializer-react-helmet-async": "^1.0.21",
|
||||
"lerna": "^4.0.0",
|
||||
"lerna-changelog": "^2.2.0",
|
||||
"lint-staged": "^12.3.7",
|
||||
|
|
|
@ -34,7 +34,7 @@ export type DetailsProps = {
|
|||
summary?: ReactElement;
|
||||
} & ComponentProps<'details'>;
|
||||
|
||||
export default function Details({
|
||||
export function Details({
|
||||
summary,
|
||||
children,
|
||||
...props
|
||||
|
|
|
@ -14,9 +14,9 @@ import React, {
|
|||
type ReactNode,
|
||||
} from 'react';
|
||||
import useIsBrowser from '@docusaurus/useIsBrowser';
|
||||
import {createStorageSlot} from './storageUtils';
|
||||
import {ReactContextError} from './reactUtils';
|
||||
import {useThemeConfig} from './useThemeConfig';
|
||||
import {createStorageSlot} from '../utils/storageUtils';
|
||||
import {ReactContextError} from '../utils/reactUtils';
|
||||
import {useThemeConfig} from '../utils/useThemeConfig';
|
||||
|
||||
export const AnnouncementBarDismissStorageKey =
|
||||
'docusaurus.announcement.dismiss';
|
|
@ -14,11 +14,11 @@ import React, {
|
|||
useRef,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {ReactContextError} from './reactUtils';
|
||||
import {ReactContextError} from '../utils/reactUtils';
|
||||
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
import {createStorageSlot} from './storageUtils';
|
||||
import {useThemeConfig} from './useThemeConfig';
|
||||
import {createStorageSlot} from '../utils/storageUtils';
|
||||
import {useThemeConfig} from '../utils/useThemeConfig';
|
||||
|
||||
type ColorModeContextValue = {
|
||||
readonly colorMode: ColorMode;
|
||||
|
@ -171,9 +171,7 @@ export function ColorModeProvider({
|
|||
}
|
||||
|
||||
export function useColorMode(): ColorModeContextValue {
|
||||
const context = useContext<ColorModeContextValue | undefined>(
|
||||
ColorModeContext,
|
||||
);
|
||||
const context = useContext(ColorModeContext);
|
||||
if (context == null) {
|
||||
throw new ReactContextError(
|
||||
'ColorModeProvider',
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import React, {type ReactNode, useMemo, useState, useContext} from 'react';
|
||||
import {ReactContextError} from './reactUtils';
|
||||
import {ReactContextError} from '../utils/reactUtils';
|
||||
|
||||
const EmptyContext: unique symbol = Symbol('EmptyContext');
|
||||
const Context = React.createContext<
|
|
@ -10,18 +10,47 @@ import React, {
|
|||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {useThemeConfig, type DocsVersionPersistence} from '../useThemeConfig';
|
||||
import {isDocsPluginEnabled} from '../docsUtils';
|
||||
import {ReactContextError} from '../reactUtils';
|
||||
import {
|
||||
useThemeConfig,
|
||||
type DocsVersionPersistence,
|
||||
} from '../utils/useThemeConfig';
|
||||
import {isDocsPluginEnabled} from '../utils/docsUtils';
|
||||
import {ReactContextError} from '../utils/reactUtils';
|
||||
import {createStorageSlot} from '../utils/storageUtils';
|
||||
|
||||
import {
|
||||
useAllDocsData,
|
||||
useDocsData,
|
||||
type GlobalPluginData,
|
||||
type GlobalVersion,
|
||||
} from '@docusaurus/plugin-content-docs/client';
|
||||
|
||||
import DocsPreferredVersionStorage from './DocsPreferredVersionStorage';
|
||||
import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants';
|
||||
|
||||
const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`;
|
||||
|
||||
const DocsPreferredVersionStorage = {
|
||||
save: (
|
||||
pluginId: string,
|
||||
persistence: DocsVersionPersistence,
|
||||
versionName: string,
|
||||
): void => {
|
||||
createStorageSlot(storageKey(pluginId), {persistence}).set(versionName);
|
||||
},
|
||||
|
||||
read: (
|
||||
pluginId: string,
|
||||
persistence: DocsVersionPersistence,
|
||||
): string | null =>
|
||||
createStorageSlot(storageKey(pluginId), {persistence}).get(),
|
||||
|
||||
clear: (pluginId: string, persistence: DocsVersionPersistence): void => {
|
||||
createStorageSlot(storageKey(pluginId), {persistence}).del();
|
||||
},
|
||||
};
|
||||
|
||||
type DocsPreferredVersionName = string | null;
|
||||
|
||||
|
@ -158,10 +187,64 @@ function DocsPreferredVersionContextProviderUnsafe({
|
|||
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
export function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue {
|
||||
function useDocsPreferredVersionContext(): DocsPreferredVersionContextValue {
|
||||
const value = useContext(Context);
|
||||
if (!value) {
|
||||
throw new ReactContextError('DocsPreferredVersionContextProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Note, the preferredVersion attribute will always be null before mount
|
||||
export function useDocsPreferredVersion(
|
||||
pluginId: string | undefined = DEFAULT_PLUGIN_ID,
|
||||
): {
|
||||
preferredVersion: GlobalVersion | null | undefined;
|
||||
savePreferredVersionName: (versionName: string) => void;
|
||||
} {
|
||||
const docsData = useDocsData(pluginId);
|
||||
const [state, api] = useDocsPreferredVersionContext();
|
||||
|
||||
const {preferredVersionName} = state[pluginId]!;
|
||||
|
||||
const preferredVersion = preferredVersionName
|
||||
? docsData.versions.find((version) => version.name === preferredVersionName)
|
||||
: null;
|
||||
|
||||
const savePreferredVersionName = useCallback(
|
||||
(versionName: string) => {
|
||||
api.savePreferredVersion(pluginId, versionName);
|
||||
},
|
||||
[api, pluginId],
|
||||
);
|
||||
|
||||
return {preferredVersion, savePreferredVersionName} as const;
|
||||
}
|
||||
|
||||
export function useDocsPreferredVersionByPluginId(): Record<
|
||||
string,
|
||||
GlobalVersion | null | undefined
|
||||
> {
|
||||
const allDocsData = useAllDocsData();
|
||||
const [state] = useDocsPreferredVersionContext();
|
||||
|
||||
function getPluginIdPreferredVersion(pluginId: string) {
|
||||
const docsData = allDocsData[pluginId]!;
|
||||
const {preferredVersionName} = state[pluginId]!;
|
||||
|
||||
return preferredVersionName
|
||||
? docsData.versions.find(
|
||||
(version) => version.name === preferredVersionName,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
const pluginIds = Object.keys(allDocsData);
|
||||
|
||||
const result: Record<string, GlobalVersion | null | undefined> = {};
|
||||
pluginIds.forEach((pluginId) => {
|
||||
result[pluginId] = getPluginIdPreferredVersion(pluginId);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* 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, {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {useWindowSize} from '../hooks/useWindowSize';
|
||||
import {useHistoryPopHandler} from '../utils/historyUtils';
|
||||
import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
|
||||
import {useThemeConfig} from '../utils/useThemeConfig';
|
||||
import {ReactContextError} from '../utils/reactUtils';
|
||||
|
||||
type NavbarMobileSidebarContextValue = {
|
||||
disabled: boolean;
|
||||
shouldRender: boolean;
|
||||
toggle: () => void;
|
||||
shown: boolean;
|
||||
};
|
||||
|
||||
const Context = React.createContext<
|
||||
NavbarMobileSidebarContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
// Mobile sidebar can be disabled in case it would lead to an empty sidebar
|
||||
// In this case it's not useful to display a navbar sidebar toggle button
|
||||
function useNavbarMobileSidebarDisabled() {
|
||||
const activeDocPlugin = useActivePlugin();
|
||||
const {items} = useThemeConfig().navbar;
|
||||
return items.length === 0 && !activeDocPlugin;
|
||||
}
|
||||
|
||||
function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue {
|
||||
const disabled = useNavbarMobileSidebarDisabled();
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
// Mobile sidebar not visible until user interaction: can avoid SSR rendering
|
||||
const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr';
|
||||
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
// Close mobile sidebar on navigation pop
|
||||
// Most likely firing when using the Android back button (but not only)
|
||||
useHistoryPopHandler(() => {
|
||||
if (shown) {
|
||||
setShown(false);
|
||||
// Should we prevent the navigation here?
|
||||
// See https://github.com/facebook/docusaurus/pull/5462#issuecomment-911699846
|
||||
return false; // prevent pop navigation
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setShown((s) => !s);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowSize === 'desktop') {
|
||||
setShown(false);
|
||||
}
|
||||
}, [windowSize]);
|
||||
|
||||
// Return stable context value
|
||||
return useMemo(
|
||||
() => ({
|
||||
disabled,
|
||||
shouldRender,
|
||||
toggle,
|
||||
shown,
|
||||
}),
|
||||
[disabled, shouldRender, toggle, shown],
|
||||
);
|
||||
}
|
||||
|
||||
export function NavbarMobileSidebarProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const value = useNavbarMobileSidebarContextValue();
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue {
|
||||
const context = React.useContext(Context);
|
||||
if (context == null) {
|
||||
throw new ReactContextError('NavbarMobileSidebarProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
|
@ -13,8 +13,8 @@ import React, {
|
|||
useContext,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {createStorageSlot, listStorageKeys} from './storageUtils';
|
||||
import {ReactContextError} from './reactUtils';
|
||||
import {createStorageSlot, listStorageKeys} from '../utils/storageUtils';
|
||||
import {ReactContextError} from '../utils/reactUtils';
|
||||
|
||||
const TAB_CHOICE_PREFIX = 'docusaurus.tab.';
|
||||
|
|
@ -9,14 +9,10 @@ import {useState, useCallback, useRef} from 'react';
|
|||
import {useLocationChange} from '../utils/useLocationChange';
|
||||
import {useScrollPosition} from '../utils/scrollUtils';
|
||||
|
||||
type UseHideableNavbarReturns = {
|
||||
export function useHideableNavbar(hideOnScroll: boolean): {
|
||||
readonly navbarRef: (node: HTMLElement | null) => void;
|
||||
readonly isNavbarVisible: boolean;
|
||||
};
|
||||
|
||||
export default function useHideableNavbar(
|
||||
hideOnScroll: boolean,
|
||||
): UseHideableNavbarReturns {
|
||||
} {
|
||||
const [isNavbarVisible, setIsNavbarVisible] = useState(hideOnScroll);
|
||||
const isFocusedAnchor = useRef(false);
|
||||
const navbarHeight = useRef(0);
|
||||
|
|
|
@ -11,9 +11,11 @@ import './styles.css';
|
|||
|
||||
export const keyboardFocusedClassName = 'navigation-with-keyboard';
|
||||
|
||||
// This hook detect keyboard focus indicator to not show outline for mouse users
|
||||
// Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
|
||||
export default function useKeyboardNavigation(): void {
|
||||
/**
|
||||
* Detect keyboard focus indicator to not show outline for mouse users
|
||||
* Inspired by https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
|
||||
*/
|
||||
export function useKeyboardNavigation(): void {
|
||||
useEffect(() => {
|
||||
function handleOutlineStyles(e: MouseEvent | KeyboardEvent) {
|
||||
if (e.type === 'keydown' && (e as KeyboardEvent).key === 'Tab') {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import {useEffect} from 'react';
|
||||
|
||||
export default function useLockBodyScroll(lock: boolean = true): void {
|
||||
export function useLockBodyScroll(lock: boolean = true): void {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = lock ? 'hidden' : 'visible';
|
||||
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
*/
|
||||
|
||||
import defaultTheme from 'prism-react-renderer/themes/palenight';
|
||||
import {useColorMode} from '../utils/colorModeUtils';
|
||||
import {useColorMode} from '../contexts/colorMode';
|
||||
import {useThemeConfig} from '../utils/useThemeConfig';
|
||||
|
||||
export default function usePrismTheme(): typeof defaultTheme {
|
||||
export function usePrismTheme(): typeof defaultTheme {
|
||||
const {prism} = useThemeConfig();
|
||||
const {colorMode} = useColorMode();
|
||||
const lightModeTheme = prism.theme || defaultTheme;
|
||||
|
|
|
@ -11,13 +11,11 @@ import {useCallback, useEffect, useState} from 'react';
|
|||
|
||||
const SEARCH_PARAM_QUERY = 'q';
|
||||
|
||||
interface UseSearchPageReturn {
|
||||
export function useSearchPage(): {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (newSearchQuery: string) => void;
|
||||
generateSearchPageLink: (targetSearchQuery: string) => string;
|
||||
}
|
||||
|
||||
export default function useSearchPage(): UseSearchPageReturn {
|
||||
} {
|
||||
const history = useHistory();
|
||||
const {
|
||||
siteConfig: {baseUrl},
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {useThemeConfig} from './useThemeConfig';
|
||||
import {useThemeConfig} from '../utils/useThemeConfig';
|
||||
|
||||
// TODO make the hardcoded theme-classic classnames configurable (or add them
|
||||
// to ThemeClassNames?)
|
||||
|
@ -120,9 +120,7 @@ export type TOCHighlightConfig = {
|
|||
maxHeadingLevel: number;
|
||||
};
|
||||
|
||||
export default function useTOCHighlight(
|
||||
config: TOCHighlightConfig | undefined,
|
||||
): void {
|
||||
export function useTOCHighlight(config: TOCHighlightConfig | undefined): void {
|
||||
const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined);
|
||||
|
||||
const anchorTopOffsetRef = useAnchorTopOffsetRef();
|
|
@ -41,7 +41,7 @@ const DevSimulateSSR = process.env.NODE_ENV === 'development' && true;
|
|||
// This hook returns an enum value on purpose!
|
||||
// We don't want it to return the actual width value, for resize perf reasons
|
||||
// We only want to re-render once a breakpoint is crossed
|
||||
export default function useWindowSize(): WindowSize {
|
||||
export function useWindowSize(): WindowSize {
|
||||
const [windowSize, setWindowSize] = useState<WindowSize>(() => {
|
||||
if (DevSimulateSSR) {
|
||||
return 'ssr';
|
||||
|
|
|
@ -22,7 +22,7 @@ export {
|
|||
export {
|
||||
DocSidebarItemsExpandedStateProvider,
|
||||
useDocSidebarItemsExpandedState,
|
||||
} from './utils/docSidebarItemsExpandedState';
|
||||
} from './contexts/docSidebarItemsExpandedState';
|
||||
|
||||
export {createStorageSlot, listStorageKeys} from './utils/storageUtils';
|
||||
|
||||
|
@ -60,8 +60,6 @@ export {usePluralForm} from './utils/usePluralForm';
|
|||
|
||||
export {useLocationChange} from './utils/useLocationChange';
|
||||
|
||||
export {usePrevious} from './utils/usePrevious';
|
||||
|
||||
export {
|
||||
useCollapsible,
|
||||
Collapsible,
|
||||
|
@ -69,23 +67,22 @@ export {
|
|||
type UseCollapsibleReturns,
|
||||
} from './components/Collapsible';
|
||||
|
||||
export {default as Details, type DetailsProps} from './components/Details';
|
||||
export {Details, type DetailsProps} from './components/Details';
|
||||
|
||||
export {
|
||||
useDocsPreferredVersion,
|
||||
useDocsPreferredVersionByPluginId,
|
||||
} from './utils/docsPreferredVersion/useDocsPreferredVersion';
|
||||
DocsPreferredVersionContextProvider,
|
||||
} from './contexts/docsPreferredVersion';
|
||||
|
||||
export {duplicates, uniq} from './utils/jsUtils';
|
||||
|
||||
export {DocsPreferredVersionContextProvider} from './utils/docsPreferredVersion/DocsPreferredVersionProvider';
|
||||
|
||||
export {ThemeClassNames} from './utils/ThemeClassNames';
|
||||
|
||||
export {
|
||||
AnnouncementBarProvider,
|
||||
useAnnouncementBar,
|
||||
} from './utils/announcementBarUtils';
|
||||
} from './contexts/announcementBar';
|
||||
|
||||
export {useLocalPathname} from './utils/useLocalPathname';
|
||||
|
||||
|
@ -99,9 +96,9 @@ export {
|
|||
export {useHistoryPopHandler} from './utils/historyUtils';
|
||||
|
||||
export {
|
||||
default as useTOCHighlight,
|
||||
useTOCHighlight,
|
||||
type TOCHighlightConfig,
|
||||
} from './utils/useTOCHighlight';
|
||||
} from './hooks/useTOCHighlight';
|
||||
|
||||
export {
|
||||
useFilteredAndTreeifiedTOC,
|
||||
|
@ -121,6 +118,7 @@ export {
|
|||
export {
|
||||
useIsomorphicLayoutEffect,
|
||||
useDynamicCallback,
|
||||
usePrevious,
|
||||
ReactContextError,
|
||||
} from './utils/reactUtils';
|
||||
|
||||
|
@ -138,30 +136,28 @@ export {
|
|||
useColorMode,
|
||||
ColorModeProvider,
|
||||
type ColorMode,
|
||||
} from './utils/colorModeUtils';
|
||||
} from './contexts/colorMode';
|
||||
|
||||
export {splitNavbarItems, NavbarProvider} from './utils/navbarUtils';
|
||||
|
||||
export {
|
||||
useTabGroupChoice,
|
||||
TabGroupChoiceProvider,
|
||||
} from './utils/tabGroupChoiceUtils';
|
||||
} from './contexts/tabGroupChoice';
|
||||
|
||||
export {
|
||||
splitNavbarItems,
|
||||
NavbarProvider,
|
||||
useNavbarMobileSidebar,
|
||||
} from './utils/navbarUtils';
|
||||
export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';
|
||||
export {
|
||||
useNavbarSecondaryMenu,
|
||||
NavbarSecondaryMenuFiller,
|
||||
} from './utils/navbarSecondaryMenuUtils';
|
||||
export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenuUtils';
|
||||
} from './utils/navbarSecondaryMenu';
|
||||
export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenu';
|
||||
|
||||
export {default as useHideableNavbar} from './hooks/useHideableNavbar';
|
||||
export {useHideableNavbar} from './hooks/useHideableNavbar';
|
||||
export {
|
||||
default as useKeyboardNavigation,
|
||||
useKeyboardNavigation,
|
||||
keyboardFocusedClassName,
|
||||
} from './hooks/useKeyboardNavigation';
|
||||
export {default as usePrismTheme} from './hooks/usePrismTheme';
|
||||
export {default as useLockBodyScroll} from './hooks/useLockBodyScroll';
|
||||
export {default as useWindowSize} from './hooks/useWindowSize';
|
||||
export {default as useSearchPage} from './hooks/useSearchPage';
|
||||
export {usePrismTheme} from './hooks/usePrismTheme';
|
||||
export {useLockBodyScroll} from './hooks/useLockBodyScroll';
|
||||
export {useWindowSize} from './hooks/useWindowSize';
|
||||
export {useSearchPage} from './hooks/useSearchPage';
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* 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 {usePrevious} from '../reactUtils';
|
||||
import {renderHook} from '@testing-library/react-hooks';
|
||||
|
||||
describe('usePrevious', () => {
|
||||
it('returns the previous value of a variable', () => {
|
||||
const {result, rerender} = renderHook((val) => usePrevious(val), {
|
||||
initialProps: 1,
|
||||
});
|
||||
expect(result.current).toBeUndefined();
|
||||
rerender(2);
|
||||
expect(result.current).toBe(1);
|
||||
rerender(3);
|
||||
expect(result.current).toBe(2);
|
||||
});
|
||||
});
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* 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 {createStorageSlot} from '../storageUtils';
|
||||
import type {DocsVersionPersistence} from '../useThemeConfig';
|
||||
|
||||
const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`;
|
||||
|
||||
const DocsPreferredVersionStorage = {
|
||||
save: (
|
||||
pluginId: string,
|
||||
persistence: DocsVersionPersistence,
|
||||
versionName: string,
|
||||
): void => {
|
||||
createStorageSlot(storageKey(pluginId), {persistence}).set(versionName);
|
||||
},
|
||||
|
||||
read: (
|
||||
pluginId: string,
|
||||
persistence: DocsVersionPersistence,
|
||||
): string | null =>
|
||||
createStorageSlot(storageKey(pluginId), {persistence}).get(),
|
||||
|
||||
clear: (pluginId: string, persistence: DocsVersionPersistence): void => {
|
||||
createStorageSlot(storageKey(pluginId), {persistence}).del();
|
||||
},
|
||||
};
|
||||
|
||||
export default DocsPreferredVersionStorage;
|
|
@ -1,70 +0,0 @@
|
|||
/**
|
||||
* 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 {useCallback} from 'react';
|
||||
import {useDocsPreferredVersionContext} from './DocsPreferredVersionProvider';
|
||||
import {
|
||||
useAllDocsData,
|
||||
useDocsData,
|
||||
type GlobalVersion,
|
||||
} from '@docusaurus/plugin-content-docs/client';
|
||||
|
||||
import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants';
|
||||
|
||||
// Note, the preferredVersion attribute will always be null before mount
|
||||
export function useDocsPreferredVersion(
|
||||
pluginId: string | undefined = DEFAULT_PLUGIN_ID,
|
||||
): {
|
||||
preferredVersion: GlobalVersion | null | undefined;
|
||||
savePreferredVersionName: (versionName: string) => void;
|
||||
} {
|
||||
const docsData = useDocsData(pluginId);
|
||||
const [state, api] = useDocsPreferredVersionContext();
|
||||
|
||||
const {preferredVersionName} = state[pluginId]!;
|
||||
|
||||
const preferredVersion = preferredVersionName
|
||||
? docsData.versions.find((version) => version.name === preferredVersionName)
|
||||
: null;
|
||||
|
||||
const savePreferredVersionName = useCallback(
|
||||
(versionName: string) => {
|
||||
api.savePreferredVersion(pluginId, versionName);
|
||||
},
|
||||
[api, pluginId],
|
||||
);
|
||||
|
||||
return {preferredVersion, savePreferredVersionName} as const;
|
||||
}
|
||||
|
||||
export function useDocsPreferredVersionByPluginId(): Record<
|
||||
string,
|
||||
GlobalVersion | null | undefined
|
||||
> {
|
||||
const allDocsData = useAllDocsData();
|
||||
const [state] = useDocsPreferredVersionContext();
|
||||
|
||||
function getPluginIdPreferredVersion(pluginId: string) {
|
||||
const docsData = allDocsData[pluginId]!;
|
||||
const {preferredVersionName} = state[pluginId]!;
|
||||
|
||||
return preferredVersionName
|
||||
? docsData.versions.find(
|
||||
(version) => version.name === preferredVersionName,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
const pluginIds = Object.keys(allDocsData);
|
||||
|
||||
const result: Record<string, GlobalVersion | null | undefined> = {};
|
||||
pluginIds.forEach((pluginId) => {
|
||||
result[pluginId] = getPluginIdPreferredVersion(pluginId);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
|
@ -14,9 +14,8 @@ import React, {
|
|||
type ReactNode,
|
||||
type ComponentType,
|
||||
} from 'react';
|
||||
import {ReactContextError} from './reactUtils';
|
||||
import {usePrevious} from './usePrevious';
|
||||
import {useNavbarMobileSidebar} from './navbarUtils';
|
||||
import {ReactContextError, usePrevious} from './reactUtils';
|
||||
import {useNavbarMobileSidebar} from '../contexts/navbarMobileSidebar';
|
||||
|
||||
/*
|
||||
The idea behind all this is that a specific component must be able to fill a
|
|
@ -5,24 +5,15 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import useWindowSize from '../hooks/useWindowSize';
|
||||
import {useHistoryPopHandler} from './historyUtils';
|
||||
import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenuUtils';
|
||||
import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
|
||||
import {useThemeConfig} from './useThemeConfig';
|
||||
import {ReactContextError} from './reactUtils';
|
||||
import React, {type ReactNode} from 'react';
|
||||
import {NavbarMobileSidebarProvider} from '../contexts/navbarMobileSidebar';
|
||||
import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenu';
|
||||
|
||||
const DefaultNavItemPosition = 'right';
|
||||
|
||||
// If split links by left/right
|
||||
// if position is unspecified, fallback to right
|
||||
/**
|
||||
* Split links by left/right. If position is unspecified, fallback to right.
|
||||
*/
|
||||
export function splitNavbarItems<T extends {position?: 'left' | 'right'}>(
|
||||
items: T[],
|
||||
): [leftItems: T[], rightItems: T[]] {
|
||||
|
@ -36,90 +27,10 @@ export function splitNavbarItems<T extends {position?: 'left' | 'right'}>(
|
|||
return [leftItems, rightItems];
|
||||
}
|
||||
|
||||
type NavbarMobileSidebarContextValue = {
|
||||
disabled: boolean;
|
||||
shouldRender: boolean;
|
||||
toggle: () => void;
|
||||
shown: boolean;
|
||||
};
|
||||
|
||||
const NavbarMobileSidebarContext = React.createContext<
|
||||
NavbarMobileSidebarContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
// Mobile sidebar can be disabled in case it would lead to an empty sidebar
|
||||
// In this case it's not useful to display a navbar sidebar toggle button
|
||||
function useNavbarMobileSidebarDisabled() {
|
||||
const activeDocPlugin = useActivePlugin();
|
||||
const {items} = useThemeConfig().navbar;
|
||||
return items.length === 0 && !activeDocPlugin;
|
||||
}
|
||||
|
||||
function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue {
|
||||
const disabled = useNavbarMobileSidebarDisabled();
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
// Mobile sidebar not visible until user interaction: can avoid SSR rendering
|
||||
const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr';
|
||||
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
// Close mobile sidebar on navigation pop
|
||||
// Most likely firing when using the Android back button (but not only)
|
||||
useHistoryPopHandler(() => {
|
||||
if (shown) {
|
||||
setShown(false);
|
||||
// Should we prevent the navigation here?
|
||||
// See https://github.com/facebook/docusaurus/pull/5462#issuecomment-911699846
|
||||
return false; // prevent pop navigation
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setShown((s) => !s);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowSize === 'desktop') {
|
||||
setShown(false);
|
||||
}
|
||||
}, [windowSize]);
|
||||
|
||||
// Return stable context value
|
||||
return useMemo(
|
||||
() => ({
|
||||
disabled,
|
||||
shouldRender,
|
||||
toggle,
|
||||
shown,
|
||||
}),
|
||||
[disabled, shouldRender, toggle, shown],
|
||||
);
|
||||
}
|
||||
|
||||
function NavbarMobileSidebarProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const value = useNavbarMobileSidebarContextValue();
|
||||
return (
|
||||
<NavbarMobileSidebarContext.Provider value={value}>
|
||||
{children}
|
||||
</NavbarMobileSidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue {
|
||||
const context = React.useContext(NavbarMobileSidebarContext);
|
||||
if (context == null) {
|
||||
throw new ReactContextError('NavbarMobileSidebarProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Add all Navbar providers at once
|
||||
/**
|
||||
* Composes the `NavbarMobileSidebarProvider` and `NavbarSecondaryMenuProvider`.
|
||||
* Because the latter depends on the former, they can't be re-ordered.
|
||||
*/
|
||||
export function NavbarProvider({children}: {children: ReactNode}): JSX.Element {
|
||||
return (
|
||||
<NavbarMobileSidebarProvider>
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
// Compare the 2 paths, case insensitive and ignoring trailing slash
|
||||
/**
|
||||
* Compare the 2 paths, case insensitive and ignoring trailing slash
|
||||
*/
|
||||
export const isSamePath = (
|
||||
path1: string | undefined,
|
||||
path2: string | undefined,
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import {useCallback, useEffect, useLayoutEffect, useRef} from 'react';
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
|
||||
/**
|
||||
* This hook is like useLayoutEffect, but without the SSR warning
|
||||
|
@ -14,12 +15,13 @@ import {useCallback, useEffect, useLayoutEffect, useRef} from 'react';
|
|||
* It is useful when you need to update a ref as soon as possible after a React
|
||||
* render (before `useEffect`)
|
||||
*/
|
||||
export const useIsomorphicLayoutEffect =
|
||||
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||
export const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM
|
||||
? useLayoutEffect
|
||||
: useEffect;
|
||||
|
||||
/**
|
||||
* Permits to transform an unstable callback (like an arrow function provided as
|
||||
* props) to a "stable" callback that is safe to use in a useEffect dependency
|
||||
* props) to a "stable" callback that is safe to use in a `useEffect` dependency
|
||||
* array. Useful to avoid React stale closure problems + avoid useless effect
|
||||
* re-executions
|
||||
*
|
||||
|
@ -42,6 +44,16 @@ export function useDynamicCallback<T extends (...args: never[]) => unknown>(
|
|||
return useCallback<T>((...args) => ref.current(...args), []);
|
||||
}
|
||||
|
||||
export function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = useRef<T>();
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
export class ReactContextError extends Error {
|
||||
constructor(providerName: string, additionalInfo?: string) {
|
||||
super();
|
||||
|
|
|
@ -10,8 +10,11 @@ import {useMemo} from 'react';
|
|||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import type {Route} from '@docusaurus/types';
|
||||
|
||||
// Note that all sites don't always have a homepage in practice
|
||||
// See https://github.com/facebook/docusaurus/pull/6517#issuecomment-1048709116
|
||||
/**
|
||||
* Note that sites don't always have a homepage in practice, so we can't assume
|
||||
* that linking to '/' is always safe.
|
||||
* @see https://github.com/facebook/docusaurus/pull/6517#issuecomment-1048709116
|
||||
*/
|
||||
export function findHomePageRoute({
|
||||
baseUrl,
|
||||
routes: initialRoutes,
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
useAllDocsData,
|
||||
useActivePluginAndVersion,
|
||||
} from '@docusaurus/plugin-content-docs/client';
|
||||
import {useDocsPreferredVersionByPluginId} from './docsPreferredVersion/useDocsPreferredVersion';
|
||||
import {useDocsPreferredVersionByPluginId} from '../contexts/docsPreferredVersion';
|
||||
import {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './searchUtils';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
|
||||
|
|
|
@ -8,9 +8,11 @@
|
|||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import {useLocation} from '@docusaurus/router';
|
||||
|
||||
// Get the pathname of current route, without the optional site baseUrl
|
||||
// - /docs/myDoc => /docs/myDoc
|
||||
// - /baseUrl/docs/myDoc => /docs/myDoc
|
||||
/**
|
||||
* Get the pathname of current route, without the optional site baseUrl
|
||||
* - /docs/myDoc => /docs/myDoc
|
||||
* - /baseUrl/docs/myDoc => /docs/myDoc
|
||||
*/
|
||||
export function useLocalPathname(): string {
|
||||
const {
|
||||
siteConfig: {baseUrl},
|
||||
|
|
|
@ -8,8 +8,7 @@
|
|||
import {useEffect} from 'react';
|
||||
import {useLocation} from '@docusaurus/router';
|
||||
import type {Location} from 'history';
|
||||
import {usePrevious} from './usePrevious';
|
||||
import {useDynamicCallback} from './reactUtils';
|
||||
import {useDynamicCallback, usePrevious} from './reactUtils';
|
||||
|
||||
type LocationChangeEvent = {
|
||||
location: Location;
|
||||
|
|
|
@ -50,17 +50,17 @@ function createLocalePluralForms(locale: string): LocalePluralForms {
|
|||
}
|
||||
|
||||
/**
|
||||
* Poor man's PluralSelector implementation, using an english fallback. We want
|
||||
* a lightweight, future-proof and good-enough solution. We don't want a perfect
|
||||
* and heavy solution.
|
||||
* Poor man's `PluralSelector` implementation, using an English fallback. We
|
||||
* want a lightweight, future-proof and good-enough solution. We don't want a
|
||||
* perfect and heavy solution.
|
||||
*
|
||||
* Docusaurus classic theme has only 2 deeply nested labels requiring complex
|
||||
* plural rules. We don't want to use Intl + PluralRules polyfills + full ICU
|
||||
* syntax (react-intl) just for that.
|
||||
* plural rules. We don't want to use `Intl` + `PluralRules` polyfills + full
|
||||
* ICU syntax (react-intl) just for that.
|
||||
*
|
||||
* Notes:
|
||||
* - 2021: 92+% Browsers support Intl.PluralRules, and support will increase in
|
||||
* the future
|
||||
* - 2021: 92+% Browsers support `Intl.PluralRules`, and support will increase
|
||||
* in the future
|
||||
* - NodeJS >= 13 has full ICU support by default
|
||||
* - In case of "mismatch" between SSR and Browser ICU support, React keeps
|
||||
* working!
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
/**
|
||||
* 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 {useRef} from 'react';
|
||||
import {useIsomorphicLayoutEffect} from './reactUtils';
|
||||
|
||||
export function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = useRef<T>();
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
|
||||
return ref.current;
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* 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 {renderHook} from '@testing-library/react-hooks/server';
|
||||
import {RouteContextProvider} from '../routeContext';
|
||||
import useRouteContext from '../exports/useRouteContext';
|
||||
|
||||
describe('RouteContextProvider', () => {
|
||||
it('creates root route context registered by plugin', () => {
|
||||
expect(
|
||||
renderHook(() => useRouteContext(), {
|
||||
wrapper: ({children}) => (
|
||||
<RouteContextProvider value={{plugin: {id: 'test', name: 'test'}}}>
|
||||
{children}
|
||||
</RouteContextProvider>
|
||||
),
|
||||
}).result.current,
|
||||
).toEqual({plugin: {id: 'test', name: 'test'}});
|
||||
});
|
||||
it('throws if there is no route context at all', () => {
|
||||
expect(
|
||||
() =>
|
||||
renderHook(() => useRouteContext(), {
|
||||
wrapper: ({children}) => (
|
||||
<RouteContextProvider value={null}>{children}</RouteContextProvider>
|
||||
),
|
||||
}).result.current,
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unexpected: no Docusaurus route context found"`,
|
||||
);
|
||||
});
|
||||
it('throws if there is no parent context created by plugin', () => {
|
||||
expect(
|
||||
() =>
|
||||
renderHook(() => useRouteContext(), {
|
||||
wrapper: ({children}) => (
|
||||
<RouteContextProvider value={{data: {some: 'data'}}}>
|
||||
{children}
|
||||
</RouteContextProvider>
|
||||
),
|
||||
}).result.current,
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unexpected: Docusaurus topmost route context has no \`plugin\` attribute"`,
|
||||
);
|
||||
});
|
||||
it('merges route context created by parent', () => {
|
||||
expect(
|
||||
renderHook(() => useRouteContext(), {
|
||||
wrapper: ({children}) => (
|
||||
<RouteContextProvider
|
||||
value={{plugin: {id: 'test', name: 'test'}, data: {some: 'data'}}}>
|
||||
<RouteContextProvider value={{data: {someMore: 'data'}}}>
|
||||
{children}
|
||||
</RouteContextProvider>
|
||||
</RouteContextProvider>
|
||||
),
|
||||
}).result.current,
|
||||
).toEqual({
|
||||
data: {some: 'data', someMore: 'data'},
|
||||
plugin: {id: 'test', name: 'test'},
|
||||
});
|
||||
});
|
||||
it('never overrides the plugin attribute', () => {
|
||||
expect(
|
||||
renderHook(() => useRouteContext(), {
|
||||
wrapper: ({children}) => (
|
||||
<RouteContextProvider
|
||||
value={{plugin: {id: 'test', name: 'test'}, data: {some: 'data'}}}>
|
||||
<RouteContextProvider
|
||||
value={{
|
||||
plugin: {id: 'adversary', name: 'adversary'},
|
||||
data: {someMore: 'data'},
|
||||
}}>
|
||||
{children}
|
||||
</RouteContextProvider>
|
||||
</RouteContextProvider>
|
||||
),
|
||||
}).result.current,
|
||||
).toEqual({
|
||||
data: {some: 'data', someMore: 'data'},
|
||||
plugin: {id: 'test', name: 'test'},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* 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 Head from '../Head';
|
||||
import {type FilledContext, HelmetProvider} from 'react-helmet-async';
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
describe('Head', () => {
|
||||
it('does exactly what Helmet does', () => {
|
||||
const context = {};
|
||||
expect(
|
||||
renderer
|
||||
.create(
|
||||
<HelmetProvider context={context}>
|
||||
<Head>
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:description" content="some description" />
|
||||
</Head>
|
||||
<Head>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="some description overridden"
|
||||
/>
|
||||
</Head>
|
||||
<Head>
|
||||
<meta
|
||||
property="duplicated?"
|
||||
content="this property is duplicated"
|
||||
/>
|
||||
<meta property="duplicated?" content="another one" />
|
||||
</Head>
|
||||
<div>Content</div>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
.toJSON(),
|
||||
).toMatchSnapshot();
|
||||
expect((context as FilledContext).helmet).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Head does exactly what Helmet does 1`] = `
|
||||
<div>
|
||||
Content
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Head does exactly what Helmet does 2`] = `
|
||||
<html>
|
||||
<head>
|
||||
<meta
|
||||
content="article"
|
||||
data-rh={true}
|
||||
property="og:type"
|
||||
/>
|
||||
<meta
|
||||
content="some description overridden"
|
||||
data-rh={true}
|
||||
property="og:description"
|
||||
/>
|
||||
<meta
|
||||
content="this property is duplicated"
|
||||
data-rh={true}
|
||||
property="duplicated?"
|
||||
/>
|
||||
<meta
|
||||
content="another one"
|
||||
data-rh={true}
|
||||
property="duplicated?"
|
||||
/>
|
||||
<title
|
||||
data-rh={true}
|
||||
>
|
||||
|
||||
</title>
|
||||
</head>
|
||||
<body />
|
||||
</html>
|
||||
`;
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* 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 {renderHook} from '@testing-library/react-hooks/server';
|
||||
import {RouteContextProvider} from '../../routeContext';
|
||||
import useRouteContext from '../useRouteContext';
|
||||
|
||||
describe('useRouteContext', () => {
|
||||
it('throws when there is no route context at all', () => {
|
||||
expect(
|
||||
() => renderHook(() => useRouteContext()).result.current,
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unexpected: no Docusaurus route context found"`,
|
||||
);
|
||||
});
|
||||
it('returns merged route contexts', () => {
|
||||
expect(
|
||||
renderHook(() => useRouteContext(), {
|
||||
wrapper: ({children}) => (
|
||||
<RouteContextProvider
|
||||
value={{plugin: {id: 'test', name: 'test'}, data: {some: 'data'}}}>
|
||||
<RouteContextProvider value={{data: {someMore: 'data'}}}>
|
||||
{children}
|
||||
</RouteContextProvider>
|
||||
</RouteContextProvider>
|
||||
),
|
||||
}).result.current,
|
||||
).toEqual({
|
||||
data: {some: 'data', someMore: 'data'},
|
||||
plugin: {id: 'test', name: 'test'},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -12,9 +12,7 @@ import {Context} from '../routeContext';
|
|||
export default function useRouteContext(): PluginRouteContext {
|
||||
const context = React.useContext(Context);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'Unexpected: no Docusaurus parent/current route context found',
|
||||
);
|
||||
throw new Error('Unexpected: no Docusaurus route context found');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
|
@ -19,12 +19,10 @@ function mergeContexts({
|
|||
}): PluginRouteContext {
|
||||
if (!parent) {
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
'Unexpected: no Docusaurus parent/current route context found',
|
||||
);
|
||||
throw new Error('Unexpected: no Docusaurus route context found');
|
||||
} else if (!('plugin' in value)) {
|
||||
throw new Error(
|
||||
'Unexpected: Docusaurus parent route context has no plugin attribute',
|
||||
'Unexpected: Docusaurus topmost route context has no `plugin` attribute',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
|
@ -45,7 +43,8 @@ export function RouteContextProvider({
|
|||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: PluginRouteContext | null;
|
||||
// Only topmost route has the `plugin` attribute
|
||||
value: PluginRouteContext | RouteContext | null;
|
||||
}): JSX.Element {
|
||||
const parent = React.useContext(Context);
|
||||
|
||||
|
|
17
website/src/utils/__tests__/jsUtils.test.ts
Normal file
17
website/src/utils/__tests__/jsUtils.test.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* 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 {toggleListItem} from '../jsUtils';
|
||||
|
||||
describe('toggleListItem', () => {
|
||||
it('removes item already in list', () => {
|
||||
expect(toggleListItem([1, 2, 3], 2)).toEqual([1, 3]);
|
||||
});
|
||||
it('appends item not in list', () => {
|
||||
expect(toggleListItem([1, 2], 3)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
|
@ -11669,6 +11669,11 @@ jest-runtime@^27.5.1:
|
|||
slash "^3.0.0"
|
||||
strip-bom "^4.0.0"
|
||||
|
||||
jest-serializer-react-helmet-async@^1.0.21:
|
||||
version "1.0.21"
|
||||
resolved "https://registry.yarnpkg.com/jest-serializer-react-helmet-async/-/jest-serializer-react-helmet-async-1.0.21.tgz#bf2aee7522909bc4c933a0911db236b92db4685c"
|
||||
integrity sha512-oJARA6ACc3QNR4s/EUjecLQclGf2+vMO0azoiEBwjJrsDHGHkMHcM935+r7aGkmteY1awdoyQ78ZGDiC7dwtsw==
|
||||
|
||||
jest-serializer@^27.5.1:
|
||||
version "27.5.1"
|
||||
resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-27.5.1.tgz#81438410a30ea66fd57ff730835123dea1fb1f64"
|
||||
|
|
Loading…
Add table
Reference in a new issue