feat(richtext-lexical): uploads

This commit is contained in:
Alessio Gravili
2024-03-01 14:46:57 -05:00
parent 37fa2f8431
commit 9283e367b1
13 changed files with 221 additions and 124 deletions

View File

@@ -124,7 +124,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
const formContent = useMemo(() => {
return (
reducedBlock &&
initialState && (
initialState !== false && (
<FieldPathProvider path="" schemaPath="">
<Form
fields={fieldMap}

View File

@@ -13,6 +13,7 @@ import {
} from '@payloadcms/ui'
import { useFieldPath } from '@payloadcms/ui'
import React, { useCallback, useEffect, useState } from 'react'
import { v4 as uuid } from 'uuid'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import './index.scss'
@@ -74,21 +75,22 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
)
return (
initialState !== false && (
<Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink') ?? ''}>
<Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink') ?? ''}>
{initialState !== false && (
<FieldPathProvider path="" schemaPath="">
<Form
fields={Array.isArray(fieldMap) ? fieldMap : []}
initialState={initialState}
onChange={[onChange]}
onSubmit={handleModalSubmit}
uuid={uuid()}
>
<RenderFields fieldMap={Array.isArray(fieldMap) ? fieldMap : []} forceRender />
<FormSubmit>{t('general:submit')}</FormSubmit>
</Form>
</FieldPathProvider>
</Drawer>
)
)}
</Drawer>
)
}

View File

@@ -63,7 +63,7 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
disabledCollections: props.disabledCollections,
enabledCollections: props.enabledCollections,
} as ExclusiveLinkCollectionsProps,
generateSchemaMap: ({ config, props, schemaMap, schemaPath }) => {
generateSchemaMap: ({ config, props }) => {
const i18n = initI18n({ config: config.i18n, context: 'client', translations })
return {

View File

@@ -6,24 +6,24 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { getTranslation } from '@payloadcms/translations'
import {
Drawer,
FieldPathProvider,
Form,
type FormProps,
type FormState,
FormSubmit,
RenderFields,
buildStateFromSchema,
fieldTypes,
useAuth,
getFormState,
useConfig,
useDocumentInfo,
useLocale,
useFieldPath,
useTranslation,
} from '@payloadcms/ui'
import { $getNodeByKey } from 'lexical'
import { sanitizeFields } from 'payload/config'
import { deepCopyObject } from 'payload/utilities'
import React, { useCallback, useEffect, useState } from 'react'
import { v4 as uuid } from 'uuid'
import type { ElementProps } from '..'
import type { UploadFeatureProps } from '../..'
import type { UploadData, UploadNode } from '../../nodes/UploadNode'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider'
@@ -46,28 +46,58 @@ export const ExtraFieldsUploadDrawer: React.FC<
} = props
const [editor] = useLexicalComposerContext()
const { editorConfig, field } = useEditorConfigContext()
const { closeModal } = useModal()
const { i18n, t } = useTranslation()
const { code: locale } = useLocale()
const { user } = useAuth()
const { closeModal } = useModal()
const { getDocPreferences } = useDocumentInfo()
const [initialState, setInitialState] = useState({})
const fieldSchemaUnsanitized = (
editorConfig?.resolvedFeatureMap.get('upload')?.props as UploadFeatureProps
)?.collections?.[relatedCollection.slug]?.fields
const { id } = useDocumentInfo()
const { schemaPath } = useFieldPath()
const config = useConfig()
const [initialState, setInitialState] = useState<FormState | false>(false)
const {
field: { richTextComponentMap },
} = useEditorConfigContext()
// Sanitize custom fields here
const validRelationships = config.collections.map((c) => c.slug) || []
const fieldSchema = sanitizeFields({
// TODO: fix this
// @ts-expect-error-next-line
config,
fields: fieldSchemaUnsanitized,
validRelationships,
})
const componentMapRenderedFieldsPath = `feature.upload.fields.${relatedCollection.slug}`
const schemaFieldsPath = `${schemaPath}.feature.upload.${relatedCollection.slug}`
const fieldMap = richTextComponentMap.get(componentMapRenderedFieldsPath) // Field Schema
useEffect(() => {
const awaitInitialState = async () => {
const state = await getFormState({
apiRoute: config.routes.api,
body: {
id,
data: deepCopyObject(fields || {}),
operation: 'update',
schemaPath: schemaFieldsPath,
},
serverURL: config.serverURL,
}) // Form State
setInitialState(state)
}
void awaitInitialState()
}, [config.routes.api, config.serverURL, schemaFieldsPath, id, fields])
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
return await getFormState({
apiRoute: config.routes.api,
body: {
id,
formState: prevFormState,
operation: 'update',
schemaPath: schemaFieldsPath,
},
serverURL: config.serverURL,
})
},
[config.routes.api, config.serverURL, schemaFieldsPath, id],
)
const handleUpdateEditData = useCallback(
(_, data) => {
@@ -88,33 +118,6 @@ export const ExtraFieldsUploadDrawer: React.FC<
[closeModal, editor, drawerSlug, nodeKey],
)
useEffect(() => {
// Sanitize custom fields here
const validRelationships = config.collections.map((c) => c.slug) || []
const fieldSchema = sanitizeFields({
// TODO: fix this
// @ts-expect-error-next-line
config,
fields: fieldSchemaUnsanitized,
validRelationships,
})
const awaitInitialState = async () => {
const preferences = await getDocPreferences()
const state = await buildStateFromSchema({
data: deepCopyObject(fields || {}),
fieldSchema,
locale,
operation: 'update',
preferences,
req,
})
setInitialState(state)
}
void awaitInitialState()
}, [user, locale, t, getDocPreferences, fields, fieldSchemaUnsanitized, config])
return (
<Drawer
slug={drawerSlug}
@@ -122,11 +125,20 @@ export const ExtraFieldsUploadDrawer: React.FC<
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
>
<Form initialState={initialState} onSubmit={handleUpdateEditData}>
[RenderFields]
{/* <RenderFields fieldSchema={fieldSchema} fieldTypes={fieldTypes} readOnly={false} /> */}
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
</Form>
{initialState !== false && (
<FieldPathProvider path="" schemaPath="">
<Form
fields={fieldMap}
initialState={initialState}
onChange={[onChange]}
onSubmit={handleUpdateEditData}
uuid={uuid()}
>
<RenderFields fieldMap={fieldMap} forceRender readOnly={false} />
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
</Form>
</FieldPathProvider>
)}
</Drawer>
)
}

View File

@@ -18,7 +18,8 @@ import {
import { $getNodeByKey } from 'lexical'
import React, { useCallback, useReducer, useState } from 'react'
import type { UploadFeatureProps } from '..'
import type { ClientComponentProps } from '../../types'
import type { UploadFeaturePropsClient } from '../feature.client'
import type { UploadData } from '../nodes/UploadNode'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
@@ -95,9 +96,10 @@ const Component: React.FC<ElementProps> = (props) => {
[setParams, cacheBust, closeDrawer],
)
const customFields = (
editorConfig?.resolvedFeatureMap?.get('upload')?.props as UploadFeatureProps
)?.collections?.[relatedCollection.slug]?.fields
const hasExtraFields = (
editorConfig?.resolvedFeatureMap?.get('upload')
?.clientFeatureProps as ClientComponentProps<UploadFeaturePropsClient>
).collections?.[relatedCollection.slug]?.hasExtraFields
return (
<div
@@ -111,16 +113,14 @@ const Component: React.FC<ElementProps> = (props) => {
</div>
<div className={`${baseClass}__topRowRightPanel`}>
<div className={`${baseClass}__collectionLabel`}>
{/* TODO: fix this */}
{/* @ts-expect-error-next-line */}
{getTranslation(relatedCollection.labels.singular, i18n)}
</div>
{editor.isEditable() && (
<div className={`${baseClass}__actions`}>
{customFields?.length > 0 && (
{hasExtraFields ? (
<DrawerToggler
className={`${baseClass}__upload-drawer-toggler`}
disabled={field?.admin?.readOnly}
disabled={field?.readOnly}
slug={drawerSlug}
>
<Button
@@ -134,11 +134,11 @@ const Component: React.FC<ElementProps> = (props) => {
tooltip={t('fields:editRelationship')}
/>
</DrawerToggler>
)}
) : null}
<Button
buttonStyle="icon-label"
disabled={field?.admin?.readOnly}
disabled={field?.readOnly}
el="div"
icon="swap"
onClick={() => {
@@ -152,7 +152,7 @@ const Component: React.FC<ElementProps> = (props) => {
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
disabled={field?.admin?.readOnly}
disabled={field?.readOnly}
icon="x"
onClick={(e) => {
e.preventDefault()

View File

@@ -77,11 +77,11 @@ const UploadDrawerComponent: React.FC<Props> = ({ enabledCollectionSlugs }) => {
}, [editor, openDrawer])
const onSelect = useCallback(
({ collectionConfig, docID }) => {
({ collectionSlug, docID }) => {
insertUpload({
id: docID,
editor,
relationTo: collectionConfig.slug,
relationTo: collectionSlug,
replaceNodeKey,
})
closeDrawer()

View File

@@ -0,0 +1,56 @@
'use client'
import type { FeatureProviderProviderClient } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types'
import { UploadIcon } from '../../lexical/ui/icons/Upload'
import { createClientComponent } from '../createClientComponent'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './drawer/commands'
import { UploadNode } from './nodes/UploadNode'
import { UploadPlugin } from './plugin'
export type UploadFeaturePropsClient = {
collections: {
[collection: string]: {
hasExtraFields: boolean
}
}
}
const UploadFeatureClient: FeatureProviderProviderClient<UploadFeaturePropsClient> = (props) => {
return {
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
nodes: [UploadNode],
plugins: [
{
Component: UploadPlugin,
position: 'normal',
},
],
slashMenu: {
options: [
{
displayName: 'Basic',
key: 'basic',
options: [
new SlashMenuOption('upload', {
Icon: UploadIcon,
displayName: 'Upload',
keywords: ['upload', 'image', 'file', 'img', 'picture', 'photo', 'media'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_UPLOAD_WITH_DRAWER_COMMAND, {
replace: false,
})
},
}),
],
},
],
},
}),
}
}
export const UploadFeatureClientComponent = createClientComponent(UploadFeatureClient)

View File

@@ -3,12 +3,11 @@ import type { Field } from 'payload/types'
import payload from 'payload'
import type { HTMLConverter } from '../converters/html/converter/types'
import type { FeatureProvider } from '../types'
import type { SerializedUploadNode } from './nodes/UploadNode'
import type { FeatureProviderProviderServer } from '../types'
import type { UploadFeaturePropsClient } from './feature.client'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './drawer/commands'
import { UploadNode } from './nodes/UploadNode'
import { UploadFeatureClientComponent } from './feature.client'
import { type SerializedUploadNode, UploadNode } from './nodes/UploadNode'
import { uploadPopulationPromiseHOC } from './populationPromise'
import { uploadValidation } from './validate'
@@ -27,13 +26,43 @@ function getAbsoluteURL(url: string): string {
return url?.startsWith('http') ? url : (payload?.config?.serverURL || '') + url
}
export const UploadFeature = (props?: UploadFeatureProps): FeatureProvider => {
export const UploadFeature: FeatureProviderProviderServer<
UploadFeatureProps,
UploadFeaturePropsClient
> = (props) => {
if (!props) {
props = { collections: {} }
}
const clientProps: UploadFeaturePropsClient = { collections: {} }
if (props.collections) {
for (const collection in props.collections) {
clientProps.collections[collection] = {
hasExtraFields: props.collections[collection].fields.length >= 1,
}
}
}
return {
feature: () => {
return {
ClientComponent: UploadFeatureClientComponent,
clientFeatureProps: clientProps,
generateSchemaMap: ({ props }) => {
if (!props?.collections) return {}
const map: {
[key: string]: Field[]
} = {}
for (const collection in props.collections) {
map[collection] = props.collections[collection].fields
}
return map
},
nodes: [
{
type: UploadNode.getType(),
converters: {
html: {
converter: async ({ node }) => {
@@ -96,39 +125,10 @@ export const UploadFeature = (props?: UploadFeatureProps): FeatureProvider => {
validations: [uploadValidation()],
},
],
plugins: [
{
Component: () =>
// @ts-expect-error-next-line
import('./plugin').then((module) => module.UploadPlugin),
position: 'normal',
},
],
props,
slashMenu: {
options: [
{
displayName: 'Basic',
key: 'basic',
options: [
new SlashMenuOption('upload', {
Icon: () =>
// @ts-expect-error-next-line
import('../../lexical/ui/icons/Upload').then((module) => module.UploadIcon),
displayName: 'Upload',
keywords: ['upload', 'image', 'file', 'img', 'picture', 'photo', 'media'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_UPLOAD_WITH_DRAWER_COMMAND, {
replace: false,
})
},
}),
],
},
],
},
serverFeatureProps: props,
}
},
key: 'upload',
serverFeatureProps: props,
}
}

View File

@@ -1,4 +1,5 @@
import type { UploadFeatureProps } from '.'
import type { UploadFeatureProps } from '@payloadcms/richtext-lexical'
import type { PopulationPromise } from '../types'
import type { SerializedUploadNode } from './nodes/UploadNode'

View File

@@ -7,20 +7,26 @@ import type { SerializedUploadNode } from './nodes/UploadNode'
import { CAN_USE_DOM } from '../../lexical/utils/canUseDOM'
export const uploadValidation = (): NodeValidation<SerializedUploadNode> => {
const uploadValidation: NodeValidation<SerializedUploadNode> = async ({
const uploadValidation: NodeValidation<SerializedUploadNode> = ({
node,
payloadConfig,
validation,
validation: {
options: {
req: {
payload: { config, db },
t,
},
},
},
}) => {
if (!CAN_USE_DOM) {
const idField = payloadConfig.collections
const idField = config.collections
.find(({ slug }) => slug === node.relationTo)
.fields.find((field) => fieldAffectsData(field) && field.name === 'id')
const type = getIDType(idField, validation?.options?.payload?.db?.defaultIDType)
const type = getIDType(idField, db?.defaultIDType)
if (!isValidID(node.value?.id, type)) {
return validation.options.t('validation:validUploadID')
return t('validation:validUploadID')
}
}

View File

@@ -20,7 +20,7 @@ import { OrderedListFeature } from '../../../features/lists/orderedlist/feature.
import { UnorderedListFeature } from '../../../features/lists/unorderedlist/feature.server'
import { ParagraphFeature } from '../../../features/paragraph/feature.server'
import { RelationshipFeature } from '../../../features/relationship/feature.server'
import { UploadFeature } from '../../../features/upload'
import { UploadFeature } from '../../../features/upload/feature.server'
import { LexicalEditorTheme } from '../../theme/EditorTheme'
import { sanitizeServerEditorConfig } from './sanitize'

View File

@@ -346,9 +346,9 @@ export type {
ServerFeature,
ServerFeatureProviderMap,
} from './field/features/types'
export { UploadFeature } from './field/features/upload'
export { UploadFeature } from './field/features/upload/feature.server'
export type { UploadFeatureProps } from './field/features/upload'
export type { UploadFeatureProps } from './field/features/upload/feature.server'
export type { RawUploadPayload } from './field/features/upload/nodes/UploadNode'

View File

@@ -17,6 +17,7 @@ import {
TreeViewFeature,
UnderlineFeature,
UnorderedListFeature,
UploadFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import path from 'path'
@@ -97,7 +98,14 @@ export function buildConfigWithDefaults(testConfig?: Partial<Config>): Promise<S
features: [
ParagraphFeature(),
RelationshipFeature(),
LinkFeature(),
LinkFeature({
fields: [
{
name: 'description',
type: 'text',
},
],
}),
CheckListFeature(),
UnorderedListFeature(),
OrderedListFeature(),
@@ -105,6 +113,18 @@ export function buildConfigWithDefaults(testConfig?: Partial<Config>): Promise<S
BlockQuoteFeature(),
BoldFeature(),
ItalicFeature(),
UploadFeature({
collections: {
media: {
fields: [
{
name: 'alt',
type: 'text',
},
],
},
},
}),
UnderlineFeature(),
StrikethroughFeature(),
SubscriptFeature(),