feat(ui): save collection folder tab preferences (#13702)

Saves folder preferences when navigating from list to folder view. This
is a UX improvement so users don't need to click by-folder every time
they go back to the list view.
This commit is contained in:
Jarrod Flesch
2025-09-09 11:43:00 -04:00
committed by GitHub
parent 1293019825
commit 6a0637ecb2
23 changed files with 576 additions and 708 deletions

View File

@@ -0,0 +1,9 @@
export const getRouteWithoutAdmin = ({
adminRoute,
route,
}: {
adminRoute: string
route: string
}): string => {
return adminRoute && adminRoute !== '/' ? route.replace(adminRoute, '') : route
}

View File

@@ -0,0 +1,18 @@
import type { PayloadRequest, VisibleEntities } from 'payload'
import { isEntityHidden } from 'payload'
export function getVisibleEntities({ req }: { req: PayloadRequest }): VisibleEntities {
return {
collections: req.payload.config.collections
.map(({ slug, admin: { hidden } }) =>
!isEntityHidden({ hidden, user: req.user }) ? slug : null,
)
.filter(Boolean),
globals: req.payload.config.globals
.map(({ slug, admin: { hidden } }) =>
!isEntityHidden({ hidden, user: req.user }) ? slug : null,
)
.filter(Boolean),
}
}

View File

@@ -1,81 +0,0 @@
import type {
Payload,
SanitizedCollectionConfig,
SanitizedConfig,
SanitizedGlobalConfig,
} from 'payload'
import { getRouteWithoutAdmin, isAdminRoute } from './shared.js'
type Args = {
adminRoute: string
config: SanitizedConfig
defaultIDType: Payload['db']['defaultIDType']
payload?: Payload
route: string
}
type RouteInfo = {
collectionConfig?: SanitizedCollectionConfig
collectionSlug?: string
docID?: number | string
globalConfig?: SanitizedGlobalConfig
globalSlug?: string
}
export function getRouteInfo({
adminRoute,
config,
defaultIDType,
payload,
route,
}: Args): RouteInfo {
if (isAdminRoute({ adminRoute, config, route })) {
const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route })
const routeSegments = routeWithoutAdmin.split('/').filter(Boolean)
const [entityType, entitySlug, segment3, segment4] = routeSegments
const collectionSlug = entityType === 'collections' ? entitySlug : undefined
const globalSlug = entityType === 'globals' ? entitySlug : undefined
let collectionConfig: SanitizedCollectionConfig | undefined
let globalConfig: SanitizedGlobalConfig | undefined
let idType = defaultIDType
if (collectionSlug) {
collectionConfig = payload.collections?.[collectionSlug]?.config
}
if (globalSlug) {
globalConfig = config.globals.find((global) => global.slug === globalSlug)
}
// If the collection is using a custom ID, we need to determine its type
if (collectionConfig && payload) {
if (payload.collections?.[collectionSlug]?.customIDType) {
idType = payload.collections?.[collectionSlug].customIDType
}
}
let docID: number | string | undefined
if (collectionSlug) {
if (segment3 === 'trash' && segment4) {
// /collections/:slug/trash/:id
docID = idType === 'number' ? Number(segment4) : segment4
} else if (segment3 && segment3 !== 'create') {
// /collections/:slug/:id
docID = idType === 'number' ? Number(segment3) : segment3
}
}
return {
collectionConfig,
collectionSlug,
docID,
globalConfig,
globalSlug,
}
}
return {}
}

View File

@@ -1,121 +0,0 @@
import type { InitPageResult, VisibleEntities } from 'payload'
import { notFound } from 'next/navigation.js'
import { isEntityHidden } from 'payload'
import * as qs from 'qs-esm'
import type { Args } from './types.js'
import { initReq } from '../initReq.js'
import { getRouteInfo } from './handleAdminPage.js'
import { handleAuthRedirect } from './handleAuthRedirect.js'
import { isCustomAdminView } from './isCustomAdminView.js'
import { isPublicAdminRoute } from './shared.js'
export const initPage = async ({
config: configPromise,
importMap,
route,
routeParams = {},
searchParams,
useLayoutReq,
}: Args): Promise<InitPageResult> => {
const queryString = `${qs.stringify(searchParams ?? {}, { addQueryPrefix: true })}`
const {
cookies,
locale,
permissions,
req,
req: { payload },
} = await initReq({
configPromise,
importMap,
key: useLayoutReq ? 'RootLayout' : 'initPage',
overrides: {
fallbackLocale: false,
req: {
query: qs.parse(queryString, {
depth: 10,
ignoreQueryPrefix: true,
}),
routeParams,
},
urlSuffix: `${route}${searchParams ? queryString : ''}`,
},
})
const {
collections,
globals,
routes: { admin: adminRoute },
} = payload.config
const languageOptions = Object.entries(payload.config.i18n.supportedLanguages || {}).reduce(
(acc, [language, languageConfig]) => {
if (Object.keys(payload.config.i18n.supportedLanguages).includes(language)) {
acc.push({
label: languageConfig.translations.general.thisLanguage,
value: language,
})
}
return acc
},
[],
)
const visibleEntities: VisibleEntities = {
collections: collections
.map(({ slug, admin: { hidden } }) =>
!isEntityHidden({ hidden, user: req.user }) ? slug : null,
)
.filter(Boolean),
globals: globals
.map(({ slug, admin: { hidden } }) =>
!isEntityHidden({ hidden, user: req.user }) ? slug : null,
)
.filter(Boolean),
}
let redirectTo = null
if (
!permissions.canAccessAdmin &&
!isPublicAdminRoute({ adminRoute, config: payload.config, route }) &&
!isCustomAdminView({ adminRoute, config: payload.config, route })
) {
redirectTo = handleAuthRedirect({
config: payload.config,
route,
searchParams,
user: req.user,
})
}
const { collectionConfig, collectionSlug, docID, globalConfig, globalSlug } = getRouteInfo({
adminRoute,
config: payload.config,
defaultIDType: payload.db.defaultIDType,
payload,
route,
})
if ((collectionSlug && !collectionConfig) || (globalSlug && !globalConfig)) {
return notFound()
}
return {
collectionConfig,
cookies,
docID,
globalConfig,
languageOptions,
locale,
permissions,
redirectTo,
req,
translations: req.i18n.translations,
visibleEntities,
}
}

View File

@@ -1,40 +0,0 @@
import type { ImportMap, SanitizedConfig } from 'payload'
export type Args = {
/**
* Your sanitized Payload config.
* If unresolved, this function will await the promise.
*/
config: Promise<SanitizedConfig> | SanitizedConfig
importMap: ImportMap
/**
* If true, redirects unauthenticated users to the admin login page.
* If a string is provided, the user will be redirected to that specific URL.
*/
redirectUnauthenticatedUser?: boolean | string
/**
* The current route, i.e. `/admin/collections/posts`.
*/
route: string
/**
* The route parameters of the current route
*
* @example `{ collection: 'posts', id: "post-id" }`.
*/
routeParams?: { [key: string]: string }
/**
* The search parameters of the current route provided to all pages in Next.js.
*/
searchParams: { [key: string]: string | string[] | undefined }
/**
* If `useLayoutReq` is `true`, this page will use the cached `req` created by the root layout
* instead of creating a new one.
*
* This improves performance for pages that are able to share the same `req` as the root layout,
* as permissions do not need to be re-calculated.
*
* If the page has unique query and url params that need to be part of the `req` object, or if you
* need permissions calculation to respect those you should not use this property.
*/
useLayoutReq?: boolean
}

View File

@@ -1,6 +1,6 @@
import type { AdminViewConfig, PayloadRequest, SanitizedConfig } from 'payload'
import type { SanitizedConfig } from 'payload'
import { getRouteWithoutAdmin } from './shared.js'
import { getRouteWithoutAdmin } from './getRouteWithoutAdmin.js'
/**
* Returns an array of views marked with 'public: true' in the config

View File

@@ -1,5 +1,7 @@
import type { SanitizedConfig } from 'payload'
import { getRouteWithoutAdmin } from './getRouteWithoutAdmin.js'
// Routes that require admin authentication
const publicAdminRoutes: (keyof Pick<
SanitizedConfig['admin']['routes'],
@@ -15,17 +17,6 @@ const publicAdminRoutes: (keyof Pick<
'reset',
]
export const isAdminRoute = ({
adminRoute,
route,
}: {
adminRoute: string
config: SanitizedConfig
route: string
}): boolean => {
return route.startsWith(adminRoute)
}
export const isPublicAdminRoute = ({
adminRoute,
config,
@@ -50,13 +41,3 @@ export const isPublicAdminRoute = ({
return isPublicAdminRoute
}
export const getRouteWithoutAdmin = ({
adminRoute,
route,
}: {
adminRoute: string
route: string
}): string => {
return adminRoute && adminRoute !== '/' ? route.replace(adminRoute, '') : route
}

View File

@@ -1,13 +1,14 @@
import type { I18n } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type { AdminViewServerProps, ImportMap, SanitizedConfig } from 'payload'
import { formatAdminURL } from 'payload/shared'
import * as qs from 'qs-esm'
import React from 'react'
import { DefaultTemplate } from '../../templates/Default/index.js'
import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
import { initPage } from '../../utilities/initPage/index.js'
import { getVisibleEntities } from '../../utilities/getVisibleEntities.js'
import { initReq } from '../../utilities/initReq.js'
import { NotFoundClient } from './index.client.js'
export const generateNotFoundViewMetadata = async ({
@@ -27,12 +28,6 @@ export const generateNotFoundViewMetadata = async ({
}
}
export type GenerateViewMetadata = (args: {
config: SanitizedConfig
i18n: I18n
params?: { [key: string]: string | string[] }
}) => Promise<Metadata>
export const NotFoundPage = async ({
config: configPromise,
importMap,
@@ -52,31 +47,45 @@ export const NotFoundPage = async ({
const { routes: { admin: adminRoute } = {} } = config
const searchParams = await searchParamsPromise
const initPageResult = await initPage({
config,
const queryString = `${qs.stringify(searchParams ?? {}, { addQueryPrefix: true })}`
const {
locale,
permissions,
req,
req: { payload },
} = await initReq({
configPromise: config,
importMap,
redirectUnauthenticatedUser: true,
route: formatAdminURL({ adminRoute, path: '/not-found' }),
searchParams,
useLayoutReq: true,
key: 'RootLayout',
overrides: {
fallbackLocale: false,
req: {
query: qs.parse(queryString, {
depth: 10,
ignoreQueryPrefix: true,
}),
},
urlSuffix: `${formatAdminURL({ adminRoute, path: '/not-found' })}${searchParams ? queryString : ''}`,
},
})
const params = await paramsPromise
if (!initPageResult.req.user || !initPageResult.permissions.canAccessAdmin) {
if (!req.user || !permissions.canAccessAdmin) {
return <NotFoundClient />
}
const params = await paramsPromise
const visibleEntities = getVisibleEntities({ req })
return (
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
i18n={req.i18n}
locale={locale}
params={params}
payload={initPageResult.req.payload}
permissions={initPageResult.permissions}
payload={payload}
permissions={permissions}
searchParams={searchParams}
user={initPageResult.req.user}
visibleEntities={initPageResult.visibleEntities}
user={req.user}
visibleEntities={visibleEntities}
>
<NotFoundClient />
</DefaultTemplate>

View File

@@ -3,7 +3,6 @@ import type {
EditConfig,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
ServerPropsFromView,
} from 'payload'
export function getViewActions({
@@ -12,23 +11,21 @@ export function getViewActions({
}: {
editConfig: EditConfig
viewKey: keyof EditConfig
}): CustomComponent[] | undefined {
}): CustomComponent[] {
if (editConfig && viewKey in editConfig && 'actions' in editConfig[viewKey]) {
return editConfig[viewKey].actions
return editConfig[viewKey].actions ?? []
}
return undefined
return []
}
export function attachViewActions({
export function getSubViewActions({
collectionOrGlobal,
serverProps,
viewKeyArg,
}: {
collectionOrGlobal: SanitizedCollectionConfig | SanitizedGlobalConfig
serverProps: ServerPropsFromView
viewKeyArg?: keyof EditConfig
}) {
}): CustomComponent[] {
if (collectionOrGlobal?.admin?.components?.views?.edit) {
let viewKey = viewKeyArg || 'default'
if ('root' in collectionOrGlobal.admin.components.views.edit) {
@@ -40,8 +37,8 @@ export function attachViewActions({
viewKey,
})
if (actions) {
serverProps.viewActions = serverProps.viewActions.concat(actions)
}
return actions
}
return []
}

View File

@@ -1,18 +1,20 @@
import type {
AdminViewServerProps,
CollectionPreferences,
CollectionSlug,
CustomComponent,
DocumentSubViewTypes,
ImportMap,
Payload,
PayloadComponent,
SanitizedCollectionConfig,
SanitizedConfig,
ServerPropsFromView,
SanitizedGlobalConfig,
ViewTypes,
} from 'payload'
import type React from 'react'
import { formatAdminURL } from 'payload/shared'
import type { initPage } from '../../utilities/initPage/index.js'
import { parseDocumentID } from 'payload'
import { formatAdminURL, isNumber } from 'payload/shared'
import { Account } from '../Account/index.js'
import { BrowseByFolder } from '../BrowseByFolder/index.js'
@@ -28,7 +30,7 @@ import { LogoutInactivity, LogoutView } from '../Logout/index.js'
import { ResetPassword, resetPasswordBaseClass } from '../ResetPassword/index.js'
import { UnauthorizedView } from '../Unauthorized/index.js'
import { Verify, verifyBaseClass } from '../Verify/index.js'
import { attachViewActions, getViewActions } from './attachViewActions.js'
import { getSubViewActions, getViewActions } from './attachViewActions.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
import { getDocumentViewInfo } from './getDocumentViewInfo.js'
import { isPathMatchingRoute } from './isPathMatchingRoute.js'
@@ -62,59 +64,66 @@ const oneSegmentViews: OneSegmentViews = {
unauthorized: UnauthorizedView,
}
type GetRouteDataResult = {
browseByFolderSlugs: CollectionSlug[]
collectionConfig?: SanitizedCollectionConfig
DefaultView: ViewFromConfig
documentSubViewType?: DocumentSubViewTypes
globalConfig?: SanitizedGlobalConfig
routeParams: {
collection?: string
folderCollection?: string
folderID?: number | string
global?: string
id?: number | string
token?: string
versionID?: number | string
}
templateClassName: string
templateType: 'default' | 'minimal'
viewActions?: CustomComponent[]
viewType?: ViewTypes
}
type GetRouteDataArgs = {
adminRoute: string
config: SanitizedConfig
collectionConfig?: SanitizedCollectionConfig
/**
* User preferences for a collection.
*
* These preferences are normally undefined
* unless the user is on the list view and the
* collection is folder enabled.
*/
collectionPreferences?: CollectionPreferences
currentRoute: string
importMap: ImportMap
globalConfig?: SanitizedGlobalConfig
payload: Payload
searchParams: {
[key: string]: string | string[]
}
segments: string[]
}
type GetRouteDataResult = {
browseByFolderSlugs: CollectionSlug[]
DefaultView: ViewFromConfig
documentSubViewType?: DocumentSubViewTypes
folderID?: string
initPageOptions: Parameters<typeof initPage>[0]
serverProps: ServerPropsFromView
templateClassName: string
templateType: 'default' | 'minimal'
viewType?: ViewTypes
}
export const getRouteData = ({
adminRoute,
config,
collectionConfig,
collectionPreferences = undefined,
currentRoute,
importMap,
searchParams,
globalConfig,
payload,
segments,
}: GetRouteDataArgs): GetRouteDataResult => {
const { config } = payload
let ViewToRender: ViewFromConfig = null
let templateClassName: string
let templateType: 'default' | 'minimal' | undefined
let documentSubViewType: DocumentSubViewTypes
let viewType: ViewTypes
let folderID: string
const initPageOptions: Parameters<typeof initPage>[0] = {
config,
importMap,
route: currentRoute,
routeParams: {},
searchParams,
}
const routeParams: GetRouteDataResult['routeParams'] = {}
const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive, segmentSix] = segments
const isGlobal = segmentOne === 'globals'
const isCollection = segmentOne === 'collections'
let matchedCollection: SanitizedConfig['collections'][number] = undefined
let matchedGlobal: SanitizedConfig['globals'][number] = undefined
const isBrowseByFolderEnabled = config.folders && config.folders.browseByFolder
const browseByFolderSlugs =
(isBrowseByFolderEnabled &&
@@ -126,19 +135,7 @@ export const getRouteData = ({
}, [])) ||
[]
const serverProps: ServerPropsFromView = {
viewActions: config?.admin?.components?.actions || [],
}
if (isCollection) {
matchedCollection = config.collections.find(({ slug }) => slug === segmentTwo)
serverProps.collectionConfig = matchedCollection
}
if (isGlobal) {
matchedGlobal = config.globals.find(({ slug }) => slug === segmentTwo)
serverProps.globalConfig = matchedGlobal
}
const viewActions: CustomComponent[] = [...(config?.admin?.components?.actions || [])]
switch (segments.length) {
case 0: {
@@ -215,7 +212,7 @@ export const getRouteData = ({
`/${segmentOne}` === config.admin.routes.browseByFolder
) {
// --> /browse-by-folder/:folderID
initPageOptions.routeParams.folderID = folderID
routeParams.folderID = segmentTwo
ViewToRender = {
Component: oneSegmentViews.browseByFolder,
@@ -223,11 +220,23 @@ export const getRouteData = ({
templateClassName = baseClasses.folders
templateType = 'default'
viewType = 'folders'
folderID = segmentTwo
} else if (isCollection && matchedCollection) {
// --> /collections/:collectionSlug
initPageOptions.routeParams.collection = matchedCollection.slug
} else if (collectionConfig) {
// --> /collections/:collectionSlug'
routeParams.collection = collectionConfig.slug
if (
collectionPreferences?.listViewType &&
collectionPreferences.listViewType === 'folders'
) {
// Render folder view by default if set in preferences
ViewToRender = {
Component: CollectionFolderView,
}
templateClassName = `collection-folders`
templateType = 'default'
viewType = 'collection-folders'
} else {
ViewToRender = {
Component: ListView,
}
@@ -235,12 +244,12 @@ export const getRouteData = ({
templateClassName = `${segmentTwo}-list`
templateType = 'default'
viewType = 'list'
serverProps.viewActions = serverProps.viewActions.concat(
matchedCollection.admin.components?.views?.list?.actions,
)
} else if (isGlobal && matchedGlobal) {
}
viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || []))
} else if (globalConfig) {
// --> /globals/:globalSlug
initPageOptions.routeParams.global = matchedGlobal.slug
routeParams.global = globalConfig.slug
ViewToRender = {
Component: DocumentView,
@@ -251,9 +260,9 @@ export const getRouteData = ({
viewType = 'document'
// add default view actions
serverProps.viewActions = serverProps.viewActions.concat(
getViewActions({
editConfig: matchedGlobal.admin?.components?.views?.edit,
viewActions.push(
...getViewActions({
editConfig: globalConfig.admin?.components?.views?.edit,
viewKey: 'default',
}),
)
@@ -263,7 +272,8 @@ export const getRouteData = ({
default:
if (segmentTwo === 'verify') {
// --> /:collectionSlug/verify/:token
initPageOptions.routeParams.collection = segmentOne
routeParams.collection = segmentOne
routeParams.token = segmentThree
ViewToRender = {
Component: Verify,
@@ -272,8 +282,8 @@ export const getRouteData = ({
templateClassName = 'verify'
templateType = 'minimal'
viewType = 'verify'
} else if (isCollection && matchedCollection) {
initPageOptions.routeParams.collection = matchedCollection.slug
} else if (collectionConfig) {
routeParams.collection = collectionConfig.slug
if (segmentThree === 'trash' && typeof segmentFour === 'string') {
// --> /collections/:collectionSlug/trash/:id (read-only)
@@ -281,8 +291,8 @@ export const getRouteData = ({
// --> /collections/:collectionSlug/trash/:id/preview
// --> /collections/:collectionSlug/trash/:id/versions
// --> /collections/:collectionSlug/trash/:id/versions/:versionID
initPageOptions.routeParams.id = segmentFour
initPageOptions.routeParams.versionID = segmentSix
routeParams.id = segmentFour
routeParams.versionID = segmentSix
ViewToRender = {
Component: DocumentView,
@@ -295,11 +305,12 @@ export const getRouteData = ({
viewType = viewInfo.viewType
documentSubViewType = viewInfo.documentSubViewType
attachViewActions({
collectionOrGlobal: matchedCollection,
serverProps,
viewActions.push(
...getSubViewActions({
collectionOrGlobal: collectionConfig,
viewKeyArg: documentSubViewType,
})
}),
)
} else if (segmentThree === 'trash') {
// --> /collections/:collectionSlug/trash
ViewToRender = {
@@ -310,19 +321,14 @@ export const getRouteData = ({
templateType = 'default'
viewType = 'trash'
serverProps.viewActions = serverProps.viewActions.concat(
matchedCollection.admin.components?.views?.list?.actions ?? [],
)
} else if (
config.folders &&
segmentThree === config.folders.slug &&
matchedCollection.folders
) {
viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || []))
} else {
if (config.folders && segmentThree === config.folders.slug && collectionConfig.folders) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
// --> /collections/:collectionSlug/:folderCollectionSlug/:folderID
initPageOptions.routeParams.folderCollection = segmentThree
initPageOptions.routeParams.folderID = segmentFour
routeParams.folderCollection = segmentThree
routeParams.folderID = segmentFour
ViewToRender = {
Component: CollectionFolderView,
@@ -331,15 +337,17 @@ export const getRouteData = ({
templateClassName = `collection-folders`
templateType = 'default'
viewType = 'collection-folders'
folderID = segmentFour
viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || []))
} else {
// Collection Edit Views
// --> /collections/:collectionSlug/create
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:versionID
initPageOptions.routeParams.id = segmentThree
initPageOptions.routeParams.versionID = segmentFive
routeParams.id = segmentThree === 'create' ? undefined : segmentThree
routeParams.versionID = segmentFive
ViewToRender = {
Component: DocumentView,
@@ -352,19 +360,21 @@ export const getRouteData = ({
viewType = viewInfo.viewType
documentSubViewType = viewInfo.documentSubViewType
attachViewActions({
collectionOrGlobal: matchedCollection,
serverProps,
viewActions.push(
...getSubViewActions({
collectionOrGlobal: collectionConfig,
viewKeyArg: documentSubViewType,
})
}),
)
}
} else if (isGlobal && matchedGlobal) {
}
} else if (globalConfig) {
// Global Edit Views
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/versions/:versionID
// --> /globals/:globalSlug/api
initPageOptions.routeParams.global = matchedGlobal.slug
initPageOptions.routeParams.versionID = segmentFour
routeParams.global = globalConfig.slug
routeParams.versionID = segmentFour
ViewToRender = {
Component: DocumentView,
@@ -377,11 +387,12 @@ export const getRouteData = ({
viewType = viewInfo.viewType
documentSubViewType = viewInfo.documentSubViewType
attachViewActions({
collectionOrGlobal: matchedGlobal,
serverProps,
viewActions.push(
...getSubViewActions({
collectionOrGlobal: globalConfig,
viewKeyArg: documentSubViewType,
})
}),
)
}
break
}
@@ -390,17 +401,53 @@ export const getRouteData = ({
ViewToRender = getCustomViewByRoute({ config, currentRoute })?.view
}
serverProps.viewActions.reverse()
if (collectionConfig) {
if (routeParams.id) {
routeParams.id = parseDocumentID({
id: routeParams.id,
collectionSlug: collectionConfig.slug,
payload,
})
}
if (routeParams.versionID) {
routeParams.versionID = parseDocumentID({
id: routeParams.versionID,
collectionSlug: collectionConfig.slug,
payload,
})
}
}
if (config.folders && routeParams.folderID) {
routeParams.folderID = parseDocumentID({
id: routeParams.folderID,
collectionSlug: config.folders.slug,
payload,
})
}
if (globalConfig && routeParams.versionID) {
routeParams.versionID =
payload.db.defaultIDType === 'number' && isNumber(routeParams.versionID)
? Number(routeParams.versionID)
: routeParams.versionID
}
if (viewActions.length) {
viewActions.reverse()
}
return {
browseByFolderSlugs,
collectionConfig,
DefaultView: ViewToRender,
documentSubViewType,
folderID,
initPageOptions,
serverProps,
globalConfig,
routeParams,
templateClassName,
templateType,
viewActions: viewActions.length ? viewActions : undefined,
viewType,
}
}

View File

@@ -1,22 +1,30 @@
import type { I18nClient } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type {
AdminViewClientProps,
AdminViewServerPropsOnly,
CollectionPreferences,
ImportMap,
SanitizedCollectionConfig,
SanitizedConfig,
SanitizedGlobalConfig,
} from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { notFound, redirect } from 'next/navigation.js'
import {
type AdminViewClientProps,
type AdminViewServerPropsOnly,
type ImportMap,
parseDocumentID,
type SanitizedConfig,
} from 'payload'
import { formatAdminURL } from 'payload/shared'
import * as qs from 'qs-esm'
import React from 'react'
import { DefaultTemplate } from '../../templates/Default/index.js'
import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { initPage } from '../../utilities/initPage/index.js'
import { getPreferences } from '../../utilities/getPreferences.js'
import { getVisibleEntities } from '../../utilities/getVisibleEntities.js'
import { handleAuthRedirect } from '../../utilities/handleAuthRedirect.js'
import { initReq } from '../../utilities/initReq.js'
import { isCustomAdminView } from '../../utilities/isCustomAdminView.js'
import { isPublicAdminRoute } from '../../utilities/isPublicAdminRoute.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
import { getRouteData } from './getRouteData.js'
import { SyncClientConfig } from './SyncClientConfig.js'
@@ -61,11 +69,16 @@ export const RootPage = async ({
})
const segments = Array.isArray(params.segments) ? params.segments : []
const isCollectionRoute = segments[0] === 'collections'
const isGlobalRoute = segments[0] === 'globals'
let collectionConfig: SanitizedCollectionConfig = undefined
let globalConfig: SanitizedGlobalConfig = undefined
const searchParams = await searchParamsPromise
// Redirect `${adminRoute}/collections` to `${adminRoute}`
if (segments.length === 1 && segments[0] === 'collections') {
if (isCollectionRoute) {
if (segments.length === 1) {
const { viewKey } = getCustomViewByRoute({
config,
currentRoute: '/collections',
@@ -77,8 +90,14 @@ export const RootPage = async ({
}
}
if (segments[1]) {
collectionConfig = config.collections.find(({ slug }) => slug === segments[1])
}
}
// Redirect `${adminRoute}/globals` to `${adminRoute}`
if (segments.length === 1 && segments[0] === 'globals') {
if (isGlobalRoute) {
if (segments.length === 1) {
const { viewKey } = getCustomViewByRoute({
config,
currentRoute: '/globals',
@@ -90,33 +109,96 @@ export const RootPage = async ({
}
}
if (segments[1]) {
globalConfig = config.globals.find(({ slug }) => slug === segments[1])
}
}
if ((isCollectionRoute && !collectionConfig) || (isGlobalRoute && !globalConfig)) {
return notFound()
}
const queryString = `${qs.stringify(searchParams ?? {}, { addQueryPrefix: true })}`
const {
cookies,
locale,
permissions,
req,
req: { payload },
} = await initReq({
configPromise: config,
importMap,
key: 'initPage',
overrides: {
fallbackLocale: false,
req: {
query: qs.parse(queryString, {
depth: 10,
ignoreQueryPrefix: true,
}),
},
urlSuffix: `${currentRoute}${searchParams ? queryString : ''}`,
},
})
if (
!permissions.canAccessAdmin &&
!isPublicAdminRoute({ adminRoute, config: payload.config, route: currentRoute }) &&
!isCustomAdminView({ adminRoute, config: payload.config, route: currentRoute })
) {
redirect(
handleAuthRedirect({
config: payload.config,
route: currentRoute,
searchParams,
user: req.user,
}),
)
}
let collectionPreferences: CollectionPreferences = undefined
if (collectionConfig && segments.length === 2) {
if (config.folders && collectionConfig.folders && segments[1] !== config.folders.slug) {
await getPreferences<CollectionPreferences>(
`collection-${collectionConfig.slug}`,
req.payload,
req.user.id,
config.admin.user,
).then((res) => {
collectionPreferences = res.value
})
}
}
const {
browseByFolderSlugs,
DefaultView,
documentSubViewType,
folderID: folderIDParam,
initPageOptions,
serverProps,
routeParams,
templateClassName,
templateType,
viewActions,
viewType,
} = getRouteData({
adminRoute,
config,
collectionConfig,
collectionPreferences,
currentRoute,
importMap,
globalConfig,
payload,
searchParams,
segments,
})
const initPageResult = await initPage(initPageOptions)
req.routeParams = routeParams
const dbHasUser =
initPageResult.req.user ||
(await initPageResult?.req.payload.db
req.user ||
(await req.payload.db
.findOne({
collection: userSlug,
req: initPageResult?.req,
req,
})
?.then((doc) => !!doc))
@@ -125,7 +207,7 @@ export const RootPage = async ({
* The current route did not match any default views or custom route views.
*/
if (!DefaultView?.Component && !DefaultView?.payloadComponent) {
if (initPageResult?.req?.user) {
if (req?.user) {
notFound()
}
@@ -134,15 +216,10 @@ export const RootPage = async ({
}
}
if (typeof initPageResult?.redirectTo === 'string') {
redirect(initPageResult.redirectTo)
}
if (initPageResult) {
const createFirstUserRoute = formatAdminURL({ adminRoute, path: _createFirstUserRoute })
const collectionConfig = config.collections.find(({ slug }) => slug === userSlug)
const disableLocalStrategy = collectionConfig?.auth?.disableLocalStrategy
const usersCollection = config.collections.find(({ slug }) => slug === userSlug)
const disableLocalStrategy = usersCollection?.auth?.disableLocalStrategy
if (disableLocalStrategy && currentRoute === createFirstUserRoute) {
redirect(adminRoute)
@@ -155,7 +232,6 @@ export const RootPage = async ({
if (dbHasUser && currentRoute === createFirstUserRoute) {
redirect(adminRoute)
}
}
if (!DefaultView?.Component && !DefaultView?.payloadComponent && !dbHasUser) {
redirect(adminRoute)
@@ -163,19 +239,14 @@ export const RootPage = async ({
const clientConfig = getClientConfig({
config,
i18n: initPageResult?.req.i18n,
i18n: req.i18n,
importMap,
user: viewType === 'createFirstUser' ? true : initPageResult?.req.user,
user: viewType === 'createFirstUser' ? true : req.user,
})
const payload = initPageResult?.req.payload
const folderID = payload.config.folders
? parseDocumentID({
id: folderIDParam,
collectionSlug: payload.config.folders.slug,
payload,
})
: undefined
const visibleEntities = getVisibleEntities({ req })
const folderID = routeParams.folderID
const RenderedView = RenderServerComponent({
clientProps: {
@@ -188,16 +259,41 @@ export const RootPage = async ({
Fallback: DefaultView.Component,
importMap,
serverProps: {
...serverProps,
clientConfig,
docID: initPageResult?.docID,
collectionConfig,
docID: routeParams.id,
folderID,
i18n: initPageResult?.req.i18n,
globalConfig,
i18n: req.i18n,
importMap,
initPageResult,
initPageResult: {
collectionConfig,
cookies,
docID: routeParams.id,
globalConfig,
languageOptions: Object.entries(req.payload.config.i18n.supportedLanguages || {}).reduce(
(acc, [language, languageConfig]) => {
if (Object.keys(req.payload.config.i18n.supportedLanguages).includes(language)) {
acc.push({
label: languageConfig.translations.general.thisLanguage,
value: language,
})
}
return acc
},
[],
),
locale,
permissions,
req,
translations: req.i18n.translations,
visibleEntities,
},
params,
payload: initPageResult?.req.payload,
payload: req.payload,
searchParams,
viewActions,
} satisfies AdminViewServerPropsOnly,
})
@@ -210,25 +306,25 @@ export const RootPage = async ({
)}
{templateType === 'default' && (
<DefaultTemplate
collectionSlug={initPageResult?.collectionConfig?.slug}
docID={initPageResult?.docID}
collectionSlug={collectionConfig?.slug}
docID={routeParams.id}
documentSubViewType={documentSubViewType}
globalSlug={initPageResult?.globalConfig?.slug}
i18n={initPageResult?.req.i18n}
locale={initPageResult?.locale}
globalSlug={globalConfig?.slug}
i18n={req.i18n}
locale={locale}
params={params}
payload={initPageResult?.req.payload}
permissions={initPageResult?.permissions}
req={initPageResult?.req}
payload={req.payload}
permissions={permissions}
req={req}
searchParams={searchParams}
user={initPageResult?.req.user}
viewActions={serverProps.viewActions}
user={req.user}
viewActions={viewActions}
viewType={viewType}
visibleEntities={{
// The reason we are not passing in initPageResult.visibleEntities directly is due to a "Cannot assign to read only property of object '#<Object>" error introduced in React 19
// which this caused as soon as initPageResult.visibleEntities is passed in
collections: initPageResult?.visibleEntities?.collections,
globals: initPageResult?.visibleEntities?.globals,
collections: visibleEntities?.collections,
globals: visibleEntities?.globals,
}}
>
{RenderedView}

View File

@@ -40,12 +40,14 @@ export type AdminViewClientProps = {
export type AdminViewServerPropsOnly = {
readonly clientConfig: ClientConfig
readonly collectionConfig?: SanitizedCollectionConfig
readonly disableActions?: boolean
/**
* @todo remove `docID` here as it is already contained in `initPageResult`
*/
readonly docID?: number | string
readonly folderID?: number | string
readonly globalConfig?: SanitizedGlobalConfig
readonly importMap: ImportMap
readonly initialData?: Data
readonly initPageResult: InitPageResult
@@ -54,6 +56,7 @@ export type AdminViewServerPropsOnly = {
readonly redirectAfterDelete?: boolean
readonly redirectAfterDuplicate?: boolean
readonly redirectAfterRestore?: boolean
readonly viewActions?: CustomComponent[]
} & ServerProps
export type AdminViewServerProps = AdminViewClientProps & AdminViewServerPropsOnly

View File

@@ -39,6 +39,7 @@ export type CollectionPreferences = {
editViewType?: 'default' | 'live-preview'
groupBy?: string
limit?: number
listViewType?: 'folders' | 'list'
preset?: DefaultDocumentIDType
sort?: string
}

View File

@@ -64,6 +64,7 @@
&__account {
position: relative;
flex-shrink: 0;
&:focus:not(:focus-visible) {
opacity: 1;

View File

@@ -1,5 +1,5 @@
@layer payload-default {
.list-pills {
.default-list-view-tabs {
display: flex;
gap: calc(var(--base) * 0.5);
}

View File

@@ -0,0 +1,126 @@
'use client'
import type { ClientCollectionConfig, ClientConfig, ViewTypes } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { useRouter } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React from 'react'
import { usePreferences } from '../../providers/Preferences/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import './index.scss'
const baseClass = 'default-list-view-tabs'
type DefaultListViewTabsProps = {
collectionConfig: ClientCollectionConfig
config: ClientConfig
onChange?: (viewType: ViewTypes) => void
viewType?: ViewTypes
}
export const DefaultListViewTabs: React.FC<DefaultListViewTabsProps> = ({
collectionConfig,
config,
onChange,
viewType,
}) => {
const { i18n, t } = useTranslation()
const { setPreference } = usePreferences()
const router = useRouter()
const isTrashEnabled = collectionConfig.trash
const isFoldersEnabled = collectionConfig.folders && config.folders
if (!isTrashEnabled && !isFoldersEnabled) {
return null
}
const handleViewChange = async (newViewType: ViewTypes) => {
if (onChange) {
onChange(newViewType)
}
if (newViewType === 'list' || newViewType === 'folders') {
await setPreference(`collection-${collectionConfig.slug}`, {
listViewType: newViewType,
})
}
let path: `/${string}` = `/collections/${collectionConfig.slug}`
switch (newViewType) {
case 'folders':
if (config.folders) {
path = `/collections/${collectionConfig.slug}/${config.folders.slug}`
}
break
case 'trash':
path = `/collections/${collectionConfig.slug}/trash`
break
}
const url = formatAdminURL({
adminRoute: config.routes.admin,
path,
serverURL: config.serverURL,
})
router.push(url)
}
const allButtonLabel = `${t('general:all')} ${getTranslation(collectionConfig?.labels?.plural, i18n)}`
const allButtonId = allButtonLabel.toLowerCase().replace(/\s+/g, '-')
return (
<div className={baseClass}>
<Button
buttonStyle="tab"
className={[`${baseClass}__button`, viewType === 'list' && `${baseClass}__button--active`]
.filter(Boolean)
.join(' ')}
disabled={viewType === 'list'}
el="button"
id={allButtonId}
onClick={() => handleViewChange('list')}
>
{t('general:all')} {getTranslation(collectionConfig?.labels?.plural, i18n)}
</Button>
{isFoldersEnabled && (
<Button
buttonStyle="tab"
className={[
`${baseClass}__button`,
viewType === 'folders' && `${baseClass}__button--active`,
]
.filter(Boolean)
.join(' ')}
disabled={viewType === 'folders'}
el="button"
onClick={() => handleViewChange('folders')}
>
{t('folder:byFolder')}
</Button>
)}
{isTrashEnabled && (
<Button
buttonStyle="tab"
className={[
`${baseClass}__button`,
viewType === 'trash' && `${baseClass}__button--active`,
]
.filter(Boolean)
.join(' ')}
disabled={viewType === 'trash'}
el="button"
id="trash-view-pill"
onClick={() => handleViewChange('trash')}
>
{t('general:trash')}
</Button>
)}
</div>
)
}

View File

@@ -1,54 +0,0 @@
'use client'
import type { ClientCollectionConfig, ViewTypes } from 'payload'
import { formatAdminURL } from 'payload/shared'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import './index.scss'
const baseClass = 'list-pills'
type ByFolderPillProps = {
readonly collectionConfig: ClientCollectionConfig
readonly folderCollectionSlug: string
readonly viewType: ViewTypes
}
export function ByFolderPill({
collectionConfig,
folderCollectionSlug,
viewType,
}: ByFolderPillProps) {
const { t } = useTranslation()
const { config } = useConfig()
if (!folderCollectionSlug) {
return null
}
return (
<div className={baseClass}>
<Button
buttonStyle="tab"
className={[
`${baseClass}__button`,
viewType === 'folders' && `${baseClass}__button--active`,
]
.filter(Boolean)
.join(' ')}
disabled={viewType === 'folders'}
el={viewType === 'list' || viewType === 'trash' ? 'link' : 'div'}
to={formatAdminURL({
adminRoute: config.routes.admin,
path: `/collections/${collectionConfig.slug}/${folderCollectionSlug}`,
serverURL: config.serverURL,
})}
>
{t('folder:byFolder')}
</Button>
</div>
)
}

View File

@@ -1,47 +0,0 @@
'use client'
import type { ClientCollectionConfig, ViewTypes } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { formatAdminURL } from 'payload/shared'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import './index.scss'
const baseClass = 'list-pills'
type DefaultListPillProps = {
readonly collectionConfig: ClientCollectionConfig
readonly viewType: ViewTypes
}
export function DefaultListPill({ collectionConfig, viewType }: DefaultListPillProps) {
const { i18n, t } = useTranslation()
const { config } = useConfig()
const buttonLabel = `${t('general:all')} ${getTranslation(collectionConfig?.labels?.plural, i18n)}`
const buttonId = buttonLabel.toLowerCase().replace(/\s+/g, '-')
return (
<div className={baseClass}>
<Button
buttonStyle="tab"
className={[`${baseClass}__button`, viewType === 'list' && `${baseClass}__button--active`]
.filter(Boolean)
.join(' ')}
disabled={viewType === 'list'}
el={viewType === 'folders' || viewType === 'trash' ? 'link' : 'div'}
id={buttonId}
to={formatAdminURL({
adminRoute: config.routes.admin,
path: `/collections/${collectionConfig.slug}`,
serverURL: config.serverURL,
})}
>
{t('general:all')} {getTranslation(collectionConfig?.labels?.plural, i18n)}
</Button>
</div>
)
}

View File

@@ -1,41 +0,0 @@
'use client'
import type { ClientCollectionConfig, ViewTypes } from 'payload'
import { formatAdminURL } from 'payload/shared'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
export function TrashPill({
collectionConfig,
viewType,
}: {
collectionConfig: ClientCollectionConfig
readonly viewType: ViewTypes
}) {
const { t } = useTranslation()
const { config } = useConfig()
if (!collectionConfig.trash) {
return null
}
return (
<Button
buttonStyle="tab"
disabled={viewType === 'trash'}
el={viewType === 'list' || viewType === 'folders' ? 'link' : 'div'}
id="trash-view-pill"
key="trash-view-pill"
to={formatAdminURL({
adminRoute: config.routes.admin,
path: `/collections/${collectionConfig.slug}/trash`,
serverURL: config.serverURL,
})}
>
{t('general:trash')}
</Button>
)
}

View File

@@ -9,6 +9,7 @@ import { useRouter } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React, { Fragment } from 'react'
import { DefaultListViewTabs } from '../../elements/DefaultListViewTabs/index.js'
import { DroppableBreadcrumb } from '../../elements/FolderView/Breadcrumbs/index.js'
import { ColoredFolderIcon } from '../../elements/FolderView/ColoredFolderIcon/index.js'
import { CurrentFolderActions } from '../../elements/FolderView/CurrentFolderActions/index.js'
@@ -21,9 +22,6 @@ import {
ListBulkUploadButton,
ListCreateNewDocInFolderButton,
} from '../../elements/ListHeader/TitleActions/index.js'
import { ByFolderPill } from '../../elements/ListHeaderTabs/ByFolderPill.js'
import { DefaultListPill } from '../../elements/ListHeaderTabs/DefaultListPill.js'
import { TrashPill } from '../../elements/ListHeaderTabs/TrashPill.js'
import { NoListResults } from '../../elements/NoListResults/index.js'
import { SearchBar } from '../../elements/SearchBar/index.js'
import { useStepNav } from '../../elements/StepNav/index.js'
@@ -274,28 +272,12 @@ function CollectionFolderViewInContext(props: CollectionFolderViewInContextProps
key="list-selection"
/>
),
config.folders && collectionConfig.folders && (
<Fragment key="list-header-folder-view-buttons">
<DefaultListPill
<DefaultListViewTabs
collectionConfig={collectionConfig}
key="list-header-default-button"
config={config}
key="default-list-actions"
viewType="folders"
/>
<ByFolderPill
collectionConfig={collectionConfig}
folderCollectionSlug={folderCollectionSlug}
key="list-header-by-folder-button"
viewType="folders"
/>
</Fragment>
),
collectionConfig.trash && (
<TrashPill
collectionConfig={collectionConfig}
key="list-header-trash-button"
viewType="folders"
/>
),
/>,
].filter(Boolean)}
AfterListHeaderContent={Description}
title={getTranslation(labels?.plural, i18n)}

View File

@@ -5,6 +5,7 @@ import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import { CloseModalButton } from '../../../elements/CloseModalButton/index.js'
import { DefaultListViewTabs } from '../../../elements/DefaultListViewTabs/index.js'
import { useListDrawerContext } from '../../../elements/ListDrawer/Provider.js'
import { DrawerRelationshipSelect } from '../../../elements/ListHeader/DrawerRelationshipSelect/index.js'
import { ListDrawerCreateNewDocButton } from '../../../elements/ListHeader/DrawerTitleActions/index.js'
@@ -14,13 +15,10 @@ import {
ListCreateNewButton,
ListEmptyTrashButton,
} from '../../../elements/ListHeader/TitleActions/index.js'
import { ByFolderPill } from '../../../elements/ListHeaderTabs/ByFolderPill.js'
import { DefaultListPill } from '../../../elements/ListHeaderTabs/DefaultListPill.js'
import './index.scss'
import { TrashPill } from '../../../elements/ListHeaderTabs/TrashPill.js'
import { useConfig } from '../../../providers/Config/index.js'
import { useListQuery } from '../../../providers/ListQuery/index.js'
import { ListSelection } from '../ListSelection/index.js'
import './index.scss'
const drawerBaseClass = 'list-drawer'
@@ -119,28 +117,12 @@ export const CollectionListHeader: React.FC<ListHeaderProps> = ({
viewType={viewType}
/>
),
((collectionConfig.folders && config.folders) || isTrashEnabled) && (
<DefaultListPill
<DefaultListViewTabs
collectionConfig={collectionConfig}
key="list-header-default-button"
config={config}
key="default-list-actions"
viewType={viewType}
/>
),
collectionConfig.folders && config.folders && (
<ByFolderPill
collectionConfig={collectionConfig}
folderCollectionSlug={config.folders.slug}
key="list-header-by-folder-button"
viewType={viewType}
/>
),
isTrashEnabled && (
<TrashPill
collectionConfig={collectionConfig}
key="list-header-trash-button"
viewType={viewType}
/>
),
/>,
].filter(Boolean)}
AfterListHeaderContent={Description}
className={className}

View File

@@ -367,11 +367,11 @@ test.describe('Folders', () => {
})
test('should show By Folder button', async () => {
const folderButton = page.locator('.list-pills__button', { hasText: 'By Folder' })
const folderButton = page.locator('.default-list-view-tabs__button', { hasText: 'By Folder' })
await expect(folderButton).toBeVisible()
})
test('should navigate to By Folder view', async () => {
const folderButton = page.locator('.list-pills__button', { hasText: 'By Folder' })
const folderButton = page.locator('.default-list-view-tabs__button', { hasText: 'By Folder' })
await folderButton.click()
await expect(page).toHaveURL(`${serverURL}/admin/collections/posts/payload-folders`)
const foldersTitle = page.locator('.collection-folder-list', { hasText: 'Folders' })