feat: add admin.hidden to collections and globals (#2487)

This commit is contained in:
Dan Ribbens
2023-04-17 15:21:42 -04:00
committed by GitHub
parent faef4d5f8e
commit 81d69d1b64
14 changed files with 276 additions and 198 deletions

View File

@@ -63,18 +63,19 @@ You can find an assortment of [example collection configs](https://github.com/pa
You can customize the way that the Admin panel behaves on a collection-by-collection basis by defining the `admin` property on a collection's config.
| Option | Description |
| --------------------------- | -------------|
| `group` | Text used as a label for grouping collection links together in the navigation. |
| `hooks` | Admin-specific hooks for this collection. [More](#admin-hooks) |
| `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. |
| `disableDuplicate ` | Disables the "Duplicate" button while editing documents within this collection. |
| Option | Description |
|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `group` | Text used as a label for grouping collection links together in the navigation. |
| `hidden` | Set to true or a function, called with the current user, returning true to exclude this collection from navigation and admin routing. |
| `hooks` | Admin-specific hooks for this collection. [More](#admin-hooks) |
| `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. |
| `disableDuplicate ` | Disables the "Duplicate" button while editing documents within this collection. |
| `enableRichTextRelationship` | The [Rich Text](/docs/fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `preview` | Function to generate preview URLS within the Admin panel that can point to your app. [More](#preview). |
| `components` | Swap in your own React components to be used within this collection. [More](/docs/admin/components#collections) |
| `listSearchableFields` | Specify which fields should be searched in the List search view. [More](#list-searchable-fields) |
| `preview` | Function to generate preview URLS within the Admin panel that can point to your app. [More](#preview). |
| `components` | Swap in your own React components to be used within this collection. [More](/docs/admin/components#collections) |
| `listSearchableFields` | Specify which fields should be searched in the List search view. [More](#list-searchable-fields) |
### Preview

View File

@@ -64,10 +64,11 @@ You can find an [example Global config](https://github.com/payloadcms/public-dem
You can customize the way that the Admin panel behaves on a Global-by-Global basis by defining the `admin` property on a Global's config.
| Option | Description |
| ------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `components` | Swap in your own React components to be used within this Global. [More](/docs/admin/components#globals) |
| `preview` | Function to generate a preview URL within the Admin panel for this global that can point to your app. [More](#preview). |
| Option | Description |
|--------------|-----------------------------------------------------------------------------------------------------------------------------------|
| `hidden` | Set to true or a function, called with the current user, returning true to exclude this global from navigation and admin routing. |
| `components` | Swap in your own React components to be used within this Global. [More](/docs/admin/components#globals) |
| `preview` | Function to generate a preview URL within the Admin panel for this global that can point to your app. [More](#preview). |
### Preview

View File

@@ -1,7 +1,5 @@
import React, { Suspense, lazy, useState, useEffect } from 'react';
import {
Route, Switch, withRouter, Redirect,
} from 'react-router-dom';
import React, { lazy, Suspense, useEffect, useState } from 'react';
import { Redirect, Route, Switch, withRouter } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from './utilities/Auth';
import { useConfig } from './utilities/Config';
@@ -178,82 +176,19 @@ const Routes = () => {
</DocumentInfoProvider>
</Route>
{collections.reduce((collectionRoutes, collection) => {
const routesToReturn = [
...collectionRoutes,
<Route
key={`${collection.slug}-list`}
path={`${match.url}/collections/${collection.slug}`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<List
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>,
<Route
key={`${collection.slug}-create`}
path={`${match.url}/collections/${collection.slug}/create`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.create?.permission) {
return (
<DocumentInfoProvider collection={collection}>
<Edit
{...routeProps}
collection={collection}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
<Route
key={`${collection.slug}-edit`}
path={`${match.url}/collections/${collection.slug}/:id`}
exact
render={(routeProps) => {
const { match: { params: { id } } } = routeProps;
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<DocumentInfoProvider
key={`${collection.slug}-edit-${id}-${locale}`}
collection={collection}
id={id}
>
<Edit
isEditing
{...routeProps}
collection={collection}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
];
if (collection.versions) {
routesToReturn.push(
{collections
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.reduce((collectionRoutes, collection) => {
const routesToReturn = [
...collectionRoutes,
<Route
key={`${collection.slug}-versions`}
path={`${match.url}/collections/${collection.slug}/:id/versions`}
key={`${collection.slug}-list`}
path={`${match.url}/collections/${collection.slug}`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<Versions
<List
{...routeProps}
collection={collection}
/>
@@ -263,21 +198,15 @@ const Routes = () => {
return <Unauthorized />;
}}
/>,
);
routesToReturn.push(
<Route
key={`${collection.slug}-view-version`}
path={`${match.url}/collections/${collection.slug}/:id/versions/:versionID`}
key={`${collection.slug}-create`}
path={`${match.url}/collections/${collection.slug}/create`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
if (permissions?.collections?.[collection.slug]?.create?.permission) {
return (
<DocumentInfoProvider
collection={collection}
id={routeProps.match.params.id}
>
<Version
<DocumentInfoProvider collection={collection}>
<Edit
{...routeProps}
collection={collection}
/>
@@ -288,81 +217,154 @@ const Routes = () => {
return <Unauthorized />;
}}
/>,
);
}
return routesToReturn;
}, [])}
{globals && globals.reduce((globalRoutes, global) => {
const routesToReturn = [
...globalRoutes,
<Route
key={`${global.slug}`}
path={`${match.url}/globals/${global.slug}`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.read?.permission) {
return (
<DocumentInfoProvider
global={global}
key={`${global.slug}-${locale}`}
>
<EditGlobal
{...routeProps}
global={global}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
];
if (global.versions) {
routesToReturn.push(
<Route
key={`${global.slug}-versions`}
path={`${match.url}/globals/${global.slug}/versions`}
key={`${collection.slug}-edit`}
path={`${match.url}/collections/${collection.slug}/:id`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.readVersions?.permission) {
const { match: { params: { id } } } = routeProps;
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<Versions
{...routeProps}
global={global}
/>
<DocumentInfoProvider
key={`${collection.slug}-edit-${id}-${locale}`}
collection={collection}
id={id}
>
<Edit
isEditing
{...routeProps}
collection={collection}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
);
routesToReturn.push(
];
if (collection.versions) {
routesToReturn.push(
<Route
key={`${collection.slug}-versions`}
path={`${match.url}/collections/${collection.slug}/:id/versions`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
return (
<Versions
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>,
);
routesToReturn.push(
<Route
key={`${collection.slug}-view-version`}
path={`${match.url}/collections/${collection.slug}/:id/versions/:versionID`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
return (
<DocumentInfoProvider
collection={collection}
id={routeProps.match.params.id}
>
<Version
{...routeProps}
collection={collection}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
);
}
return routesToReturn;
}, [])}
{globals && globals
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.reduce((globalRoutes, global) => {
const routesToReturn = [
...globalRoutes,
<Route
key={`${global.slug}-view-version`}
path={`${match.url}/globals/${global.slug}/versions/:versionID`}
key={`${global.slug}`}
path={`${match.url}/globals/${global.slug}`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.readVersions?.permission) {
if (permissions?.globals?.[global.slug]?.read?.permission) {
return (
<Version
{...routeProps}
<DocumentInfoProvider
global={global}
/>
key={`${global.slug}-${locale}`}
>
<EditGlobal
{...routeProps}
global={global}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
);
}
return routesToReturn;
}, [])}
];
if (global.versions) {
routesToReturn.push(
<Route
key={`${global.slug}-versions`}
path={`${match.url}/globals/${global.slug}/versions`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.readVersions?.permission) {
return (
<Versions
{...routeProps}
global={global}
/>
);
}
return <Unauthorized />;
}}
/>,
);
routesToReturn.push(
<Route
key={`${global.slug}-view-version`}
path={`${match.url}/globals/${global.slug}/versions/:versionID`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.readVersions?.permission) {
return (
<Version
{...routeProps}
global={global}
/>
);
}
return <Unauthorized />;
}}
/>,
);
}
return routesToReturn;
}, [])}
<Route path={`${match.url}*`}>
<NotFound />

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { NavLink, Link, useHistory } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { Link, NavLink, useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
@@ -12,7 +12,7 @@ import Account from '../../graphics/Account';
import Localizer from '../Localizer';
import NavGroup from '../NavGroup';
import Logout from '../Logout';
import { groupNavItems, Group, EntityToGroup, EntityType } from '../../../utilities/groupNavItems';
import { EntityToGroup, EntityType, Group, groupNavItems } from '../../../utilities/groupNavItems';
import { getTranslation } from '../../../../utilities/getTranslation';
import './index.scss';
@@ -20,7 +20,7 @@ import './index.scss';
const baseClass = 'nav';
const DefaultNav = () => {
const { permissions } = useAuth();
const { permissions, user } = useAuth();
const [menuActive, setMenuActive] = useState(false);
const [groups, setGroups] = useState<Group[]>([]);
const history = useHistory();
@@ -47,24 +47,28 @@ const DefaultNav = () => {
useEffect(() => {
setGroups(groupNavItems([
...collections.map((collection) => {
const entityToGroup: EntityToGroup = {
type: EntityType.collection,
entity: collection,
};
...collections
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.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;
}),
...globals
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.map((global) => {
const entityToGroup: EntityToGroup = {
type: EntityType.global,
entity: global,
};
return entityToGroup;
}),
return entityToGroup;
}),
], permissions, i18n));
}, [collections, globals, permissions, i18n, i18n.language]);
}, [collections, globals, permissions, i18n, i18n.language, user]);
useEffect(() => history.listen(() => {
setMenuActive(false);

View File

@@ -8,7 +8,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 { EntityToGroup, EntityType, Group, groupNavItems } from '../../../utilities/groupNavItems';
import { getTranslation } from '../../../../utilities/getTranslation';
import './index.scss';
@@ -20,6 +20,7 @@ const Dashboard: React.FC<Props> = (props) => {
collections,
globals,
permissions,
user,
} = props;
const { push } = useHistory();
@@ -41,24 +42,28 @@ const Dashboard: React.FC<Props> = (props) => {
useEffect(() => {
setGroups(groupNavItems([
...collections.map((collection) => {
const entityToGroup: EntityToGroup = {
type: EntityType.collection,
entity: collection,
};
...collections
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.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;
}),
...globals
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.map((global) => {
const entityToGroup: EntityToGroup = {
type: EntityType.global,
entity: global,
};
return entityToGroup;
}),
return entityToGroup;
}),
], permissions, i18n));
}, [collections, globals, i18n, permissions]);
}, [collections, globals, i18n, permissions, user]);
return (
<div className={baseClass}>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
import { useStepNav } from '../../elements/StepNav';
@@ -6,7 +6,7 @@ import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import DefaultDashboard from './Default';
const Dashboard: React.FC = () => {
const { permissions } = useAuth();
const { permissions, user } = useAuth();
const { setStepNav } = useStepNav();
const [filteredGlobals, setFilteredGlobals] = useState([]);
@@ -42,6 +42,7 @@ const Dashboard: React.FC = () => {
globals: filteredGlobals,
collections: collections.filter((collection) => permissions?.collections?.[collection.slug]?.read?.permission),
permissions,
user,
}}
/>
);

View File

@@ -1,9 +1,10 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
import { Permissions } from '../../../../auth/types';
import { Permissions, User } from '../../../../auth/types';
export type Props = {
collections: SanitizedCollectionConfig[],
globals: SanitizedGlobalConfig[],
permissions: Permissions
permissions: Permissions,
user: User,
}

View File

@@ -38,6 +38,10 @@ const collectionSchema = joi.object().keys({
}),
timestamps: joi.boolean(),
admin: joi.object({
hidden: joi.alternatives().try(
joi.boolean(),
joi.func(),
),
useAsTitle: joi.string(),
defaultColumns: joi.array().items(joi.string()),
listSearchableFields: joi.array().items(joi.string()),

View File

@@ -6,7 +6,7 @@ import { Response } from 'express';
import { Access, Endpoint, EntityDescription, GeneratePreviewURL } from '../../config/types';
import { Field } from '../../fields/config/types';
import { PayloadRequest } from '../../express/types';
import { Auth, IncomingAuthType } from '../../auth/types';
import { Auth, IncomingAuthType, User } from '../../auth/types';
import { IncomingUploadType, Upload } from '../../uploads/types';
import { IncomingCollectionVersions, SanitizedCollectionVersions } from '../../versions/types';
import { Config as GeneratedTypes } from '../../generated-types';
@@ -153,6 +153,10 @@ type BeforeDuplicateArgs<T> = {
export type BeforeDuplicate<T = any> = (args: BeforeDuplicateArgs<T>) => T | Promise<T>
export type CollectionAdminOptions = {
/**
* Exclude the collection from the admin nav and routes
*/
hidden?: ((args: { user: User }) => boolean) | boolean;
/**
* Field to use as title in Edit view and first column in List view
*/

View File

@@ -9,6 +9,10 @@ const globalSchema = joi.object().keys({
joi.object().pattern(joi.string(), [joi.string()]),
),
admin: joi.object({
hidden: joi.alternatives().try(
joi.boolean(),
joi.func(),
),
group: joi.alternatives().try(
joi.string(),
joi.object().pattern(joi.string(), [joi.string()]),

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { Model, Document } from 'mongoose';
import { Document, Model } from 'mongoose';
import { DeepRequired } from 'ts-essentials';
import { GraphQLNonNull, GraphQLObjectType } from 'graphql';
import { User } from '../../auth/types';
import { PayloadRequest } from '../../express/types';
import { Access, Endpoint, EntityDescription, GeneratePreviewURL } from '../../config/types';
import { Field } from '../../fields/config/types';
@@ -46,6 +47,10 @@ export interface GlobalModel extends Model<Document> {
}
export type GlobalAdminOptions = {
/**
* Exclude the global from the admin nav and routes
*/
hidden?: ((args: { user: User }) => boolean) | boolean;
/**
* Place globals into a navigational group
* */

View File

@@ -7,7 +7,7 @@ import CustomMinimalRoute from './components/views/CustomMinimal';
import CustomDefaultRoute from './components/views/CustomDefault';
import BeforeLogin from './components/BeforeLogin';
import AfterNavLinks from './components/AfterNavLinks';
import { slug, globalSlug } from './shared';
import { globalSlug, slug } from './shared';
import Logout from './components/Logout';
import DemoUIFieldField from './components/DemoUIField/Field';
import DemoUIFieldCell from './components/DemoUIField/Cell';
@@ -68,6 +68,18 @@ export default buildConfig({
auth: true,
fields: [],
},
{
slug: 'hidden-collection',
admin: {
hidden: () => true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug,
labels: {
@@ -176,6 +188,18 @@ export default buildConfig({
},
],
globals: [
{
slug: 'hidden-global',
admin: {
hidden: () => true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: globalSlug,
label: {

View File

@@ -111,6 +111,24 @@ describe('admin', () => {
await page.locator(`.step-nav >> text=${slug}`).click();
expect(page.url()).toContain(url.list);
});
test('should not show hidden collections and globals', async () => {
await page.goto(url.admin);
// nav menu
await expect(await page.locator('#nav-hidden-collection')).toBeHidden();
await expect(await page.locator('#nav-hidden-global')).toBeHidden();
// dashboard
await expect(await page.locator('#card-hidden-collection')).toBeHidden();
await expect(await page.locator('#card-hidden-global')).toBeHidden();
// routing
await page.goto(url.collection('hidden-collection'));
await expect(await page.locator('.not-found')).toContainText('Nothing found');
await page.goto(url.global('hidden-global'));
await expect(await page.locator('.not-found')).toContainText('Nothing found');
});
});
describe('CRUD', () => {

View File

@@ -18,6 +18,10 @@ export class AdminUrlUtil {
return `${this.list}/${id}`;
}
collection(slug: string): string {
return `${this.admin}/collections/${slug}`;
}
global(slug: string): string {
return `${this.admin}/globals/${slug}`;
}