Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow custom navbarItem types to pass through validation #7231

Merged
merged 8 commits into from
Apr 29, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,44 @@ describe('themeConfig', () => {
});
});

it('accept "custom-" prefixed custom navbar item type', () => {
const config = {
navbar: {
items: [
{
type: 'custom-x',
position: 'left',
xyz: 42,
},
{
label: 'Dropdown with custom item',
position: 'right',
items: [
{
label: 'Facebook',
href: 'https://.facebook.com/',
target: '_self',
},
{
type: 'custom-y',
any: new Date(),
prop: 42,
isAccepted: true,
},
],
},
],
},
};
expect(testValidateThemeConfig(config)).toEqual({
...DEFAULT_CONFIG,
navbar: {
...DEFAULT_CONFIG.navbar,
...config.navbar,
},
});
});

it('rejects unknown navbar item type', () => {
const config = {
navbar: {
Expand Down
31 changes: 31 additions & 0 deletions packages/docusaurus-theme-classic/src/theme-classic.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,37 @@ declare module '@theme/NavbarItem/HtmlNavbarItem' {
export default function HtmlNavbarItem(props: Props): JSX.Element;
}

declare module '@theme/NavbarItem/ComponentTypes' {
import type {ComponentType} from 'react';

import type DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem';
import type DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
import type LocaleDropdownNavbarItem from '@theme/NavbarItem/LocaleDropdownNavbarItem';
import type SearchNavbarItem from '@theme/NavbarItem/SearchNavbarItem';
import type HtmlNavbarItem from '@theme/NavbarItem/HtmlNavbarItem';
import type DocNavbarItem from '@theme/NavbarItem/DocNavbarItem';
import type DocSidebarNavbarItem from '@theme/NavbarItem/DocSidebarNavbarItem';
import type DocsVersionNavbarItem from '@theme/NavbarItem/DocsVersionNavbarItem';
import type DocsVersionDropdownNavbarItem from '@theme/NavbarItem/DocsVersionDropdownNavbarItem';

export type ComponentTypesObject = {
readonly default: typeof DefaultNavbarItem;
readonly localeDropdown: typeof LocaleDropdownNavbarItem;
readonly search: typeof SearchNavbarItem;
readonly dropdown: typeof DropdownNavbarItem;
readonly html: typeof HtmlNavbarItem;
readonly doc: typeof DocNavbarItem;
readonly docSidebar: typeof DocSidebarNavbarItem;
readonly docsVersion: typeof DocsVersionNavbarItem;
readonly docsVersionDropdown: typeof DocsVersionDropdownNavbarItem;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[customComponentType: string]: ComponentType<any>;
};

const ComponentTypes: ComponentTypesObject;
export default ComponentTypes;
}

declare module '@theme/NavbarItem' {
import type {ComponentProps} from 'react';
import type {Props as DefaultNavbarItemProps} from '@theme/NavbarItem/DefaultNavbarItem';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* 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 DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem';
import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
import LocaleDropdownNavbarItem from '@theme/NavbarItem/LocaleDropdownNavbarItem';
import SearchNavbarItem from '@theme/NavbarItem/SearchNavbarItem';
import HtmlNavbarItem from '@theme/NavbarItem/HtmlNavbarItem';
import DocNavbarItem from '@theme/NavbarItem/DocNavbarItem';
import DocSidebarNavbarItem from '@theme/NavbarItem/DocSidebarNavbarItem';
import DocsVersionNavbarItem from '@theme/NavbarItem/DocsVersionNavbarItem';
import DocsVersionDropdownNavbarItem from '@theme/NavbarItem/DocsVersionDropdownNavbarItem';

import type {ComponentTypesObject} from '@theme/NavbarItem/ComponentTypes';

const ComponentTypes: ComponentTypesObject = {
default: DefaultNavbarItem,
localeDropdown: LocaleDropdownNavbarItem,
search: SearchNavbarItem,
dropdown: DropdownNavbarItem,
html: HtmlNavbarItem,
doc: DocNavbarItem,
docSidebar: DocSidebarNavbarItem,
docsVersion: DocsVersionNavbarItem,
docsVersionDropdown: DocsVersionDropdownNavbarItem,
};

export default ComponentTypes;
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,26 @@
*/

import React from 'react';
import DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem';
import DropdownNavbarItem, {
type Props as DropdownNavbarItemProps,
} from '@theme/NavbarItem/DropdownNavbarItem';
import LocaleDropdownNavbarItem from '@theme/NavbarItem/LocaleDropdownNavbarItem';
import SearchNavbarItem from '@theme/NavbarItem/SearchNavbarItem';
import HtmlNavbarItem from '@theme/NavbarItem/HtmlNavbarItem';
import {type Props as DropdownNavbarItemProps} from '@theme/NavbarItem/DropdownNavbarItem';
import type {Types, Props} from '@theme/NavbarItem';

const NavbarItemComponents: {
// Not really worth typing, as we pass all props down immediately
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[type in Exclude<Types, undefined>]: () => (props: any) => JSX.Element;
} = {
default: () => DefaultNavbarItem,
localeDropdown: () => LocaleDropdownNavbarItem,
search: () => SearchNavbarItem,
dropdown: () => DropdownNavbarItem,
html: () => HtmlNavbarItem,
import ComponentTypes from '@theme/NavbarItem/ComponentTypes';

// Need to lazy load these items as we don't know for sure the docs plugin is
// loaded. See https://github.com/facebook/docusaurus/issues/3360
/* eslint-disable @typescript-eslint/no-var-requires, global-require */
docsVersion: () => require('@theme/NavbarItem/DocsVersionNavbarItem').default,
docsVersionDropdown: () =>
require('@theme/NavbarItem/DocsVersionDropdownNavbarItem').default,
doc: () => require('@theme/NavbarItem/DocNavbarItem').default,
docSidebar: () => require('@theme/NavbarItem/DocSidebarNavbarItem').default,
/* eslint-enable @typescript-eslint/no-var-requires, global-require */
} as const;

type NavbarItemComponentType = keyof typeof NavbarItemComponents;

const getNavbarItemComponent = (type: NavbarItemComponentType) => {
const navbarItemComponentFn = NavbarItemComponents[type];
if (!navbarItemComponentFn) {
const getNavbarItemComponent = (type: NonNullable<Types>) => {
const component = ComponentTypes[type];
if (!component) {
throw new Error(`No NavbarItem component found for type "${type}".`);
}
return navbarItemComponentFn();
return component;
};

function getComponentType(
type: Types,
isDropdown: boolean,
): NavbarItemComponentType {
function getComponentType(type: Types, isDropdown: boolean) {
// Backward compatibility: navbar item with no type set
// but containing dropdown items should use the type "dropdown"
if (!type || type === 'default') {
return isDropdown ? 'dropdown' : 'default';
}
return type as NavbarItemComponentType;
return type as NonNullable<Types>;
}

export default function NavbarItem({type, ...props}: Props): JSX.Element {
Expand All @@ -65,5 +34,6 @@ export default function NavbarItem({type, ...props}: Props): JSX.Element {
(props as DropdownNavbarItemProps).items !== undefined,
);
const NavbarItemComponent = getNavbarItemComponent(componentType);
// @ts-expect-error: how to type this?
return <NavbarItemComponent {...props} />;
slorber marked this conversation as resolved.
Show resolved Hide resolved
}
27 changes: 23 additions & 4 deletions packages/docusaurus-theme-classic/src/validateThemeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,22 @@ const HtmlNavbarItemSchema = Joi.object({
value: Joi.string().required(),
});

const itemWithType = (type: string | undefined) => {
// A temporary workaround to allow users to add custom navbar items
// See https://github.com/facebook/docusaurus/issues/7227
const CustomNavbarItemRegexp = /custom-.*/;
const CustomNavbarItemSchema = Joi.object({
type: Joi.string().regex(CustomNavbarItemRegexp).required(),
}).unknown();

const itemWithType = (type: string | RegExp | undefined) => {
// Because equal(undefined) is not supported :/
const typeSchema = type
? Joi.string().required().equal(type)
: Joi.string().forbidden();
const typeSchema =
// eslint-disable-next-line no-nested-ternary
type instanceof RegExp
? Joi.string().required().regex(type)
: type
? Joi.string().required().equal(type)
: Joi.string().forbidden();
return Joi.object({
type: typeSchema,
})
Expand Down Expand Up @@ -135,6 +146,10 @@ const DropdownSubitemSchema = Joi.object({
is: itemWithType('html'),
then: HtmlNavbarItemSchema,
},
{
is: itemWithType(CustomNavbarItemRegexp),
then: CustomNavbarItemSchema,
},
{
is: Joi.alternatives().try(
itemWithType('dropdown'),
Expand Down Expand Up @@ -210,6 +225,10 @@ const NavbarItemSchema = Joi.object({
is: itemWithType('html'),
then: HtmlNavbarItemSchema,
},
{
is: itemWithType(CustomNavbarItemRegexp),
then: CustomNavbarItemSchema,
},
{
is: itemWithType(undefined),
then: Joi.object().when('.', {
Expand Down
6 changes: 6 additions & 0 deletions website/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -409,12 +409,18 @@ const config = {
position: 'left',
activeBaseRegex: `/community/`,
},
// This item links to a draft doc: only displayed in dev
{
type: 'doc',
docId: 'test-draft',
label: 'Tests',
docsPluginId: 'docs-tests',
},
// Custom item for dogfooding: only displayed in /tests/ routes
{
type: 'custom-dogfood-navbar-item',
content: '😉',
},
// Right
{
type: 'docsVersionDropdown',
Expand Down
33 changes: 33 additions & 0 deletions website/src/components/NavbarItems/CustomDogfoodNavbarItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* 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 {useLocation} from '@docusaurus/router';

// used to dogfood custom navbar elements are possible
// see https://github.com/facebook/docusaurus/issues/7227
export default function CustomDogfoodNavbarItem(props: {
content: string;
mobile?: boolean;
}): JSX.Element | null {
const {pathname} = useLocation();
const shouldRender = pathname.includes('/tests/');
if (!shouldRender) {
return null;
}
return (
<button
onClick={() => {
// eslint-disable-next-line no-alert
alert("I'm a custom navbar item type example");
}}
type="button">
{props.content}
{props.mobile ? ' (mobile)' : ''}
</button>
);
}
14 changes: 14 additions & 0 deletions website/src/theme/NavbarItem/ComponentTypes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* 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 ComponentTypes from '@theme-original/NavbarItem/ComponentTypes';
import CustomDogfoodNavbarItem from '@site/src/components/NavbarItems/CustomDogfoodNavbarItem';

export default {
...ComponentTypes,
'custom-dogfood-navbar-item': CustomDogfoodNavbarItem,
};