Merge pull request #1102 from payloadcms/feat/group-collections

This commit is contained in:
Dan Ribbens
2022-09-12 16:52:24 -04:00
committed by GitHub
15 changed files with 488 additions and 116 deletions

View File

@@ -61,6 +61,7 @@ You can customize the way that the Admin panel behaves on a collection-by-collec
| Option | Description |
| ---------------------------- | -------------|
| `group` | Text used as a label for grouping collection links together in the navigation. |
| `useAsTitle` | Specify a top-level field to use for a document title throughout the Admin panel. If no field is defined, the ID of the document is used as the title. |
| `description` | Text or React component to display below the Collection label in the List view to give editors more information. |
| `defaultColumns` | Array of field names that correspond to which columns to show by default in this collection's List view. |
@@ -78,7 +79,7 @@ If the function is specified, a Preview button will automatically appear in the
**The preview function accepts two arguments:**
1. The document being edited
1. An `options` object, containing `locale` and `token` properties. The `token` is the currently logged in user's JWT.
1. An `options` object, containing `locale` and `token` properties. The `token` is the currently logged-in user's JWT.
**Example collection with preview function:**

View File

@@ -81,32 +81,16 @@
}
}
a {
padding: base(.125) 0;
display: flex;
text-decoration: none;
&:focus {
box-shadow: none;
font-weight: 600;
}
&:active {
font-weight: normal;
}
}
nav {
margin: base(.25) 0 $baseline;
a {
position: relative;
padding: base(.125) base(1.5) base(.125) 0;
display: flex;
text-decoration: none;
svg {
opacity: 0;
position: absolute;
left: - base(.5);
transform: rotate(-90deg);
&:focus {
box-shadow: none;
font-weight: 600;
}
&:hover {
@@ -114,12 +98,24 @@
}
&.active {
font-weight: normal;
padding-left: base(.6);
font-weight: 600;
}
}
}
svg {
opacity: 1;
}
&__link {
svg {
opacity: 0;
position: absolute;
left: - base(.5);
transform: rotate(-90deg);
}
&.active {
svg {
opacity: 1;
}
}
}
@@ -184,4 +180,4 @@
font-weight: 600;
}
}
}
}

View File

@@ -10,6 +10,8 @@ import CloseMenu from '../../icons/CloseMenu';
import Icon from '../../graphics/Icon';
import Account from '../../graphics/Account';
import Localizer from '../Localizer';
import NavGroup from '../NavGroup';
import { groupNavItems, Group, EntityToGroup, EntityType } from '../../../utilities/groupNavItems';
import './index.scss';
@@ -18,6 +20,7 @@ const baseClass = 'nav';
const DefaultNav = () => {
const { permissions } = useAuth();
const [menuActive, setMenuActive] = useState(false);
const [groups, setGroups] = useState<Group[]>([]);
const history = useHistory();
const {
collections,
@@ -36,7 +39,29 @@ const DefaultNav = () => {
const classes = [
baseClass,
menuActive && `${baseClass}--menu-active`,
].filter(Boolean).join(' ');
].filter(Boolean)
.join(' ');
useEffect(() => {
setGroups(groupNavItems([
...collections.map((collection) => {
const entityToGroup: EntityToGroup = {
type: EntityType.collection,
entity: collection,
};
return entityToGroup;
}),
...globals.map((global) => {
const entityToGroup: EntityToGroup = {
type: EntityType.global,
entity: global,
};
return entityToGroup;
}),
], permissions));
}, [collections, globals, permissions]);
useEffect(() => history.listen(() => {
setMenuActive(false);
@@ -65,56 +90,44 @@ const DefaultNav = () => {
)}
</button>
</header>
<div className={`${baseClass}__wrap`}>
<nav className={`${baseClass}__wrap`}>
{Array.isArray(beforeNavLinks) && beforeNavLinks.map((Component, i) => <Component key={i} />)}
<span className={`${baseClass}__label`}>Collections</span>
<nav>
{collections && collections.map((collection, i) => {
const href = `${admin}/collections/${collection.slug}`;
{groups.map(({ label, entities }, key) => {
return (
<NavGroup {...{ key, label }}>
{entities.map(({ entity, type }, i) => {
let entityLabel: string;
let href: string;
let id: string;
if (permissions?.collections?.[collection.slug]?.read.permission) {
return (
<NavLink
id={`nav-${collection.slug}`}
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{collection.labels.plural}
</NavLink>
);
}
return null;
})}
</nav>
{(globals && globals.length > 0) && (
<React.Fragment>
<span className={`${baseClass}__label`}>Globals</span>
<nav>
{globals.map((global, i) => {
const href = `${admin}/globals/${global.slug}`;
if (permissions?.globals?.[global.slug].read.permission) {
return (
<NavLink
id={`nav-global-${global.slug}`}
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{global.label}
</NavLink>
);
if (type === EntityType.collection) {
href = `${admin}/collections/${entity.slug}`;
entityLabel = entity.labels.plural;
id = `nav-${entity.slug}`;
}
return null;
if (type === EntityType.global) {
href = `${admin}/globals/${entity.slug}`;
entityLabel = entity.label;
id = `nav-global-${entity.slug}`;
}
return (
<NavLink
id={id}
className={`${baseClass}__link`}
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{entityLabel}
</NavLink>
);
})}
</nav>
</React.Fragment>
)}
</NavGroup>
);
})}
{Array.isArray(afterNavLinks) && afterNavLinks.map((Component, i) => <Component key={i} />)}
<div className={`${baseClass}__controls`}>
<Localizer />
@@ -131,7 +144,7 @@ const DefaultNav = () => {
<LogOut />
</Link>
</div>
</div>
</nav>
</div>
</aside>
);

View File

@@ -0,0 +1,53 @@
@import '../../../scss/styles.scss';
.nav-group {
width: 100%;
margin-bottom: base(.5);
&__toggle {
cursor: pointer;
color: var(--theme-elevation-400);
background: transparent;
padding-left: 0;
border: 0;
margin-top: base(.25);
width: 100%;
text-align: left;
display: flex;
align-items: flex-start;
padding-right: base(.5);
svg {
flex-shrink: 0;
margin-top: base(-.2);
}
&:hover {
color: var(--theme-elevation-1000);
.stroke {
stroke: var(--theme-elevation-1000);
}
}
}
&__indicator {
margin-left: auto;
.stroke {
stroke: var(--theme-elevation-200);
}
}
&--collapsed {
.collapsible__toggle {
border-bottom-right-radius: $style-radius-s;
border-bottom-left-radius: $style-radius-s;
}
.nav-group__indicator {
transform: rotate(.5turn);
}
}
}

View File

@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import AnimateHeight from 'react-animate-height';
import Chevron from '../../icons/Chevron';
import { usePreferences } from '../../utilities/Preferences';
import './index.scss';
const baseClass = 'nav-group';
type Props = {
children: React.ReactNode,
label: string,
}
const NavGroup: React.FC<Props> = ({
children,
label,
}) => {
const [collapsed, setCollapsed] = useState(true);
const [animate, setAnimate] = useState(false);
const { getPreference, setPreference } = usePreferences();
const preferencesKey = `collapsed-${label}-groups`;
useEffect(() => {
if (label) {
const setCollapsedFromPreferences = async () => {
const preferences = await getPreference(preferencesKey) || [];
setCollapsed(preferences.indexOf(label) !== -1);
};
setCollapsedFromPreferences();
}
}, [getPreference, label, preferencesKey]);
if (label) {
const toggleCollapsed = async () => {
setAnimate(true);
let preferences: string[] = await getPreference(preferencesKey) || [];
if (collapsed) {
preferences = preferences.filter((preference) => label !== preference);
} else {
preferences.push(label);
}
setPreference(preferencesKey, preferences);
setCollapsed(!collapsed);
};
return (
<div
id={`nav-group-${label}`}
className={[
`${baseClass}`,
`${label}`,
collapsed && `${baseClass}--collapsed`,
].filter(Boolean)
.join(' ')}
>
<button
type="button"
className={[
`${baseClass}__toggle`,
`${baseClass}__toggle--${collapsed ? 'collapsed' : 'open'}`,
].filter(Boolean)
.join(' ')}
onClick={toggleCollapsed}
>
<div className={`${baseClass}__label`}>
{label}
</div>
<Chevron className={`${baseClass}__indicator`} />
</button>
<AnimateHeight
height={collapsed ? 0 : 'auto'}
duration={animate ? 200 : 0}
>
<div className={`${baseClass}__content`}>
{children}
</div>
</AnimateHeight>
</div>
);
}
return (
<React.Fragment>
{children}
</React.Fragment>
);
};
export default NavGroup;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useConfig } from '../../utilities/Config';
@@ -7,6 +7,7 @@ import Card from '../../elements/Card';
import Button from '../../elements/Button';
import { Props } from './types';
import { Gutter } from '../../elements/Gutter';
import { groupNavItems, Group, EntityToGroup, EntityType } from '../../../utilities/groupNavItems';
import './index.scss';
@@ -33,52 +34,81 @@ const Dashboard: React.FC<Props> = (props) => {
},
} = useConfig();
const [groups, setGroups] = useState<Group[]>([]);
useEffect(() => {
setGroups(groupNavItems([
...collections.map((collection) => {
const entityToGroup: EntityToGroup = {
type: EntityType.collection,
entity: collection,
};
return entityToGroup;
}),
...globals.map((global) => {
const entityToGroup: EntityToGroup = {
type: EntityType.global,
entity: global,
};
return entityToGroup;
}),
], permissions));
}, [collections, globals, permissions]);
return (
<div className={baseClass}>
<Eyebrow />
<Gutter className={`${baseClass}__wrap`}>
{Array.isArray(beforeDashboard) && beforeDashboard.map((Component, i) => <Component key={i} />)}
<h2 className={`${baseClass}__label`}>Collections</h2>
<ul className={`${baseClass}__card-list`}>
{collections.map((collection) => {
const hasCreatePermission = permissions?.collections?.[collection.slug]?.create?.permission;
{groups.map(({ label, entities }, groupIndex) => {
return (
<React.Fragment key={groupIndex}>
<h2 className={`${baseClass}__label`}>{label}</h2>
<ul className={`${baseClass}__card-list`}>
{entities.map(({ entity, type }, entityIndex) => {
let title: string;
let createHREF: string;
let onClick: () => void;
let hasCreatePermission: boolean;
return (
<li key={collection.slug}>
<Card
title={collection.labels.plural}
id={`card-${collection.slug}`}
onClick={() => push({ pathname: `${admin}/collections/${collection.slug}` })}
actions={hasCreatePermission ? (
<Button
el="link"
to={`${admin}/collections/${collection.slug}/create`}
icon="plus"
round
buttonStyle="icon-label"
iconStyle="with-border"
/>
) : undefined}
/>
</li>
);
})}
</ul>
{(globals.length > 0) && (
<React.Fragment>
<h2 className={`${baseClass}__label`}>Globals</h2>
<ul className={`${baseClass}__card-list`}>
{globals.map((global) => (
<li key={global.slug}>
<Card
title={global.label}
onClick={() => push({ pathname: `${admin}/globals/${global.slug}` })}
/>
</li>
))}
</ul>
</React.Fragment>
)}
if (type === EntityType.collection) {
title = entity.labels.plural;
onClick = () => push({ pathname: `${admin}/collections/${entity.slug}` });
createHREF = `${admin}/collections/${entity.slug}/create`;
hasCreatePermission = permissions?.collections?.[entity.slug]?.create?.permission;
}
if (type === EntityType.global) {
title = entity.label;
onClick = () => push({ pathname: `${admin}/globals/${global.slug}` });
}
return (
<li key={entityIndex}>
<Card
title={title}
id={`card-${entity.slug}`}
onClick={onClick}
actions={(hasCreatePermission && type === EntityType.collection) ? (
<Button
el="link"
to={createHREF}
icon="plus"
round
buttonStyle="icon-label"
iconStyle="with-border"
/>
) : undefined}
/>
</li>
);
})}
</ul>
</React.Fragment>
);
})}
{Array.isArray(afterDashboard) && afterDashboard.map((Component, i) => <Component key={i} />)}
</Gutter>
</div>

View File

@@ -57,4 +57,4 @@
margin: 0 base(.25) base(.5);
}
}
}
}

View File

@@ -0,0 +1,54 @@
import { Permissions } from '../../auth';
import { SanitizedCollectionConfig } from '../../collections/config/types';
import { SanitizedGlobalConfig } from '../../globals/config/types';
export enum EntityType {
collection = 'Collections',
global = 'Globals'
}
export type EntityToGroup = {
type: EntityType.collection
entity: SanitizedCollectionConfig
} | {
type: EntityType.global
entity: SanitizedGlobalConfig
}
export type Group = {
label: string
entities: EntityToGroup[]
}
export function groupNavItems(entities: EntityToGroup[], permissions: Permissions): Group[] {
const result = entities.reduce((groups, entityToGroup) => {
if (permissions?.[entityToGroup.type.toLowerCase()]?.[entityToGroup.entity.slug]?.read.permission) {
if (entityToGroup.entity.admin.group) {
const existingGroup = groups.find((group) => group.label === entityToGroup.entity.admin.group);
let matchedGroup: Group = existingGroup;
if (!existingGroup) {
matchedGroup = { label: entityToGroup.entity.admin.group, entities: [] };
groups.push(matchedGroup);
}
matchedGroup.entities.push(entityToGroup);
} else {
const defaultGroup = groups.find((group) => group.label === entityToGroup.type);
defaultGroup.entities.push(entityToGroup);
}
}
return groups;
}, [
{
label: 'Collections',
entities: [],
},
{
label: 'Globals',
entities: [],
},
]);
return result.filter((group) => group.entities.length > 0);
}

View File

@@ -26,6 +26,7 @@ const collectionSchema = joi.object().keys({
admin: joi.object({
useAsTitle: joi.string(),
defaultColumns: joi.array().items(joi.string()),
group: joi.string(),
description: joi.alternatives().try(
joi.string(),
componentSchema,

View File

@@ -151,6 +151,10 @@ export type CollectionAdminOptions = {
* Default columns to show in list view
*/
defaultColumns?: string[];
/**
* Place collections into a navigational group
*/
group?: string;
/**
* Custom description for collection
*/

View File

@@ -6,6 +6,7 @@ const globalSchema = joi.object().keys({
slug: joi.string().required(),
label: joi.string(),
admin: joi.object({
group: joi.string(),
hideAPIURL: joi.boolean(),
description: joi.alternatives().try(
joi.string(),

View File

@@ -65,6 +65,7 @@ export type GlobalConfig = {
fields: Field[];
admin?: {
description?: string | (() => string);
group?: string;
hideAPIURL?: boolean;
components?: {
views?: {

View File

@@ -19,6 +19,7 @@ const AfterNavLinks: React.FC = () => {
<span className="nav__label">Custom Routes</span>
<nav>
<NavLink
className="nav__link"
activeClassName="active"
to={`${adminRoute}/custom-default-route`}
>
@@ -26,6 +27,7 @@ const AfterNavLinks: React.FC = () => {
Default Template
</NavLink>
<NavLink
className="nav__link"
activeClassName="active"
to={`${adminRoute}/custom-minimal-route`}
>

View File

@@ -48,8 +48,16 @@ export default buildConfig({
},
},
collections: [
{
slug: 'users',
auth: true,
fields: [],
},
{
slug,
admin: {
group: 'One',
},
fields: [
{
name: 'title',
@@ -61,10 +69,85 @@ export default buildConfig({
},
],
},
{
slug: 'group-one-collection-ones',
admin: {
group: 'One',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-one-collection-twos',
admin: {
group: 'One',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-two-collection-ones',
admin: {
group: 'Two',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-two-collection-twos',
admin: {
group: 'Two',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
],
globals: [
{
slug: globalSlug,
admin: {
group: 'Group',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-globals-one',
admin: {
group: 'Group',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-globals-two',
admin: {
group: 'Group',
},
fields: [
{
name: 'title',

View File

@@ -56,6 +56,48 @@ describe('admin', () => {
expect(page.url()).toContain(url.list);
});
test('should collapse and expand collection groups', async () => {
await page.goto(url.admin);
const navGroup = page.locator('#nav-group-One .nav-group__toggle');
const link = await page.locator('#nav-group-one-collection-ones');
await expect(navGroup).toContainText('One');
await expect(link).toBeVisible();
await navGroup.click();
await expect(link).not.toBeVisible();
await navGroup.click();
await expect(link).toBeVisible();
});
test('should collapse and expand globals groups', async () => {
await page.goto(url.admin);
const navGroup = page.locator('#nav-group-Group .nav-group__toggle');
const link = await page.locator('#nav-global-group-globals-one');
await expect(navGroup).toContainText('Group');
await expect(link).toBeVisible();
await navGroup.click();
await expect(link).not.toBeVisible();
await navGroup.click();
await expect(link).toBeVisible();
});
test('should save nav group collapse preferences', async () => {
await page.goto(url.admin);
const navGroup = page.locator('#nav-group-One .nav-group__toggle');
await navGroup.click();
await page.goto(url.admin);
const link = await page.locator('#nav-group-one-collection-ones');
await expect(link).not.toBeVisible();
});
test('breadcrumbs - from list to dashboard', async () => {
await page.goto(url.list);
await page.locator('.step-nav a[href="/admin"]').click();