Skip to content

Commit

Permalink
Customisable Navigation (#6111)
Browse files Browse the repository at this point in the history
* add Navigation component to customisable components in adminConfig

* add composable navigation components and types to admin-ui/component exports

* update to Navigation component

* add types for Navigation component

* add example for custom-navigation-compoent

* add custom route

* add example

* add guide links

* wip nav guide

* add minor a11y fixes

* update docs and examples

* custom admin-ui docs

* update yarn.lock

* smoke tests for nav example

* update test and example

* dependency fixes

* fix smoke tests

* fix failing tests

* nope nevermind, not fixed

* nav architecture refactor

* docs wip

* remove d ocs to be added in subsequent PR

* type shuffling, so we do not have icky circular type references in @keystone-next/types

* changesets

* Update .changeset/fluffy-schools-allow.md

Co-authored-by: Tim Leslie <timl@thinkmill.com.au>

* Update .changeset/rare-carrots-deliver.md

Co-authored-by: Tim Leslie <timl@thinkmill.com.au>

* yarn.lock reversion

* remove unnecessary comments

Co-authored-by: Tim Leslie <timl@thinkmill.com.au>
  • Loading branch information
gwyneplaine and timleslie committed Jul 26, 2021
1 parent df419a7 commit 9e2deac
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 82 deletions.
5 changes: 5 additions & 0 deletions .changeset/fluffy-schools-allow.md
@@ -0,0 +1,5 @@
---
'@keystone-next/keystone': minor
---

Added the ability to customise the Navigation component in the Admin UI, and provided helper components to do so.
5 changes: 5 additions & 0 deletions .changeset/rare-carrots-deliver.md
@@ -0,0 +1,5 @@
---
'@keystone-next/types': minor
---

Add Admin UI specific types AuthenticatedItem, VisibleLists, CreateViewFieldModes and NavigationProps to exports.
187 changes: 122 additions & 65 deletions packages/keystone/src/admin-ui/components/Navigation.tsx
@@ -1,8 +1,9 @@
/* @jsx jsx */

import { AllHTMLAttributes, ReactNode } from 'react';
import { AllHTMLAttributes, ReactNode, Fragment } from 'react';
import { useRouter } from 'next/router';
import { Stack, jsx, useTheme } from '@keystone-ui/core';
import { NavigationProps, ListMeta } from '@keystone-next/types';
import { Stack, jsx, useTheme, Text } from '@keystone-ui/core';
import { Button } from '@keystone-ui/button';
import { Popover } from '@keystone-ui/popover';
import { MoreHorizontalIcon } from '@keystone-ui/icons/icons/MoreHorizontalIcon';
Expand All @@ -15,44 +16,47 @@ import { SignoutButton } from './SignoutButton';
type NavItemProps = {
href: string;
children: ReactNode;
isSelected?: boolean;
};

const NavItem = ({ href, children }: NavItemProps) => {
export const NavItem = ({ href, children, isSelected: _isSelected }: NavItemProps) => {
const { colors, palette, spacing, radii, typography } = useTheme();
const router = useRouter();
const isSelected =
router.pathname === href || router.pathname.split('/')[1] === href.split('/')[1];

const isSelected = _isSelected !== undefined ? _isSelected : router.pathname === href;

return (
<Link
aria-current={isSelected ? 'location' : false}
href={href}
css={{
background: 'transparent',
borderBottomRightRadius: radii.xsmall,
borderTopRightRadius: radii.xsmall,
color: palette.neutral700,
display: 'block',
fontWeight: typography.fontWeight.medium,
marginBottom: spacing.xsmall,
marginRight: spacing.xlarge,
padding: `${spacing.small}px ${spacing.xlarge}px`,
position: 'relative',
textDecoration: 'none',
<li>
<Link
aria-current={isSelected ? 'location' : false}
href={href}
css={{
background: 'transparent',
borderBottomRightRadius: radii.xsmall,
borderTopRightRadius: radii.xsmall,
color: palette.neutral700,
display: 'block',
fontWeight: typography.fontWeight.medium,
marginBottom: spacing.xsmall,
marginRight: spacing.xlarge,
padding: `${spacing.small}px ${spacing.xlarge}px`,
position: 'relative',
textDecoration: 'none',

':hover': {
background: colors.backgroundHover,
color: colors.linkHoverColor,
},
':hover': {
background: colors.backgroundHover,
color: colors.linkHoverColor,
},

'&[aria-current=location]': {
background: palette.neutral200,
color: palette.neutral900,
},
}}
>
{children}
</Link>
'&[aria-current=location]': {
background: palette.neutral200,
color: palette.neutral900,
},
}}
>
{children}
</Link>
</li>
);
};

Expand Down Expand Up @@ -122,14 +126,12 @@ const PopoverLink = ({ children, ...props }: AllHTMLAttributes<HTMLAnchorElement
);
};

export const Navigation = () => {
const {
adminMeta: { lists },
authenticatedItem,
visibleLists,
} = useKeystone();
const { spacing } = useTheme();
export type NavigationContainerProps = Pick<NavigationProps, 'authenticatedItem'> & {
children: ReactNode;
};

export const NavigationContainer = ({ authenticatedItem, children }: NavigationContainerProps) => {
const { spacing } = useTheme();
return (
<div
css={{
Expand All @@ -141,33 +143,88 @@ export const Navigation = () => {
{authenticatedItem.state === 'authenticated' && (
<AuthenticatedItem item={authenticatedItem} />
)}
<nav css={{ marginTop: spacing.xlarge }}>
<NavItem href="/">Dashboard</NavItem>
{(() => {
if (visibleLists.state === 'loading') return null;
if (visibleLists.state === 'error') {
return (
<span css={{ color: 'red' }}>
{visibleLists.error instanceof Error
? visibleLists.error.message
: visibleLists.error[0].message}
</span>
);
}
return Object.keys(lists).map(key => {
if (!visibleLists.lists.has(key)) {
return null;
}

const list = lists[key];
return (
<NavItem key={key} href={`/${list.path}`}>
{lists[key].label}
</NavItem>
);
});
})()}
<nav role="navigation" aria-label="Side Navigation" css={{ marginTop: spacing.xlarge }}>
<ul
css={{
padding: 0,
margin: 0,
li: {
listStyle: 'none',
},
}}
>
{children}
</ul>
</nav>
</div>
);
};

export const ListNavItem = ({ list }: { list: ListMeta }) => {
const router = useRouter();
return (
<NavItem
isSelected={router.pathname.split('/')[1] === list.path.split('/')[1]}
href={list.path}
>
{list.label}
</NavItem>
);
};

type NavItemsProps = Pick<NavigationProps, 'lists'> & { include?: string[] };

export const ListNavItems = ({ lists = [], include = [] }: NavItemsProps) => {
const renderedList = include.length > 0 ? lists.filter(i => include.includes(i.key)) : lists;

return (
<Fragment>
{renderedList.map((list: ListMeta) => {
return <ListNavItem key={list.key} list={list} />;
})}
</Fragment>
);
};

export const Navigation = () => {
const {
adminMeta: { lists },
adminConfig,
authenticatedItem,
visibleLists,
} = useKeystone();

if (visibleLists.state === 'loading') return null;
// This visible lists error is critical and likely to result in a server restart
// if it happens, we'll show the error and not render the navigation component/s
if (visibleLists.state === 'error') {
return (
<Text as="span" paddingLeft="xlarge" css={{ color: 'red' }}>
{visibleLists.error instanceof Error
? visibleLists.error.message
: visibleLists.error[0].message}
</Text>
);
}
const renderableLists = Object.keys(lists)
.map(key => {
if (!visibleLists.lists.has(key)) return null;
return lists[key];
})
.filter((x): x is NonNullable<typeof x> => Boolean(x));

if (adminConfig?.components?.Navigation) {
return (
<adminConfig.components.Navigation
authenticatedItem={authenticatedItem}
lists={renderableLists}
/>
);
}

return (
<NavigationContainer authenticatedItem={authenticatedItem}>
<ListNavItems lists={renderableLists} />
</NavigationContainer>
);
};
12 changes: 11 additions & 1 deletion packages/keystone/src/admin-ui/components/index.ts
@@ -1,8 +1,18 @@
// FIELD VIEW SPECIFIC COMPONENTS
export { CellContainer } from './CellContainer';
export { CellLink } from './CellLink';

export { ErrorBoundary, ErrorContainer } from './Errors';

// ADMIN-UI CUSTOM COMPONENTS
export { Logo } from './Logo';
export { Navigation } from './Navigation';
export { Navigation, NavigationContainer, NavItem, ListNavItems, ListNavItem } from './Navigation';

// co-locating the type with the admin-ui/component for a more a salient mental model.
// importing this type from @keystone-next/keystone/admin-ui/components is probably intuitive for a user
export type { NavigationProps } from '@keystone-next/types';

// CUSTOM PAGE BUILDING UTILITIES
export { PageContainer } from './PageContainer';
export { CreateItemDrawer } from './CreateItemDrawer';
export { GraphQLErrorNotice } from './GraphQLErrorNotice';
17 changes: 2 additions & 15 deletions packages/keystone/src/admin-ui/utils/useLazyMetadata.tsx
@@ -1,23 +1,10 @@
import { GraphQLError } from 'graphql';
import type { AuthenticatedItem, VisibleLists, CreateViewFieldModes } from '@keystone-next/types';
import { useMemo } from 'react';
import { DeepNullable, makeDataGetter } from '@keystone-next/admin-ui-utils';
import { DocumentNode, useQuery, QueryResult, ServerError, ServerParseError } from '../apollo';

export type AuthenticatedItem =
| { state: 'unauthenticated' }
| { state: 'authenticated'; label: string; id: string; listKey: string }
| { state: 'loading' }
| { state: 'error'; error: Error | readonly [GraphQLError, ...GraphQLError[]] };

export type VisibleLists =
| { state: 'loaded'; lists: ReadonlySet<string> }
| { state: 'loading' }
| { state: 'error'; error: Error | readonly [GraphQLError, ...GraphQLError[]] };

export type CreateViewFieldModes =
| { state: 'loaded'; lists: Record<string, Record<string, 'edit' | 'hidden'>> }
| { state: 'loading' }
| { state: 'error'; error: Error | readonly [GraphQLError, ...GraphQLError[]] };
export type { AuthenticatedItem, VisibleLists, CreateViewFieldModes } from '@keystone-next/types';

export function useLazyMetadata(query: DocumentNode): {
authenticatedItem: AuthenticatedItem;
Expand Down
29 changes: 28 additions & 1 deletion packages/types/src/admin-meta.ts
@@ -1,7 +1,34 @@
import { GraphQLError } from 'graphql';
import type { ReactElement } from 'react';
import { GqlNames, JSONValue } from './utils';

export type AdminConfig = { components?: { Logo?: (props: {}) => ReactElement } };
export type NavigationProps = {
authenticatedItem: AuthenticatedItem;
lists: ListMeta[];
};

export type AuthenticatedItem =
| { state: 'unauthenticated' }
| { state: 'authenticated'; label: string; id: string; listKey: string }
| { state: 'loading' }
| { state: 'error'; error: Error | readonly [GraphQLError, ...GraphQLError[]] };

export type VisibleLists =
| { state: 'loaded'; lists: ReadonlySet<string> }
| { state: 'loading' }
| { state: 'error'; error: Error | readonly [GraphQLError, ...GraphQLError[]] };

export type CreateViewFieldModes =
| { state: 'loaded'; lists: Record<string, Record<string, 'edit' | 'hidden'>> }
| { state: 'loading' }
| { state: 'error'; error: Error | readonly [GraphQLError, ...GraphQLError[]] };

export type AdminConfig = {
components?: {
Logo?: (props: {}) => ReactElement;
Navigation?: (props: NavigationProps) => ReactElement;
};
};

export type FieldControllerConfig<FieldMeta extends JSONValue | undefined = undefined> = {
listKey: string;
Expand Down

1 comment on commit 9e2deac

@vercel
Copy link

@vercel vercel bot commented on 9e2deac Jul 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.