Files
payloadcms/packages/ui/src/fields/Upload/Input.tsx

619 lines
18 KiB
TypeScript

'use client'
import type {
ClientCollectionConfig,
FieldLabelClientProps,
FilterOptionsResult,
JsonObject,
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 type { PopulateDocs, ReloadDoc } from './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 { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
import { ShimmerEffect } from '../../elements/ShimmerEffect/index.js'
import { FieldDescription } from '../../fields/FieldDescription/index.js'
import { FieldError } from '../../fields/FieldError/index.js'
import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/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 = {
readonly AfterInput?: React.ReactNode
readonly allowCreate?: boolean
/**
* Controls the visibility of the "Create new collection" button
*/
readonly api?: string
readonly BeforeInput?: React.ReactNode
readonly className?: string
readonly collection?: ClientCollectionConfig
readonly customUploadActions?: React.ReactNode[]
readonly Description?: React.ReactNode
readonly description?: StaticDescription
readonly displayPreview?: boolean
readonly Error?: React.ReactNode
readonly filterOptions?: FilterOptionsResult
readonly hasMany?: boolean
readonly hideRemoveFile?: boolean
readonly isSortable?: boolean
readonly Label?: React.ReactNode
readonly label?: StaticLabel
readonly labelProps?: FieldLabelClientProps<MarkOptional<UploadFieldClient, 'type'>>
readonly localized?: boolean
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)
}
export function UploadInput(props: UploadInputProps) {
const {
AfterInput,
allowCreate,
api,
BeforeInput,
className,
Description,
description,
displayPreview,
Error,
filterOptions: filterOptionsFromProps,
hasMany,
isSortable,
Label,
label,
localized,
maxRows,
onChange: onChangeFromProps,
path,
readOnly,
relationTo,
required,
serverURL,
showError,
style,
value,
} = props
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,
setCurrentActivePath,
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 (!allowCreate) {
return false
}
if (typeof activeRelationTo === 'string') {
if (permissions?.collections && permissions.collections?.[activeRelationTo]?.create) {
return true
}
}
return false
}, [activeRelationTo, permissions, allowCreate])
const onChange = React.useCallback(
(newValue) => {
if (typeof onChangeFromProps === 'function') {
onChangeFromProps(newValue)
}
},
[onChangeFromProps],
)
const populateDocs = React.useCallback<PopulateDocs>(
async (ids, relatedCollectionSlug) => {
if (!ids.length) {
return
}
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-Payload-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
if (response.ok) {
const json = await response.json()
let sortedDocs = ids.map((id) =>
json.docs.find((doc) => {
return String(doc.id) === String(id)
}),
)
if (sortedDocs.includes(undefined) && hasMany) {
sortedDocs = sortedDocs.map((doc, index) =>
doc
? doc
: {
id: ids[index],
filename: `${t('general:untitled')} - ID: ${ids[index]}`,
isPlaceholder: true,
},
)
}
return { ...json, docs: sortedDocs }
}
return null
},
[code, serverURL, api, i18n.language, t, hasMany],
)
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)
if (typeof maxRows === 'number') {
setMaxFiles(maxRows)
}
setCurrentActivePath(path)
openModal(drawerSlug)
},
[
drawerSlug,
hasMany,
openModal,
relationTo,
setCollectionSlug,
setInitialFiles,
maxRows,
setMaxFiles,
path,
setCurrentActivePath,
],
)
// 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 = useCallback<NonNullable<ListDrawerProps['onSelect']>>(
async ({ collectionSlug, doc }) => {
const loadedDocs = await populateDocs([doc.id], 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 : []), doc.id])
} else {
onChange(doc.id)
}
closeListDrawer()
},
[closeListDrawer, hasMany, populateDocs, onChange, value, activeRelationTo],
)
const reloadDoc = React.useCallback<ReloadDoc>(
async (docID, collectionSlug) => {
const { docs } = await populateDocs([docID], collectionSlug)
if (docs[0]) {
let updatedDocsToPropogate = []
setPopulatedDocs((currentDocs) => {
const existingDocIndex = currentDocs?.findIndex((doc) => {
const hasExisting = doc.value?.id === docs[0].id || doc.value?.isPlaceholder
return hasExisting && doc.relationTo === collectionSlug
})
if (existingDocIndex > -1) {
const updatedDocs = [...currentDocs]
updatedDocs[existingDocIndex] = {
relationTo: collectionSlug,
value: docs[0],
}
updatedDocsToPropogate = updatedDocs
return updatedDocs
}
})
if (updatedDocsToPropogate.length && hasMany) {
onChange(updatedDocsToPropogate.map((doc) => doc.value?.id))
}
}
},
[populateDocs, onChange, hasMany],
)
// 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])
useEffect(() => {
setOnSuccess(path, onUploadSuccess)
}, [value, path, onUploadSuccess, setOnSuccess])
const showDropzone =
!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}
>
<RenderCustomComponent
CustomComponent={Label}
Fallback={
<FieldLabel label={label} localized={localized} path={path} required={required} />
}
/>
<div className={`${baseClass}__wrap`}>
<RenderCustomComponent
CustomComponent={Error}
Fallback={<FieldError path={path} showError={showError} />}
/>
</div>
{BeforeInput}
<div className={`${baseClass}__dropzoneAndUpload`}>
{hasMany && Array.isArray(value) && value.length > 0 ? (
<>
{populatedDocs && populatedDocs?.length > 0 ? (
<UploadComponentHasMany
displayPreview={displayPreview}
fileDocs={populatedDocs}
isSortable={isSortable && !readOnly}
onRemove={onRemove}
onReorder={onReorder}
readonly={readOnly}
reloadDoc={reloadDoc}
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
displayPreview={displayPreview}
fileDoc={populatedDocs[0]}
onRemove={onRemove}
readonly={readOnly}
reloadDoc={reloadDoc}
serverURL={serverURL}
/>
) : populatedDocs && value && !populatedDocs?.[0]?.value ? (
<>
{t('general:untitled')} - ID: {value}
</>
) : (
<ShimmerEffect height="62px" />
)}
</>
) : null}
{showDropzone ? (
<Dropzone
disabled={readOnly || !canCreate}
multipleFiles={hasMany}
onChange={onLocalFileSelection}
>
<div className={`${baseClass}__dropzoneContent`}>
<div className={`${baseClass}__dropzoneContent__buttons`}>
{canCreate && (
<>
<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={canCreate}
enableRowSelections={hasMany}
onBulkSelect={onListBulkSelect}
onSelect={onListSelect}
/>
</div>
{canCreate && !readOnly && (
<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>
{AfterInput}
<RenderCustomComponent
CustomComponent={Description}
Fallback={<FieldDescription description={description} path={path} />}
/>
</div>
)
}