feat(ui): use document drawers for folder edit/create (#12676)

This PR re-uses the document drawers for editing and creating folders.
This allows us to easily render document fields that are added inside
`collectionOverrides` on the folder config.

Not much changed, the folder drawer UI now resembles what you would
expect when you create a document in payload. It is a bit slimmed back
but generally very similar.
This commit is contained in:
Jarrod Flesch
2025-06-04 16:52:49 -04:00
committed by GitHub
parent 545d870650
commit 9581092995
18 changed files with 136 additions and 315 deletions

View File

@@ -4,11 +4,13 @@
.doc-drawer {
&__header {
width: 100%;
margin-top: base(2.5);
margin-top: calc(var(--base) * 2);
display: flex;
flex-direction: column;
gap: base(0.5);
gap: calc(var(--base) * 0.5);
align-items: flex-start;
border-bottom: 1px solid var(--theme-elevation-100);
padding-bottom: var(--base);
}
&__header-content {
@@ -46,12 +48,12 @@
padding: 0;
cursor: pointer;
overflow: hidden;
width: base(2);
height: base(2);
width: calc(var(--base) * 2);
height: calc(var(--base) * 2);
svg {
width: base(2);
height: base(2);
width: calc(var(--base) * 2);
height: calc(var(--base) * 2);
position: relative;
.stroke {
@@ -61,10 +63,16 @@
}
}
&__divider {
height: 1px;
background: var(--theme-elevation-100);
width: 100%;
}
@include mid-break {
.doc-drawer__header {
margin-top: base(1.5);
margin-bottom: base(0.5);
margin-top: calc(var(--base) * 1.5);
margin-bottom: calc(var(--base) * 0.5);
padding-left: var(--gutter-h);
padding-right: var(--gutter-h);
}

View File

@@ -13,7 +13,8 @@ import './index.scss'
export const DocumentDrawerHeader: React.FC<{
drawerSlug: string
}> = ({ drawerSlug }) => {
showDocumentID?: boolean
}> = ({ drawerSlug, showDocumentID = true }) => {
const { closeModal } = useModal()
const { t } = useTranslation()
@@ -32,12 +33,12 @@ export const DocumentDrawerHeader: React.FC<{
<XIcon />
</button>
</div>
<DocumentTitle />
{showDocumentID && <DocumentID />}
</Gutter>
)
}
const DocumentTitle: React.FC = () => {
const DocumentID: React.FC = () => {
const { id } = useDocumentInfo()
const { title } = useDocumentTitle()
return id && id !== title ? <IDLabel id={id.toString()} /> : null

View File

@@ -1,7 +1,7 @@
.drawerHeader {
.drawer-action-header {
padding-top: calc(var(--base) * 2);
padding-bottom: calc(var(--base) * 1);
border-bottom: 1px solid var(--theme-border-color);
border-bottom: 1px solid var(--theme-elevation-100);
&__content {
margin-left: var(--gutter-h);

View File

@@ -7,10 +7,11 @@ import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import './index.scss'
const baseClass = 'drawerHeader'
const baseClass = 'drawer-action-header'
type DrawerActionHeaderArgs = {
readonly cancelLabel?: string
className?: string
readonly onCancel?: () => void
readonly onSave?: () => void
readonly saveLabel?: string
@@ -18,6 +19,7 @@ type DrawerActionHeaderArgs = {
}
export const DrawerActionHeader = ({
cancelLabel,
className,
onCancel,
onSave,
saveLabel,
@@ -26,7 +28,7 @@ export const DrawerActionHeader = ({
const { t } = useTranslation()
return (
<div className={baseClass}>
<div className={[baseClass, className].filter(Boolean).join(' ')}>
<div className={`${baseClass}__content`}>
<h1 className={`${baseClass}__title`}>{title}</h1>

View File

@@ -8,12 +8,11 @@ import { useConfig } from '../../../providers/Config/index.js'
import { useFolder } from '../../../providers/Folders/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { ConfirmationModal } from '../../ConfirmationModal/index.js'
import { useDocumentDrawer } from '../../DocumentDrawer/index.js'
import { Popup, PopupList } from '../../Popup/index.js'
import { Translation } from '../../Translation/index.js'
import { MoveItemsToFolderDrawer } from '../Drawers/MoveToFolder/index.js'
import { RenameFolderDrawer } from '../Drawers/RenameFolder/index.js'
const renameFolderDrawerSlug = 'rename-folder--current-folder'
const moveToFolderDrawerSlug = 'move-to-folder--current-folder'
const confirmDeleteDrawerSlug = 'confirm-many-delete'
@@ -34,6 +33,11 @@ export function CurrentFolderActions({ className }: Props) {
renameFolder,
setFolderID,
} = useFolder()
const [FolderDocumentDrawer, , { closeDrawer: closeFolderDrawer, openDrawer: openFolderDrawer }] =
useDocumentDrawer({
id: folderID,
collectionSlug: folderCollectionSlug,
})
const { config } = useConfig()
const { routes, serverURL } = config
const { closeModal, openModal } = useModal()
@@ -60,10 +64,12 @@ export function CurrentFolderActions({ className }: Props) {
<PopupList.ButtonGroup>
<PopupList.Button
onClick={() => {
openModal(renameFolderDrawerSlug)
openFolderDrawer()
}}
>
{t('folder:renameFolder')}
{t('general:editLabel', {
label: getTranslation(folderCollectionConfig.labels.singular, i18n),
})}
</PopupList.Button>
<PopupList.Button
onClick={() => {
@@ -136,15 +142,13 @@ export function CurrentFolderActions({ className }: Props) {
onConfirm={deleteCurrentFolder}
/>
<RenameFolderDrawer
drawerSlug={renameFolderDrawerSlug}
folderToRename={currentFolder}
onRenameConfirm={({ folderID: updatedFolderID, updatedName }) => {
<FolderDocumentDrawer
onSave={(result) => {
renameFolder({
folderID: updatedFolderID,
newName: updatedName,
folderID: result.doc.id,
newName: result.doc[folderCollectionConfig.admin.useAsTitle],
})
closeModal(renameFolderDrawerSlug)
closeFolderDrawer()
}}
/>
</>

View File

@@ -1,30 +0,0 @@
@layer payload-default {
.drawerHeader {
padding-top: calc(var(--base) * 2);
padding-bottom: calc(var(--base) * 1.5);
border-bottom: 1px solid var(--theme-border-color);
&__content {
margin-left: var(--gutter-h);
margin-right: var(--gutter-h);
display: flex;
justify-content: space-between;
align-items: center;
}
&__title {
margin: 0;
}
&__actions {
display: flex;
margin-left: auto;
padding-left: var(--base);
gap: var(--base);
button {
margin: 0;
}
}
}
}

View File

@@ -1,37 +0,0 @@
'use client'
import React from 'react'
import { FormSubmit } from '../../../../forms/Submit/index.js'
import { useTranslation } from '../../../../providers/Translation/index.js'
import { Button } from '../../../Button/index.js'
import './index.scss'
const baseClass = 'drawerHeader'
type DrawerHeaderArgs = {
readonly onCancel?: () => void
readonly onSave?: () => void
readonly title: string
}
export const DrawerHeader = ({ onCancel, onSave, title }: DrawerHeaderArgs) => {
const { t } = useTranslation()
return (
<div className={baseClass}>
<div className={`${baseClass}__content`}>
<h1 className={`${baseClass}__title`}>{title}</h1>
<div className={`${baseClass}__actions`}>
<Button aria-label={t('general:cancel')} buttonStyle="secondary" onClick={onCancel}>
{t('general:cancel')}
</Button>
<FormSubmit aria-label={t('general:applyChanges')} onClick={onSave}>
{t('general:applyChanges')}
</FormSubmit>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import type { DocumentDrawerContextProps } from '../../../DocumentDrawer/Provider.js'
import { useTranslation } from '../../../../providers/Translation/index.js'
import { useDocumentDrawer } from '../../../DocumentDrawer/index.js'
import { ListSelectionButton } from '../../../ListSelection/index.js'
type EditFolderActionProps = {
folderCollectionSlug: string
id: number | string
onSave: DocumentDrawerContextProps['onSave']
}
export const EditFolderAction = ({ id, folderCollectionSlug, onSave }: EditFolderActionProps) => {
const { t } = useTranslation()
const [FolderDocumentDrawer, , { closeDrawer, openDrawer }] = useDocumentDrawer({
id,
collectionSlug: folderCollectionSlug,
})
if (!id) {
return null
}
return (
<>
<ListSelectionButton onClick={openDrawer} type="button">
{t('general:edit')}
</ListSelectionButton>
<FolderDocumentDrawer
onSave={async (args) => {
await onSave(args)
closeDrawer()
}}
/>
</>
)
}

View File

@@ -8,7 +8,7 @@
&__breadcrumbs-section {
padding: calc(var(--base) * 0.75) var(--gutter-h);
border-bottom: 1px solid var(--theme-border-color);
border-bottom: 1px solid var(--theme-elevation-100);
display: flex;
justify-content: space-between;

View File

@@ -18,6 +18,7 @@ import { FolderProvider, useFolder } from '../../../../providers/Folders/index.j
import { useTranslation } from '../../../../providers/Translation/index.js'
import { Button } from '../../../Button/index.js'
import { ConfirmationModal } from '../../../ConfirmationModal/index.js'
import { useDocumentDrawer } from '../../../DocumentDrawer/index.js'
import { Drawer } from '../../../Drawer/index.js'
import { DrawerActionHeader } from '../../../DrawerActionHeader/index.js'
import { DrawerContentContainer } from '../../../DrawerContentContainer/index.js'
@@ -28,13 +29,11 @@ import { Translation } from '../../../Translation/index.js'
import { FolderBreadcrumbs } from '../../Breadcrumbs/index.js'
import { ColoredFolderIcon } from '../../ColoredFolderIcon/index.js'
import { ItemCardGrid } from '../../ItemCardGrid/index.js'
import { NewFolderDrawer } from '../NewFolder/index.js'
import './index.scss'
const baseClass = 'move-folder-drawer'
const baseModalSlug = 'move-folder-drawer'
const confirmModalSlug = `${baseModalSlug}-confirm-move`
const newFolderDrawerSlug = `${baseModalSlug}-new-folder`
type ActionProps =
| {
@@ -158,10 +157,15 @@ function Content({
folderCollectionConfig,
folderCollectionSlug,
folderFieldName,
folderID,
getSelectedItems,
setFolderID,
subfolders,
} = useFolder()
const [FolderDocumentDrawer, , { closeDrawer: closeFolderDrawer, openDrawer: openFolderDrawer }] =
useDocumentDrawer({
collectionSlug: folderCollectionSlug,
})
const { getEntityConfig } = useConfig()
const getSelectedFolder = React.useCallback((): {
@@ -268,24 +272,27 @@ function Content({
className={`${baseClass}__add-folder-button`}
margin={false}
onClick={() => {
openModal(newFolderDrawerSlug)
openFolderDrawer()
}}
>
{t('fields:addLabel', {
label: getTranslation(folderCollectionConfig.labels?.singular, i18n),
})}
</Button>
<NewFolderDrawer
drawerSlug={newFolderDrawerSlug}
onNewFolderSuccess={(doc) => {
closeModal(newFolderDrawerSlug)
<FolderDocumentDrawer
initialData={{
[folderFieldName]: folderID,
}}
onSave={(result) => {
if (typeof onCreateSuccess === 'function') {
void onCreateSuccess({
collectionSlug: folderCollectionConfig.slug,
doc,
doc: result.doc,
})
}
closeFolderDrawer()
}}
redirectAfterCreate={false}
/>
</>
)}

View File

@@ -1,95 +0,0 @@
'use client'
import type { FolderInterface } from 'payload/shared'
import { useModal } from '@faceless-ui/modal'
import React from 'react'
import { HiddenField } from '../../../../fields/Hidden/index.js'
import { TextField } from '../../../../fields/Text/index.js'
import { Form } from '../../../../forms/Form/index.js'
import { useConfig } from '../../../../providers/Config/index.js'
import { useFolder } from '../../../../providers/Folders/index.js'
import { useTranslation } from '../../../../providers/Translation/index.js'
import { Drawer } from '../../../Drawer/index.js'
import { DrawerActionHeader } from '../../../DrawerActionHeader/index.js'
import { DrawerContentContainer } from '../../../DrawerContentContainer/index.js'
type Props = {
readonly drawerSlug: string
readonly onNewFolderSuccess: (doc: FolderInterface) => Promise<void> | void
}
export const NewFolderDrawer = ({ drawerSlug, onNewFolderSuccess }: Props) => {
const { config } = useConfig()
const { routes, serverURL } = config
const { closeModal } = useModal()
const { t } = useTranslation()
const { folderCollectionSlug, folderFieldName, folderID } = useFolder()
return (
<Drawer gutter={false} Header={null} slug={drawerSlug}>
<Form
action={`${serverURL}${routes.api}/${folderCollectionSlug}?depth=0`}
handleResponse={async (res, successToast, errorToast) => {
try {
const { doc } = await res.json()
successToast(
t('general:successfullyCreated', {
label: `"${doc.name}"`,
}),
)
await onNewFolderSuccess(doc)
} catch (_) {
errorToast(t('general:error'))
}
}}
initialState={{
name: {
initialValue: '',
valid: true,
validate: (value) => {
if (!value) {
return t('validation:required')
}
return true
},
value: '',
},
[folderFieldName]: {
initialValue: '',
valid: true,
value: '',
},
}}
method="POST"
>
<DrawerActionHeader
onCancel={() => {
closeModal(drawerSlug)
}}
saveLabel={t('general:create')}
title={t('folder:newFolder')}
/>
<DrawerContentContainer>
<TextField
field={{
name: 'name',
label: t('folder:folderName'),
required: true,
}}
path="name"
validate={(value) => {
if (!value) {
return t('validation:required')
}
return true
}}
/>
<HiddenField key={folderID} path={folderFieldName} value={folderID} />
</DrawerContentContainer>
</Form>
</Drawer>
)
}

View File

@@ -1,85 +0,0 @@
'use client'
import type { FolderOrDocument } from 'payload/shared'
import { useModal } from '@faceless-ui/modal'
import React from 'react'
import { TextField } from '../../../../fields/Text/index.js'
import { Form } from '../../../../forms/Form/index.js'
import { useConfig } from '../../../../providers/Config/index.js'
import { useFolder } from '../../../../providers/Folders/index.js'
import { useTranslation } from '../../../../providers/Translation/index.js'
import { Drawer } from '../../../Drawer/index.js'
import { DrawerContentContainer } from '../../../DrawerContentContainer/index.js'
import { DrawerHeader } from '../DrawerHeader/index.js'
type Props = {
readonly drawerSlug: string
readonly folderToRename: FolderOrDocument
readonly onRenameConfirm: ({
folderID,
updatedName,
}: {
folderID: number | string
updatedName: string
}) => void
}
export function RenameFolderDrawer(props: Props) {
const { drawerSlug, folderToRename, onRenameConfirm } = props
const { t } = useTranslation()
const { folderCollectionConfig, folderCollectionSlug } = useFolder()
const { config } = useConfig()
const { closeModal } = useModal()
const { routes, serverURL } = config
const folderName = folderToRename.value._folderOrDocumentTitle
const folderID = folderToRename.value.id
const folderUseAsTitle = folderCollectionConfig.admin.useAsTitle
return (
<Drawer gutter={false} Header={null} slug={drawerSlug}>
<Form
action={`${serverURL}${routes.api}/${folderCollectionSlug}/${folderToRename.value.id}?depth=0`}
initialState={{
name: {
initialValue: folderName,
valid: true,
validate: (value) => {
if (!value) {
return t('validation:required')
}
return true
},
value: folderName,
},
}}
method="PATCH"
onSuccess={(data: { doc: FolderOrDocument['value'] }) => {
return onRenameConfirm({
folderID,
updatedName: data?.doc?.[folderUseAsTitle],
})
}}
>
<DrawerHeader onCancel={() => closeModal(drawerSlug)} title={t('folder:renameFolder')} />
<DrawerContentContainer>
<TextField
field={{
name: 'name',
label: t('folder:folderName'),
required: true,
}}
path="name"
validate={(value) => {
if (!value) {
return t('validation:required')
}
return true
}}
/>
</DrawerContentContainer>
</Form>
</Drawer>
)
}

View File

@@ -10,8 +10,7 @@ import { useConfig } from '../../../providers/Config/index.js'
import { useFolder } from '../../../providers/Folders/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { Button } from '../../Button/index.js'
import { DocumentDrawer } from '../../DocumentDrawer/index.js'
import { NewFolderDrawer } from '../../FolderView/Drawers/NewFolder/index.js'
import { DocumentDrawer, useDocumentDrawer } from '../../DocumentDrawer/index.js'
import { Popup, PopupList } from '../../Popup/index.js'
const baseClass = 'create-new-doc-in-folder'
@@ -30,12 +29,15 @@ export function ListCreateNewDocInFolderButton({
}) => Promise<void> | void
slugPrefix: string
}) {
const newFolderDrawerSlug = `${slugPrefix}-new-folder-drawer`
const newDocInFolderDrawerSlug = `${slugPrefix}-new-doc-in-folder-drawer`
const { i18n } = useTranslation()
const { closeModal, openModal } = useModal()
const { config } = useConfig()
const { folderCollectionConfig, folderFieldName, folderID } = useFolder()
const { folderCollectionConfig, folderCollectionSlug, folderFieldName, folderID } = useFolder()
const [FolderDocumentDrawer, , { closeDrawer: closeFolderDrawer, openDrawer: openFolderDrawer }] =
useDocumentDrawer({
collectionSlug: folderCollectionSlug,
})
const [createCollectionSlug, setCreateCollectionSlug] = React.useState<string | undefined>()
const [enabledCollections] = React.useState<ClientCollectionConfig[]>(() =>
collectionSlugs.reduce((acc, collectionSlug) => {
@@ -61,7 +63,7 @@ export function ListCreateNewDocInFolderButton({
el="div"
onClick={() => {
if (enabledCollections[0].slug === folderCollectionConfig.slug) {
openModal(newFolderDrawerSlug)
openFolderDrawer()
} else {
setCreateCollectionSlug(enabledCollections[0].slug)
openModal(newDocInFolderDrawerSlug)
@@ -94,7 +96,7 @@ export function ListCreateNewDocInFolderButton({
key={index}
onClick={() => {
if (collection.slug === folderCollectionConfig.slug) {
openModal(newFolderDrawerSlug)
openFolderDrawer()
} else {
setCreateCollectionSlug(collection.slug)
openModal(newDocInFolderDrawerSlug)
@@ -128,17 +130,20 @@ export function ListCreateNewDocInFolderButton({
)}
{collectionSlugs.includes(folderCollectionConfig.slug) && (
<NewFolderDrawer
drawerSlug={newFolderDrawerSlug}
onNewFolderSuccess={(doc) => {
closeModal(newFolderDrawerSlug)
<FolderDocumentDrawer
initialData={{
[folderFieldName]: folderID,
}}
onSave={(result) => {
if (typeof onCreateSuccess === 'function') {
void onCreateSuccess({
collectionSlug: folderCollectionConfig.slug,
doc,
doc: result.doc,
})
}
closeFolderDrawer()
}}
redirectAfterCreate={false}
/>
)}
</React.Fragment>

View File

@@ -58,7 +58,6 @@ export {
useBulkUpload,
useBulkUploadDrawerSlug,
} from '../../elements/BulkUpload/index.js'
export { DrawerActionHeader } from '../../elements/DrawerActionHeader/index.js'
export { DrawerContentContainer } from '../../elements/DrawerContentContainer/index.js'
export type { BulkUploadProps } from '../../elements/BulkUpload/index.js'
export { Banner } from '../../elements/Banner/index.js'

View File

@@ -9,8 +9,8 @@ import { toast } from 'sonner'
import { DeleteMany_v4 } from '../../../elements/DeleteMany/index.js'
import { EditMany_v4 } from '../../../elements/EditMany/index.js'
import { EditFolderAction } from '../../../elements/FolderView/Drawers/EditFolderAction/index.js'
import { MoveItemsToFolderDrawer } from '../../../elements/FolderView/Drawers/MoveToFolder/index.js'
import { RenameFolderDrawer } from '../../../elements/FolderView/Drawers/RenameFolder/index.js'
import { ListSelection_v4, ListSelectionButton } from '../../../elements/ListSelection/index.js'
import { PublishMany_v4 } from '../../../elements/PublishMany/index.js'
import { UnpublishMany_v4 } from '../../../elements/UnpublishMany/index.js'
@@ -19,7 +19,6 @@ import { useFolder } from '../../../providers/Folders/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
const moveToFolderDrawerSlug = 'move-to-folder--list'
const renameFolderDrawerSlug = 'rename-folder--list'
type GroupedSelections = {
[relationTo: string]: {
@@ -41,6 +40,7 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
const {
clearSelections,
currentFolder,
folderCollectionConfig,
folderCollectionSlug,
folderFieldName,
folderID,
@@ -117,22 +117,17 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
</Fragment>
),
count === 1 && !singleNonFolderCollectionSelected && (
<React.Fragment key="rename-folder">
<ListSelectionButton onClick={() => openModal(renameFolderDrawerSlug)} type="button">
{t('general:rename')}
</ListSelectionButton>
<RenameFolderDrawer
drawerSlug={renameFolderDrawerSlug}
folderToRename={items[0]}
onRenameConfirm={({ folderID: updatedFolderID, updatedName }) => {
<EditFolderAction
folderCollectionSlug={folderCollectionSlug}
id={groupedSelections[folderCollectionSlug].ids[0]}
key="edit-folder-action"
onSave={({ doc }) => {
renameFolder({
folderID: updatedFolderID,
newName: updatedName,
folderID: doc.id,
newName: doc[folderCollectionConfig.admin.useAsTitle],
})
closeModal(renameFolderDrawerSlug)
}}
/>
</React.Fragment>
),
count > 0 ? (
<React.Fragment key={moveToFolderDrawerSlug}>

View File

@@ -445,6 +445,8 @@ export function DefaultEditView({
!documentLockStateRef.current?.hasShownLockedModal &&
!isLockExpired
const isFolderCollection = config.folders && collectionSlug === config.folders?.slug
return (
<main className={classes.filter(Boolean).join(' ')}>
<OperationProvider operation={operation}>
@@ -460,8 +462,10 @@ export function DefaultEditView({
onChange={[onChange]}
onSuccess={onSave}
>
{isInDrawer && <DocumentDrawerHeader drawerSlug={drawerSlug} />}
{isLockingEnabled && shouldShowDocumentLockedModal && !isReadOnlyForIncomingUser && (
{isInDrawer && (
<DocumentDrawerHeader drawerSlug={drawerSlug} showDocumentID={!isFolderCollection} />
)}
{isLockingEnabled && shouldShowDocumentLockedModal && (
<DocumentLocked
handleGoBack={() => handleGoBack({ adminRoute, collectionSlug, router })}
isActive={shouldShowDocumentLockedModal}
@@ -522,7 +526,7 @@ export function DefaultEditView({
SaveDraftButton,
}}
data={savedDocumentData}
disableActions={disableActions}
disableActions={disableActions || isFolderCollection}
disableCreate={disableCreate}
EditMenuItems={EditMenuItems}
hasPublishPermission={hasPublishPermission}

View File

@@ -21,6 +21,10 @@ export default buildConfigWithDefaults({
// debug: true,
collectionOverrides: [
({ collection }) => {
collection.fields.push({
name: 'folderSlug',
type: 'text',
})
return collection
},
],

View File

@@ -201,6 +201,7 @@ export interface FolderInterface {
hasNextPage?: boolean;
totalDocs?: number;
};
belongsToCollections?: ('posts' | 'media' | 'drafts' | 'autosave' | 'all')[] | null;
updatedAt: string;
createdAt: string;
}
@@ -418,6 +419,7 @@ export interface PayloadFoldersSelect<T extends boolean = true> {
name?: T;
folder?: T;
documentsAndFolders?: T;
belongsToCollections?: T;
updatedAt?: T;
createdAt?: T;
}