Files
payloadcms/packages/ui/src/fields/Upload/Input.tsx
Dan Ribbens 9ef4fab65d fix: ui crashes editing doc with deleted upload (#8526)
fix #8133

UI of a deleted item (will need another iteration).

![clear-upload-state](https://github.com/user-attachments/assets/2d9baebe-9a12-4905-9449-457972f4505b)
2024-10-02 15:22:57 -04:00

568 lines
16 KiB
TypeScript

'use client'
import type {
ClientCollectionConfig,
FieldDescriptionClientProps,
FieldErrorClientProps,
FieldLabelClientProps,
FilterOptionsResult,
JsonObject,
MappedComponent,
PaginatedDocs,
StaticDescription,
StaticLabel,
UploadFieldClient,
UploadField as UploadFieldType,
Where,
} from 'payload'
import type { MarkOptional } from 'ts-essentials'
import { useModal } from '@faceless-ui/modal'
import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useMemo } from 'react'
import type { ListDrawerProps } from '../../elements/ListDrawer/types.js'
import { useBulkUpload } from '../../elements/BulkUpload/index.js'
import { Button } from '../../elements/Button/index.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
import { Dropzone } from '../../elements/Dropzone/index.js'
import { useListDrawer } from '../../elements/ListDrawer/index.js'
import { ShimmerEffect } from '../../elements/ShimmerEffect/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { FieldDescription } from '../FieldDescription/index.js'
import { FieldError } from '../FieldError/index.js'
import { FieldLabel } from '../FieldLabel/index.js'
import { fieldBaseClass } from '../shared/index.js'
import { UploadComponentHasMany } from './HasMany/index.js'
import { UploadComponentHasOne } from './HasOne/index.js'
import './index.scss'
export const baseClass = 'upload'
type PopulatedDocs = { relationTo: string; value: JsonObject }[]
export type UploadInputProps = {
/**
* Controls the visibility of the "Create new collection" button
*/
readonly api?: string
readonly className?: string
readonly collection?: ClientCollectionConfig
readonly customUploadActions?: React.ReactNode[]
readonly Description?: MappedComponent
readonly description?: StaticDescription
readonly descriptionProps?: FieldDescriptionClientProps<MarkOptional<UploadFieldClient, 'type'>>
readonly Error?: MappedComponent
readonly errorProps?: FieldErrorClientProps<MarkOptional<UploadFieldClient, 'type'>>
readonly field?: MarkOptional<UploadFieldClient, 'type'>
readonly filterOptions?: FilterOptionsResult
readonly hasMany?: boolean
readonly isSortable?: boolean
readonly Label?: MappedComponent
readonly label?: StaticLabel
readonly labelProps?: FieldLabelClientProps<MarkOptional<UploadFieldClient, 'type'>>
readonly maxRows?: number
readonly onChange?: (e) => void
readonly path: string
readonly readOnly?: boolean
readonly relationTo: UploadFieldType['relationTo']
readonly required?: boolean
readonly serverURL?: string
readonly showError?: boolean
readonly style?: React.CSSProperties
readonly value?: (number | string)[] | (number | string)
readonly width?: React.CSSProperties['width']
}
export function UploadInput(props: UploadInputProps) {
const {
api,
className,
Description,
description,
descriptionProps,
Error,
errorProps,
field,
filterOptions: filterOptionsFromProps,
hasMany,
isSortable,
Label,
label,
labelProps,
maxRows,
onChange: onChangeFromProps,
path,
readOnly,
relationTo,
required,
serverURL,
showError,
style,
value,
width,
} = props
const allowCreate = field?.admin?.allowCreate !== false
const [populatedDocs, setPopulatedDocs] = React.useState<
{
relationTo: string
value: JsonObject
}[]
>()
const [activeRelationTo, setActiveRelationTo] = React.useState<string>(
Array.isArray(relationTo) ? relationTo[0] : relationTo,
)
const { openModal } = useModal()
const { drawerSlug, setCollectionSlug, setInitialFiles, setMaxFiles, setOnSuccess } =
useBulkUpload()
const { permissions } = useAuth()
const { code } = useLocale()
const { i18n, t } = useTranslation()
const filterOptions: FilterOptionsResult = useMemo(() => {
return {
...filterOptionsFromProps,
[activeRelationTo]: {
...((filterOptionsFromProps?.[activeRelationTo] as any) || {}),
id: {
...((filterOptionsFromProps?.[activeRelationTo] as any)?.id || {}),
not_in: ((filterOptionsFromProps?.[activeRelationTo] as any)?.id?.not_in || []).concat(
...(Array.isArray(value) || value ? [value] : []),
),
},
},
}
}, [value, activeRelationTo, filterOptionsFromProps])
const [ListDrawer, , { closeDrawer: closeListDrawer, openDrawer: openListDrawer }] =
useListDrawer({
collectionSlugs: typeof relationTo === 'string' ? [relationTo] : relationTo,
filterOptions,
})
const [
CreateDocDrawer,
,
{ closeDrawer: closeCreateDocDrawer, openDrawer: openCreateDocDrawer },
] = useDocumentDrawer({
collectionSlug: activeRelationTo,
})
/**
* Prevent initial retrieval of documents from running more than once
*/
const loadedValueDocsRef = React.useRef<boolean>(false)
const canCreate = useMemo(() => {
if (typeof activeRelationTo === 'string') {
if (permissions?.collections && permissions.collections?.[activeRelationTo]?.create) {
if (permissions.collections[activeRelationTo].create?.permission === true) {
return true
}
}
}
return false
}, [activeRelationTo, permissions])
const onChange = React.useCallback(
(newValue) => {
if (typeof onChangeFromProps === 'function') {
onChangeFromProps(newValue)
}
},
[onChangeFromProps],
)
const populateDocs = React.useCallback(
async (
ids: (number | string)[],
relatedCollectionSlug: string,
): Promise<null | PaginatedDocs> => {
const query: {
[key: string]: unknown
where: Where
} = {
depth: 0,
draft: true,
limit: ids.length,
locale: code,
where: {
and: [
{
id: {
in: ids,
},
},
],
},
}
const response = await fetch(`${serverURL}${api}/${relatedCollectionSlug}`, {
body: qs.stringify(query),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/x-www-form-urlencoded',
'X-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
if (response.ok) {
const json = await response.json()
const sortedDocs = ids.map((id) =>
json.docs.find((doc) => {
return String(doc.id) === String(id)
}),
)
return { ...json, docs: sortedDocs }
}
return null
},
[code, serverURL, api, i18n.language],
)
const onUploadSuccess = useCallback(
(newDocs: JsonObject[]) => {
if (hasMany) {
const mergedValue = [
...(Array.isArray(value) ? value : []),
...newDocs.map((doc) => doc.id),
]
onChange(mergedValue)
setPopulatedDocs((currentDocs) => [
...(currentDocs || []),
...newDocs.map((doc) => ({
relationTo: activeRelationTo,
value: doc,
})),
])
} else {
const firstDoc = newDocs[0]
onChange(firstDoc.id)
setPopulatedDocs([
{
relationTo: activeRelationTo,
value: firstDoc,
},
])
}
},
[value, onChange, activeRelationTo, hasMany],
)
const onLocalFileSelection = React.useCallback(
(fileList?: FileList) => {
let fileListToUse = fileList
if (!hasMany && fileList && fileList.length > 1) {
const dataTransfer = new DataTransfer()
dataTransfer.items.add(fileList[0])
fileListToUse = dataTransfer.files
}
if (fileListToUse) {
setInitialFiles(fileListToUse)
}
setCollectionSlug(relationTo)
setOnSuccess(onUploadSuccess)
if (typeof maxRows === 'number') {
setMaxFiles(maxRows)
}
openModal(drawerSlug)
},
[
drawerSlug,
hasMany,
onUploadSuccess,
openModal,
relationTo,
setCollectionSlug,
setInitialFiles,
setOnSuccess,
maxRows,
setMaxFiles,
],
)
// only hasMany can bulk select
const onListBulkSelect = React.useCallback<NonNullable<ListDrawerProps['onBulkSelect']>>(
async (docs) => {
const selectedDocIDs = []
for (const [id, isSelected] of docs) {
if (isSelected) {
selectedDocIDs.push(id)
}
}
const loadedDocs = await populateDocs(selectedDocIDs, activeRelationTo)
if (loadedDocs) {
setPopulatedDocs((currentDocs) => [
...(currentDocs || []),
...loadedDocs.docs.map((doc) => ({
relationTo: activeRelationTo,
value: doc,
})),
])
}
onChange([...(Array.isArray(value) ? value : []), ...selectedDocIDs])
closeListDrawer()
},
[activeRelationTo, closeListDrawer, onChange, populateDocs, value],
)
const onDocCreate = React.useCallback(
(data) => {
if (data.doc) {
setPopulatedDocs((currentDocs) => [
...(currentDocs || []),
{
relationTo: activeRelationTo,
value: data.doc,
},
])
onChange(data.doc.id)
}
closeCreateDocDrawer()
},
[closeCreateDocDrawer, activeRelationTo, onChange],
)
const onListSelect = React.useCallback<NonNullable<ListDrawerProps['onSelect']>>(
async ({ collectionSlug, docID }) => {
const loadedDocs = await populateDocs([docID], collectionSlug)
const selectedDoc = loadedDocs ? loadedDocs.docs?.[0] : null
setPopulatedDocs((currentDocs) => {
if (selectedDoc) {
if (hasMany) {
return [
...(currentDocs || []),
{
relationTo: activeRelationTo,
value: selectedDoc,
},
]
}
return [
{
relationTo: activeRelationTo,
value: selectedDoc,
},
]
}
return currentDocs
})
if (hasMany) {
onChange([...(Array.isArray(value) ? value : []), docID])
} else {
onChange(docID)
}
closeListDrawer()
},
[closeListDrawer, hasMany, populateDocs, onChange, value, activeRelationTo],
)
// only hasMany can reorder
const onReorder = React.useCallback(
(newValue) => {
const newValueIDs = newValue.map(({ value }) => value.id)
onChange(newValueIDs)
setPopulatedDocs(newValue)
},
[onChange],
)
const onRemove = React.useCallback(
(newValue?: PopulatedDocs) => {
const newValueIDs = newValue ? newValue.map(({ value }) => value.id) : null
onChange(hasMany ? newValueIDs : newValueIDs ? newValueIDs[0] : null)
setPopulatedDocs(newValue ? newValue : [])
},
[onChange, hasMany],
)
useEffect(() => {
async function loadInitialDocs() {
if (value) {
loadedValueDocsRef.current = true
const loadedDocs = await populateDocs(
Array.isArray(value) ? value : [value],
activeRelationTo,
)
if (loadedDocs) {
setPopulatedDocs(
loadedDocs.docs.map((doc) => ({ relationTo: activeRelationTo, value: doc })),
)
}
}
}
if (!loadedValueDocsRef.current) {
void loadInitialDocs()
}
}, [populateDocs, activeRelationTo, value])
const showDropzone =
!readOnly &&
(!value ||
(hasMany &&
Array.isArray(value) &&
(typeof maxRows !== 'number' || value.length < maxRows)) ||
(!hasMany && populatedDocs?.[0] && typeof populatedDocs[0].value === 'undefined'))
return (
<div
className={[
fieldBaseClass,
baseClass,
className,
showError && 'error',
readOnly && 'read-only',
]
.filter(Boolean)
.join(' ')}
id={`field-${path.replace(/\./g, '__')}`}
style={{
...style,
width,
}}
>
<FieldLabel
Label={Label}
label={label}
required={required}
{...(labelProps || {})}
field={field as UploadFieldClient}
/>
<div className={`${baseClass}__wrap`}>
<FieldError
CustomError={Error}
path={path}
{...(errorProps || {})}
field={field as UploadFieldClient}
/>
</div>
<div className={`${baseClass}__dropzoneAndUpload`}>
{hasMany && Array.isArray(value) && value.length > 0 ? (
<>
{populatedDocs && populatedDocs?.length > 0 ? (
<UploadComponentHasMany
fileDocs={populatedDocs}
isSortable={isSortable && !readOnly}
onRemove={onRemove}
onReorder={onReorder}
readonly={readOnly}
serverURL={serverURL}
/>
) : (
<div className={`${baseClass}__loadingRows`}>
{value.map((id) => (
<ShimmerEffect height="40px" key={id} />
))}
</div>
)}
</>
) : null}
{!hasMany && value ? (
<>
{populatedDocs && populatedDocs?.length > 0 && populatedDocs[0].value ? (
<UploadComponentHasOne
fileDoc={populatedDocs[0]}
onRemove={onRemove}
readonly={readOnly}
serverURL={serverURL}
/>
) : populatedDocs && value && !populatedDocs?.[0]?.value ? (
<>
{t('general:untitled')} - ID: {value}
</>
) : (
<ShimmerEffect height="62px" />
)}
</>
) : null}
{showDropzone ? (
<Dropzone disabled={!allowCreate} multipleFiles={hasMany} onChange={onLocalFileSelection}>
<div className={`${baseClass}__dropzoneContent`}>
<div className={`${baseClass}__dropzoneContent__buttons`}>
{allowCreate && (
<>
<Button
buttonStyle="pill"
className={`${baseClass}__createNewToggler`}
disabled={readOnly || !canCreate}
onClick={() => {
if (!readOnly) {
if (hasMany) {
onLocalFileSelection()
} else {
openCreateDocDrawer()
}
}
}}
size="small"
>
{t('general:createNew')}
</Button>
<span className={`${baseClass}__dropzoneContent__orText`}>
{t('general:or')}
</span>
</>
)}
<Button
buttonStyle="pill"
className={`${baseClass}__listToggler`}
disabled={readOnly}
onClick={openListDrawer}
size="small"
>
{t('fields:chooseFromExisting')}
</Button>
<CreateDocDrawer onSave={onDocCreate} />
<ListDrawer
allowCreate={allowCreate}
enableRowSelections={hasMany}
onBulkSelect={onListBulkSelect}
onSelect={onListSelect}
/>
</div>
{allowCreate && (
<p className={`${baseClass}__dragAndDropText`}>
{t('general:or')} {t('upload:dragAndDrop')}
</p>
)}
</div>
</Dropzone>
) : (
<>
{!readOnly &&
!populatedDocs &&
(!value ||
typeof maxRows !== 'number' ||
(Array.isArray(value) && value.length < maxRows)) ? (
<ShimmerEffect height="40px" />
) : null}
</>
)}
</div>
<FieldDescription
Description={Description}
description={description}
{...(descriptionProps || {})}
field={field as UploadFieldClient}
/>
</div>
)
}