Merge branch 'feat/group-collections' of github.com:payloadcms/payload into feat/group-collections

This commit is contained in:
James
2022-09-12 10:55:15 -07:00
11 changed files with 353 additions and 41 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

@@ -98,6 +98,7 @@
nav {
margin: base(.25) 0 $baseline;
width: 100%;
a {
position: relative;

View File

@@ -10,6 +10,9 @@ import CloseMenu from '../../icons/CloseMenu';
import Icon from '../../graphics/Icon';
import Account from '../../graphics/Account';
import Localizer from '../Localizer';
import NavGroup from '../NavGroup';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
import './index.scss';
@@ -36,7 +39,26 @@ const DefaultNav = () => {
const classes = [
baseClass,
menuActive && `${baseClass}--menu-active`,
].filter(Boolean).join(' ');
].filter(Boolean)
.join(' ');
const groupNavItems = (items) => {
return items.reduce((acc, currentValue) => {
if (currentValue.admin.group) {
if (acc[currentValue.admin.group]) {
acc[currentValue.admin.group].push(currentValue);
} else {
acc[currentValue.admin.group] = [currentValue];
}
} else {
acc[''].push(currentValue);
}
return acc;
}, { '': [] });
};
const groupedCollections: Record<string, SanitizedCollectionConfig[]> = groupNavItems(collections);
const groupedGlobals: Record<string, SanitizedGlobalConfig[]> = groupNavItems(globals);
useEffect(() => history.listen(() => {
setMenuActive(false);
@@ -67,51 +89,73 @@ const DefaultNav = () => {
</header>
<div 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}`;
{ groupedCollections[''].length > 0 && (
<span className={`${baseClass}__label`}>Collections</span>
) }
<nav className={`${baseClass}__collections`}>
{Object.entries(groupedCollections)
.map(([group, groupCollections]) => (
<NavGroup
key={group}
label={group}
type="collections"
>
{groupCollections.map((collection, i) => {
const href = `${admin}/collections/${collection.slug}`;
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;
})}
if (permissions?.collections?.[collection.slug]?.read.permission) {
return (
<NavLink
id={`nav-${collection.slug}`}
className={`${baseClass}__link`}
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{collection.labels.plural}
</NavLink>
);
}
return null;
})}
</NavGroup>
))}
</nav>
{(globals && globals.length > 0) && (
<React.Fragment>
{ groupedGlobals[''].length > 0 && (
<span className={`${baseClass}__label`}>Globals</span>
<nav>
{globals.map((global, i) => {
const href = `${admin}/globals/${global.slug}`;
) }
<nav className={`${baseClass}__globals`}>
{Object.entries(groupedGlobals)
.map(([group, globalsGroup]) => (
<NavGroup
key={group}
label={group}
type="globals"
>
{globalsGroup.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>
);
}
return null;
})}
if (permissions?.globals?.[global.slug]?.read.permission) {
return (
<NavLink
id={`nav-global-${global.slug}`}
className={`${baseClass}__link`}
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{global.label}
</NavLink>
);
}
return null;
})}
</NavGroup>
))}
</nav>
</React.Fragment>
)}

View File

@@ -0,0 +1,46 @@
@import '../../../scss/styles.scss';
.nav-group {
width: 100%;
&__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: center;
&:hover {
color: var(--theme-elevation-1000);
.stroke {
stroke: var(--theme-elevation-1000);
}
}
}
&__indicator {
transform: rotate(.5turn);
margin-left: auto;
.stroke {
stroke: var(--theme-elevation-400);
}
}
&--collapsed {
.collapsible__toggle {
border-bottom-right-radius: $style-radius-s;
border-bottom-left-radius: $style-radius-s;
}
.nav-group__indicator {
transform: rotate(0turn);
}
}
}

View File

@@ -0,0 +1,85 @@
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';
const NavGroup: React.FC<{ children: React.ReactNode, label: string, type: string}> = ({
children,
label,
type,
}) => {
const [collapsed, setCollapsed] = useState(true);
const [animate, setAnimate] = useState(false);
const { getPreference, setPreference } = usePreferences();
const preferencesKey = `collapsed-${type}-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}
>
{label}
<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

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

@@ -48,8 +48,19 @@ export default buildConfig({
},
},
collections: [
{
slug: 'users',
admin: {
group: 'One',
},
auth: true,
fields: [],
},
{
slug,
admin: {
group: 'One',
},
fields: [
{
name: 'title',
@@ -61,10 +72,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();