From 81d69d1b64484afd45601f79f176d683c9954a70 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 17 Apr 2023 15:21:42 -0400 Subject: [PATCH] feat: add admin.hidden to collections and globals (#2487) --- docs/configuration/collections.mdx | 23 +- docs/configuration/globals.mdx | 9 +- src/admin/components/Routes.tsx | 284 +++++++++--------- src/admin/components/elements/Nav/index.tsx | 42 +-- .../components/views/Dashboard/Default.tsx | 37 ++- .../components/views/Dashboard/index.tsx | 5 +- src/admin/components/views/Dashboard/types.ts | 5 +- src/collections/config/schema.ts | 4 + src/collections/config/types.ts | 6 +- src/globals/config/schema.ts | 4 + src/globals/config/types.ts | 7 +- test/admin/config.ts | 26 +- test/admin/e2e.spec.ts | 18 ++ test/helpers/adminUrlUtil.ts | 4 + 14 files changed, 276 insertions(+), 198 deletions(-) diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 3b0c3a026..68e28e14a 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -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 diff --git a/docs/configuration/globals.mdx b/docs/configuration/globals.mdx index 8707f03ca..88c3d902b 100644 --- a/docs/configuration/globals.mdx +++ b/docs/configuration/globals.mdx @@ -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 diff --git a/src/admin/components/Routes.tsx b/src/admin/components/Routes.tsx index d4423ee21..ffa02c429 100644 --- a/src/admin/components/Routes.tsx +++ b/src/admin/components/Routes.tsx @@ -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 = () => { - {collections.reduce((collectionRoutes, collection) => { - const routesToReturn = [ - ...collectionRoutes, - { - if (permissions?.collections?.[collection.slug]?.read?.permission) { - return ( - - ); - } - - return ; - }} - />, - { - if (permissions?.collections?.[collection.slug]?.create?.permission) { - return ( - - - - ); - } - - return ; - }} - />, - { - const { match: { params: { id } } } = routeProps; - if (permissions?.collections?.[collection.slug]?.read?.permission) { - return ( - - - - ); - } - - return ; - }} - />, - ]; - - if (collection.versions) { - routesToReturn.push( + {collections + .filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden)) + .reduce((collectionRoutes, collection) => { + const routesToReturn = [ + ...collectionRoutes, { - if (permissions?.collections?.[collection.slug]?.readVersions?.permission) { + if (permissions?.collections?.[collection.slug]?.read?.permission) { return ( - @@ -263,21 +198,15 @@ const Routes = () => { return ; }} />, - ); - - routesToReturn.push( { - if (permissions?.collections?.[collection.slug]?.readVersions?.permission) { + if (permissions?.collections?.[collection.slug]?.create?.permission) { return ( - - + @@ -288,81 +217,154 @@ const Routes = () => { return ; }} />, - ); - } - - return routesToReturn; - }, [])} - - {globals && globals.reduce((globalRoutes, global) => { - const routesToReturn = [ - ...globalRoutes, - { - if (permissions?.globals?.[global.slug]?.read?.permission) { - return ( - - - - ); - } - - return ; - }} - />, - ]; - - if (global.versions) { - routesToReturn.push( { - if (permissions?.globals?.[global.slug]?.readVersions?.permission) { + const { match: { params: { id } } } = routeProps; + if (permissions?.collections?.[collection.slug]?.read?.permission) { return ( - + + + ); } return ; }} />, - ); - routesToReturn.push( + ]; + + if (collection.versions) { + routesToReturn.push( + { + if (permissions?.collections?.[collection.slug]?.readVersions?.permission) { + return ( + + ); + } + + return ; + }} + />, + ); + + routesToReturn.push( + { + if (permissions?.collections?.[collection.slug]?.readVersions?.permission) { + return ( + + + + ); + } + + return ; + }} + />, + ); + } + + return routesToReturn; + }, [])} + + {globals && globals + .filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden)) + .reduce((globalRoutes, global) => { + const routesToReturn = [ + ...globalRoutes, { - if (permissions?.globals?.[global.slug]?.readVersions?.permission) { + if (permissions?.globals?.[global.slug]?.read?.permission) { return ( - + key={`${global.slug}-${locale}`} + > + + ); } return ; }} />, - ); - } - return routesToReturn; - }, [])} + ]; + + if (global.versions) { + routesToReturn.push( + { + if (permissions?.globals?.[global.slug]?.readVersions?.permission) { + return ( + + ); + } + + return ; + }} + />, + ); + routesToReturn.push( + { + if (permissions?.globals?.[global.slug]?.readVersions?.permission) { + return ( + + ); + } + + return ; + }} + />, + ); + } + return routesToReturn; + }, [])} diff --git a/src/admin/components/elements/Nav/index.tsx b/src/admin/components/elements/Nav/index.tsx index 6f2c71ad7..f2540331d 100644 --- a/src/admin/components/elements/Nav/index.tsx +++ b/src/admin/components/elements/Nav/index.tsx @@ -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([]); 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); diff --git a/src/admin/components/views/Dashboard/Default.tsx b/src/admin/components/views/Dashboard/Default.tsx index 70dd81177..056d63892 100644 --- a/src/admin/components/views/Dashboard/Default.tsx +++ b/src/admin/components/views/Dashboard/Default.tsx @@ -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) => { collections, globals, permissions, + user, } = props; const { push } = useHistory(); @@ -41,24 +42,28 @@ const Dashboard: React.FC = (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 (
diff --git a/src/admin/components/views/Dashboard/index.tsx b/src/admin/components/views/Dashboard/index.tsx index 93beb421d..7fed8e743 100644 --- a/src/admin/components/views/Dashboard/index.tsx +++ b/src/admin/components/views/Dashboard/index.tsx @@ -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, }} /> ); diff --git a/src/admin/components/views/Dashboard/types.ts b/src/admin/components/views/Dashboard/types.ts index 3783ce2ae..892cf9178 100644 --- a/src/admin/components/views/Dashboard/types.ts +++ b/src/admin/components/views/Dashboard/types.ts @@ -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, } diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 3f3a24231..2950eff74 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -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()), diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index e0eff06b4..2bdbcb95f 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -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 = { export type BeforeDuplicate = (args: BeforeDuplicateArgs) => T | Promise 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 */ diff --git a/src/globals/config/schema.ts b/src/globals/config/schema.ts index 1095082d0..0101308eb 100644 --- a/src/globals/config/schema.ts +++ b/src/globals/config/schema.ts @@ -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()]), diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts index 060733d07..67ca97b72 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -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 { } export type GlobalAdminOptions = { + /** + * Exclude the global from the admin nav and routes + */ + hidden?: ((args: { user: User }) => boolean) | boolean; /** * Place globals into a navigational group * */ diff --git a/test/admin/config.ts b/test/admin/config.ts index 9dcb5e7d1..22d2f0111 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -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: { diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index 979c73f51..46eebd746 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -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', () => { diff --git a/test/helpers/adminUrlUtil.ts b/test/helpers/adminUrlUtil.ts index 781fdd91b..253d164c5 100644 --- a/test/helpers/adminUrlUtil.ts +++ b/test/helpers/adminUrlUtil.ts @@ -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}`; }