Skip to content

feat(next): adds condition property to all document views #12698

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/custom-components/custom-views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ For more granular control, pass a configuration object instead. Payload exposes
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Component` \* | Pass in the component path that should be rendered when a user navigates to this route. |
| `path` \* | Any valid URL path or array of paths that [`path-to-regexp`](https://www.npmjs.com/package/path-to-regex) understands. |
| `condition` | Optional function that receives `req` and `doc` as arguments and returns a `boolean`. When `true`, the route and associated tab are rendered. Defaults to `true`. |
| `exact` | Boolean. When true, will only match if the path matches the `usePathname()` exactly. |
| `strict` | When true, a path that has a trailing slash will only match a `location.pathname` with a trailing slash. This has no effect when there are additional URL segments in the pathname. |
| `sensitive` | When true, will match if the path is case sensitive. |
Expand Down
35 changes: 35 additions & 0 deletions docs/custom-components/document-views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,38 @@ export function MyCustomTabComponent(props: DocumentTabClientProps) {
)
}
```

## Restricting Document Views

You can restrict access to specific Document Views by using the `views.edit.[key].condition` property in your [Collection Config](../configuration/collections) or [Global Config](../configuration/globals). This allows you to control which user roles can access specific views.

To restrict access, define a condition function that returns a `boolean`. This function receives both the `req` and the relevant `doc` as arguments.

If the condition returns `false`, the corresponding **view and its tab** will not be rendered or accessible to the user.

#### Example

```ts
import type { CollectionConfig } from 'payload'

export const MyCollection: CollectionConfig = {
slug: 'my-collection',
// ...
admin: {
// ...
components: {
views: {
edit: {
api: {
condition: ({ doc, req: { user } }) => {
return user?.roles?.includes('admin') ?? false
},
},
},
},
},
},
}
```

In this example, only users with the `admin` role can access the API View and its associated tab. This setup works for both Collection and Global Document Views.
34 changes: 29 additions & 5 deletions packages/next/src/elements/DocumentHeader/Tabs/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { I18n } from '@payloadcms/translations'
import type {
Data,
DocumentTabClientProps,
DocumentTabServerPropsOnly,
Payload,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
Expand All @@ -13,19 +15,21 @@ import React from 'react'

import { ShouldRenderTabs } from './ShouldRenderTabs.js'
import { DocumentTab } from './Tab/index.js'
import { getTabs } from './tabs/index.js'
import './index.scss'
import { getTabs } from './tabs/index.js'

const baseClass = 'doc-tabs'

export const DocumentTabs: React.FC<{
collectionConfig: SanitizedCollectionConfig
doc: Data
globalConfig: SanitizedGlobalConfig
i18n: I18n
payload: Payload
permissions: SanitizedPermissions
req?: PayloadRequest
}> = (props) => {
const { collectionConfig, globalConfig, i18n, payload, permissions } = props
const { collectionConfig, doc, globalConfig, i18n, payload, permissions, req } = props
const { config } = payload

const tabs = getTabs({
Expand All @@ -38,13 +42,33 @@ export const DocumentTabs: React.FC<{
<div className={baseClass}>
<div className={`${baseClass}__tabs-container`}>
<ul className={`${baseClass}__tabs`}>
{tabs?.map(({ tab, viewPath }, index) => {
{tabs?.map(({ name, tab, viewPath }, index) => {
const { condition } = tab || {}

const meetsCondition =
!condition || condition({ collectionConfig, config, globalConfig, permissions })

if (!meetsCondition) {
let viewConfig

if (collectionConfig) {
if (typeof collectionConfig?.admin?.components?.views?.edit === 'object') {
viewConfig = collectionConfig.admin.components.views.edit[name]
}
} else if (globalConfig) {
if (typeof globalConfig?.admin?.components?.views?.edit === 'object') {
viewConfig = globalConfig.admin.components.views.edit[name]
}
}

const { condition: viewCondition } = viewConfig || {}

const meetsViewCondition =
!viewCondition ||
viewCondition({
doc,
req,
})

if (!meetsCondition || !meetsViewCondition) {
return null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ export const getTabs = ({
}: {
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
}): { tab: DocumentTabConfig; viewPath: string }[] => {
}): { name: string; tab: DocumentTabConfig; viewPath: string }[] => {
const customViews =
collectionConfig?.admin?.components?.views?.edit ||
globalConfig?.admin?.components?.views?.edit ||
{}

return [
{
name: 'default',
tab: {
href: '',
label: ({ t }) => t('general:edit'),
Expand All @@ -29,6 +30,7 @@ export const getTabs = ({
viewPath: '/',
},
{
name: 'livePreview',
tab: {
condition: ({ collectionConfig, config, globalConfig }) => {
if (collectionConfig) {
Expand All @@ -55,6 +57,7 @@ export const getTabs = ({
viewPath: '/preview',
},
{
name: 'versions',
tab: {
condition: ({ collectionConfig, globalConfig, permissions }) =>
Boolean(
Expand All @@ -71,6 +74,7 @@ export const getTabs = ({
viewPath: '/versions',
},
{
name: 'api',
tab: {
condition: ({ collectionConfig, globalConfig }) =>
(collectionConfig && !collectionConfig?.admin?.hideAPIURL) ||
Expand Down
8 changes: 7 additions & 1 deletion packages/next/src/elements/DocumentHeader/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { I18n } from '@payloadcms/translations'
import type {
Data,
Payload,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
Expand All @@ -16,24 +18,28 @@ const baseClass = `doc-header`

export const DocumentHeader: React.FC<{
collectionConfig?: SanitizedCollectionConfig
doc: Data
globalConfig?: SanitizedGlobalConfig
hideTabs?: boolean
i18n: I18n
payload: Payload
permissions: SanitizedPermissions
req?: PayloadRequest
}> = (props) => {
const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props
const { collectionConfig, doc, globalConfig, hideTabs, i18n, payload, permissions, req } = props

return (
<Gutter className={baseClass}>
<RenderTitle className={`${baseClass}__title`} />
{!hideTabs && (
<DocumentTabs
collectionConfig={collectionConfig}
doc={doc}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
)}
</Gutter>
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/views/Account/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export async function Account({ initPageResult, params, searchParams }: AdminVie
<EditDepthProvider>
<DocumentHeader
collectionConfig={collectionConfig}
doc={data}
hideTabs
i18n={i18n}
payload={payload}
Expand Down
44 changes: 37 additions & 7 deletions packages/next/src/views/Document/getDocumentView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type {
Data,
PayloadComponent,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedCollectionPermission,
SanitizedConfig,
Expand All @@ -18,6 +20,7 @@ import { VersionView as DefaultVersionView } from '../Version/index.js'
import { VersionsView as DefaultVersionsView } from '../Versions/index.js'
import { getCustomViewByKey } from './getCustomViewByKey.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
import { getViewCondition } from './getViewCondition.js'

export type ViewFromConfig<TProps extends object> = {
Component?: React.FC<TProps>
Expand All @@ -27,14 +30,18 @@ export type ViewFromConfig<TProps extends object> = {
export const getDocumentView = ({
collectionConfig,
config,
doc,
docPermissions,
globalConfig,
req,
routeSegments,
}: {
collectionConfig?: SanitizedCollectionConfig
config: SanitizedConfig
doc: Data
docPermissions: SanitizedCollectionPermission | SanitizedGlobalPermission
globalConfig?: SanitizedGlobalConfig
req?: PayloadRequest
routeSegments: string[]
}): {
View: ViewToRender
Expand All @@ -52,6 +59,21 @@ export const getDocumentView = ({
(collectionConfig && collectionConfig?.admin?.components?.views) ||
(globalConfig && globalConfig?.admin?.components?.views)

const viewCondition = (viewKey: string): boolean => {
const passesCondition = getViewCondition({
name: viewKey,
collectionConfig,
doc,
globalConfig,
req,
})

if (passesCondition === true) {
return true
}
return false
}

if (!docPermissions?.read) {
throw new Error('not-found')
}
Expand Down Expand Up @@ -119,16 +141,19 @@ export const getDocumentView = ({
switch (segment4) {
// --> /collections/:collectionSlug/:id/api
case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) {
const passesCondition = viewCondition('api')
if (passesCondition && collectionConfig?.admin?.hideAPIURL !== true) {
View = getCustomViewByKey(views, 'api') || DefaultAPIView
}
break
}

case 'preview': {
// --> /collections/:collectionSlug/:id/preview

const passesCondition = viewCondition('preview')
if (
(collectionConfig && collectionConfig?.admin?.livePreview) ||
(passesCondition && collectionConfig && collectionConfig?.admin?.livePreview) ||
config?.admin?.livePreview?.collections?.includes(collectionConfig?.slug)
) {
View = getCustomViewByKey(views, 'livePreview') || DefaultLivePreviewView
Expand All @@ -138,7 +163,8 @@ export const getDocumentView = ({

case 'versions': {
// --> /collections/:collectionSlug/:id/versions
if (docPermissions?.readVersions) {
const passesCondition = viewCondition('versions')
if (passesCondition && docPermissions?.readVersions) {
View = getCustomViewByKey(views, 'versions') || DefaultVersionsView
} else {
View = UnauthorizedViewWithGutter
Expand Down Expand Up @@ -185,7 +211,8 @@ export const getDocumentView = ({
default: {
// --> /collections/:collectionSlug/:id/versions/:version
if (segment4 === 'versions') {
if (docPermissions?.readVersions) {
const passesCondition = viewCondition('versions')
if (passesCondition && docPermissions?.readVersions) {
View = getCustomViewByKey(views, 'version') || DefaultVersionView
} else {
View = UnauthorizedViewWithGutter
Expand Down Expand Up @@ -240,7 +267,8 @@ export const getDocumentView = ({
switch (segment3) {
// --> /globals/:globalSlug/api
case 'api': {
if (globalConfig?.admin?.hideAPIURL !== true) {
const passesCondition = viewCondition('api')
if (passesCondition && globalConfig?.admin?.hideAPIURL !== true) {
View = getCustomViewByKey(views, 'api') || DefaultAPIView
}

Expand All @@ -249,8 +277,9 @@ export const getDocumentView = ({

case 'preview': {
// --> /globals/:globalSlug/preview
const passesCondition = viewCondition('preview')
if (
(globalConfig && globalConfig?.admin?.livePreview) ||
(passesCondition && globalConfig && globalConfig?.admin?.livePreview) ||
config?.admin?.livePreview?.globals?.includes(globalConfig?.slug)
) {
View = getCustomViewByKey(views, 'livePreview') || DefaultLivePreviewView
Expand All @@ -261,7 +290,8 @@ export const getDocumentView = ({

case 'versions': {
// --> /globals/:globalSlug/versions
if (docPermissions?.readVersions) {
const passesCondition = viewCondition('versions')
if (passesCondition && docPermissions?.readVersions) {
View = getCustomViewByKey(views, 'versions') || DefaultVersionsView
} else {
View = UnauthorizedViewWithGutter
Expand Down
11 changes: 10 additions & 1 deletion packages/next/src/views/Document/getMetaBySegment.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Metadata } from 'next'
import type { EditConfig, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
import type {
EditConfig,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
} from 'payload'

import type { GenerateViewMetadata } from '../Root/index.js'

Expand All @@ -16,6 +21,7 @@ export type GenerateEditViewMetadata = (
args: {
collectionConfig?: null | SanitizedCollectionConfig
globalConfig?: null | SanitizedGlobalConfig
req?: PayloadRequest
view?: keyof EditConfig
} & Parameters<GenerateViewMetadata>[0],
) => Promise<Metadata>
Expand All @@ -25,6 +31,7 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
config,
globalConfig,
params,
req,
}) => {
const { segments } = params

Expand Down Expand Up @@ -124,6 +131,7 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
const { viewKey } = getDocumentView({
collectionConfig,
config,
doc: {},
docPermissions: {
create: true,
delete: true,
Expand All @@ -133,6 +141,7 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
update: true,
},
globalConfig,
req,
routeSegments: typeof segments === 'string' ? [segments] : segments,
})

Expand Down
Loading
Loading