feat: collection groups
This commit is contained in:
@@ -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:**
|
||||
|
||||
|
||||
@@ -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 { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -36,7 +38,21 @@ const DefaultNav = () => {
|
||||
const classes = [
|
||||
baseClass,
|
||||
menuActive && `${baseClass}--menu-active`,
|
||||
].filter(Boolean).join(' ');
|
||||
].filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const groupedCollections: Record<string, SanitizedCollectionConfig[]> = collections.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;
|
||||
}, { '': [] });
|
||||
|
||||
useEffect(() => history.listen(() => {
|
||||
setMenuActive(false);
|
||||
@@ -68,31 +84,39 @@ const DefaultNav = () => {
|
||||
<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}`;
|
||||
<nav className={`${baseClass}__collections`}>
|
||||
{Object.entries(groupedCollections)
|
||||
.map(([group, groupCollections]) => (
|
||||
<NavGroup
|
||||
key={group}
|
||||
label={group}
|
||||
>
|
||||
{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>
|
||||
<span className={`${baseClass}__label`}>Globals</span>
|
||||
<nav>
|
||||
<nav className={`${baseClass}__globals`}>
|
||||
{globals.map((global, i) => {
|
||||
const href = `${admin}/globals/${global.slug}`;
|
||||
|
||||
|
||||
32
src/admin/components/elements/NavGroup/index.scss
Normal file
32
src/admin/components/elements/NavGroup/index.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.nav-group {
|
||||
&__toggle {
|
||||
cursor: pointer;
|
||||
color: var(--theme-elevation-600);
|
||||
background: transparent;
|
||||
padding-left: 0;
|
||||
border: 0;
|
||||
margin-top: base(.25);
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
transform: rotate(.5turn);
|
||||
|
||||
.stroke {
|
||||
stroke: var(--theme-elevation-600);
|
||||
}
|
||||
}
|
||||
|
||||
&--collapsed {
|
||||
.collapsible__toggle {
|
||||
border-bottom-right-radius: $style-radius-s;
|
||||
border-bottom-left-radius: $style-radius-s;
|
||||
}
|
||||
|
||||
.nav-group__indicator {
|
||||
transform: rotate(0turn);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
83
src/admin/components/elements/NavGroup/index.tsx
Normal file
83
src/admin/components/elements/NavGroup/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
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 preferencesKey = 'collapsed-nav-groups';
|
||||
|
||||
const NavGroup: React.FC<{ children: React.ReactNode, label: string}> = ({
|
||||
children,
|
||||
label,
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [animate, setAnimate] = useState(false);
|
||||
const { getPreference, setPreference } = usePreferences();
|
||||
|
||||
useEffect(() => {
|
||||
if (label) {
|
||||
const setCollapsedFromPreferences = async () => {
|
||||
const preferences = await getPreference(preferencesKey) || [];
|
||||
setCollapsed(preferences.indexOf(label) !== -1);
|
||||
};
|
||||
setCollapsedFromPreferences();
|
||||
}
|
||||
}, [getPreference, label]);
|
||||
|
||||
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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -61,6 +61,54 @@ 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: [
|
||||
{
|
||||
|
||||
@@ -56,6 +56,33 @@ 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 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();
|
||||
|
||||
Reference in New Issue
Block a user