From 0e4eb906f2881dca518fea6b41e460bc57da9801 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Fri, 21 Jan 2022 10:15:51 -0500 Subject: [PATCH] feat: enhances rich text upload with custom field API * feat: adds admin.upload.collections[collection-name].fields to the RTE to save specific data on upload elements * chore: renames flatten to unflatten in reduceFieldsToValues, disables automatic arrow function return in eslint * docs: adds documentation for upload.collections[collection-name].fields feature * feat: adds recursion to richText field to populate relationship and upload nested fields * chore: removes unused css * fix: import path for createRichTextRelationshipPromise * docs: updates docs to include images for the RTE upload docs --- .eslintrc.js | 1 + demo/collections/RichText.ts | 50 ++++ docs/fields/rich-text.mdx | 24 +- .../components/elements/Button/index.scss | 20 ++ .../components/elements/Button/index.tsx | 27 +- src/admin/components/elements/Button/types.ts | 1 + .../components/elements/Tooltip/index.scss | 1 + .../forms/Form/reduceFieldsToValues.ts | 8 +- .../forms/field-types/RichText/RichText.tsx | 6 +- .../upload/Element/EditModal/index.scss | 25 ++ .../upload/Element/EditModal/index.tsx | 98 +++++++ .../upload/Element/SwapUploadModal/index.scss | 25 ++ .../upload/Element/SwapUploadModal/index.tsx | 184 ++++++++++++ .../elements/upload/Element/index.scss | 75 +++-- .../elements/upload/Element/index.tsx | 271 ++++++------------ src/admin/scss/vars.scss | 4 + src/fields/config/schema.ts | 5 + src/fields/config/types.ts | 7 + src/fields/richText/populate.ts | 54 ++++ src/fields/richText/recurseNestedFields.ts | 183 ++++++++++++ .../relationshipPromise.ts} | 65 ++--- src/fields/traverseFields.ts | 8 +- src/graphql/schema/buildObjectType.ts | 2 +- 23 files changed, 885 insertions(+), 259 deletions(-) create mode 100644 src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.scss create mode 100644 src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx create mode 100644 src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.scss create mode 100644 src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.tsx create mode 100644 src/fields/richText/populate.ts create mode 100644 src/fields/richText/recurseNestedFields.ts rename src/fields/{richTextRelationshipPromise.ts => richText/relationshipPromise.ts} (68%) diff --git a/.eslintrc.js b/.eslintrc.js index 575d038773..53ef3f3d7b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -51,6 +51,7 @@ module.exports = { 'react/no-unused-prop-types': 'off', 'no-underscore-dangle': 'off', 'no-use-before-define': 'off', + 'arrow-body-style': 0, '@typescript-eslint/no-use-before-define': ['error'], 'import/extensions': [ 'error', diff --git a/demo/collections/RichText.ts b/demo/collections/RichText.ts index e51996dfaa..72d8df5a1d 100644 --- a/demo/collections/RichText.ts +++ b/demo/collections/RichText.ts @@ -17,6 +17,56 @@ const RichText: CollectionConfig = { type: 'richText', label: 'Default Rich Text', required: true, + admin: { + upload: { + collections: { + media: { + fields: [ + { + type: 'textarea', + name: 'caption', + label: 'Caption', + }, + { + type: 'row', + fields: [ + { + type: 'relationship', + relationTo: 'admins', + name: 'linkToAdmin', + label: 'Link to Admin', + }, + { + type: 'select', + name: 'imageAlignment', + label: 'Image Alignment', + options: [ + { + label: 'Left', + value: 'left', + }, + { + label: 'Center', + value: 'center', + }, + { + label: 'Right', + value: 'right', + }, + ], + }, + ], + }, + { + type: 'checkbox', + name: 'wrapText', + label: 'Wrap Text', + }, + ], + }, + }, + }, + }, }, { name: 'customRichText', diff --git a/docs/fields/rich-text.mdx b/docs/fields/rich-text.mdx index 88174900f1..b86fd08760 100644 --- a/docs/fields/rich-text.mdx +++ b/docs/fields/rich-text.mdx @@ -77,6 +77,16 @@ The default `leaves` available in Payload are: Set this property to `true` to hide this field's gutter within the admin panel. The field gutter is rendered as a vertical line and padding, but often if this field is nested within a Group, Block, or Array, you may want to hide the gutter. +**`upload.collections[collection-name].fields`** + +This allows [fields](/docs/fields/overview) to be saved as meta data on an upload field inside the Rich Text Editor. When this is present, the fields will render inside a modal that can be opened by clicking the "edit" button on the upload element. + +![RichText upload element](https://payloadcms.com/images/fields/richText/rte-upload-element.jpg) +*RichText field using the upload element* + +![RichText upload element modal](https://payloadcms.com/images/fields/richText/rte-upload-fields-modal.jpg) +*RichText upload element modal displaying fields from the config* + ### Relationship element The built-in `relationship` element is a powerful way to reference other Documents directly within your Rich Text editor. @@ -143,7 +153,7 @@ Custom `Leaf` objects follow a similar pattern but require you to define the `Le ] } ], - elements: [ + leaves: [ 'bold', 'italic', { @@ -154,7 +164,17 @@ Custom `Leaf` objects follow a similar pattern but require you to define the `Le // any plugins that are required by this leaf go here ] } - ] + ], + upload: { + collections: { + media: { + fields: [ + // any fields that you would like to save + // on an upload element in the `media` collection + ], + }, + }, + }, } } ] diff --git a/src/admin/components/elements/Button/index.scss b/src/admin/components/elements/Button/index.scss index afaa090d90..c189f12652 100644 --- a/src/admin/components/elements/Button/index.scss +++ b/src/admin/components/elements/Button/index.scss @@ -20,6 +20,26 @@ @include color-svg(currentColor); } + &--has-tooltip { + position: relative; + + } + + .btn__tooltip { + opacity: 0; + visibility: hidden; + transform: translate(-50%, -10px); + } + + .btn__content { + &:hover { + .btn__tooltip { + opacity: 1; + visibility: visible; + } + } + } + &--icon-style-without-border { .btn__icon { border: none; diff --git a/src/admin/components/elements/Button/index.tsx b/src/admin/components/elements/Button/index.tsx index ec859765d5..96dd5c0cab 100644 --- a/src/admin/components/elements/Button/index.tsx +++ b/src/admin/components/elements/Button/index.tsx @@ -6,6 +6,8 @@ import plus from '../../icons/Plus'; import x from '../../icons/X'; import chevron from '../../icons/Chevron'; import edit from '../../icons/Edit'; +import swap from '../../icons/Swap'; +import Tooltip from '../Tooltip'; import './index.scss'; @@ -14,15 +16,21 @@ const icons = { x, chevron, edit, + swap, }; const baseClass = 'btn'; -const ButtonContents = ({ children, icon }) => { +const ButtonContents = ({ children, icon, tooltip }) => { const BuiltInIcon = icons[icon]; return ( + {tooltip && ( + + {tooltip} + + )} {children && ( {children} @@ -55,6 +63,7 @@ const Button: React.FC = (props) => { size = 'medium', iconPosition = 'right', newTab, + tooltip, } = props; const classes = [ @@ -68,6 +77,7 @@ const Button: React.FC = (props) => { round && `${baseClass}--round`, size && `${baseClass}--size-${size}`, iconPosition && `${baseClass}--icon-position-${iconPosition}`, + tooltip && `${baseClass}--has-tooltip`, ].filter(Boolean).join(' '); function handleClick(event) { @@ -90,7 +100,10 @@ const Button: React.FC = (props) => { {...buttonProps} to={to || url} > - + {children} @@ -102,7 +115,10 @@ const Button: React.FC = (props) => { {...buttonProps} href={url} > - + {children} @@ -114,7 +130,10 @@ const Button: React.FC = (props) => { type="submit" {...buttonProps} > - + {children} diff --git a/src/admin/components/elements/Button/types.ts b/src/admin/components/elements/Button/types.ts index 01aa28dc35..2ddce72171 100644 --- a/src/admin/components/elements/Button/types.ts +++ b/src/admin/components/elements/Button/types.ts @@ -16,4 +16,5 @@ export type Props = { size?: 'small' | 'medium', iconPosition?: 'left' | 'right', newTab?: boolean + tooltip?: string } diff --git a/src/admin/components/elements/Tooltip/index.scss b/src/admin/components/elements/Tooltip/index.scss index 807fb723b2..e64558fcf0 100644 --- a/src/admin/components/elements/Tooltip/index.scss +++ b/src/admin/components/elements/Tooltip/index.scss @@ -12,6 +12,7 @@ line-height: base(.75); font-weight: normal; white-space: nowrap; + border-radius: 2px; span { position: absolute; diff --git a/src/admin/components/forms/Form/reduceFieldsToValues.ts b/src/admin/components/forms/Form/reduceFieldsToValues.ts index 6fbe19bbc0..56dbdeec17 100644 --- a/src/admin/components/forms/Form/reduceFieldsToValues.ts +++ b/src/admin/components/forms/Form/reduceFieldsToValues.ts @@ -1,7 +1,7 @@ -import { unflatten } from 'flatley'; +import { unflatten as flatleyUnflatten } from 'flatley'; import { Fields, Data } from './types'; -const reduceFieldsToValues = (fields: Fields, flatten?: boolean): Data => { +const reduceFieldsToValues = (fields: Fields, unflatten?: boolean): Data => { const data = {}; Object.keys(fields).forEach((key) => { @@ -14,8 +14,8 @@ const reduceFieldsToValues = (fields: Fields, flatten?: boolean): Data => { } }); - if (flatten) { - const unflattened = unflatten(data, { safe: true }); + if (unflatten) { + const unflattened = flatleyUnflatten(data, { safe: true }); return unflattened; } diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index d76498e9db..81f803d88c 100644 --- a/src/admin/components/forms/field-types/RichText/RichText.tsx +++ b/src/admin/components/forms/field-types/RichText/RichText.tsx @@ -80,6 +80,7 @@ const RichText: React.FC = (props) => { attributes={attributes} element={element} path={path} + fieldProps={props} > {children} @@ -87,7 +88,7 @@ const RichText: React.FC = (props) => { } return
{children}
; - }, [enabledElements, path]); + }, [enabledElements, path, props]); const renderLeaf = useCallback(({ attributes, children, leaf }) => { const matchedLeafName = Object.keys(enabledLeaves).find((leafName) => leaf[leafName]); @@ -100,6 +101,7 @@ const RichText: React.FC = (props) => { attributes={attributes} leaf={leaf} path={path} + fieldProps={props} > {children} @@ -109,7 +111,7 @@ const RichText: React.FC = (props) => { return ( {children} ); - }, [enabledLeaves, path]); + }, [enabledLeaves, path, props]); const memoizedValidate = useCallback((value) => { const validationResult = validate(value, { required }); diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.scss b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.scss new file mode 100644 index 0000000000..325f357c8f --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.scss @@ -0,0 +1,25 @@ +@import '../../../../../../../../scss/styles.scss'; + +.edit-upload-modal { + @include blur-bg; + display: flex; + align-items: center; + + .template-minimal { + padding-top: base(4); + align-items: flex-start; + } + + &__header { + margin-bottom: $baseline; + display: flex; + + h1 { + margin: 0 auto 0 0; + } + + .btn { + margin: 0 0 0 $baseline; + } + } +} diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx new file mode 100644 index 0000000000..29b946d1a2 --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Transforms, Element } from 'slate'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { Modal } from '@faceless-ui/modal'; +import { SanitizedCollectionConfig } from '../../../../../../../../../collections/config/types'; +import buildStateFromSchema from '../../../../../../Form/buildStateFromSchema'; +import MinimalTemplate from '../../../../../../../templates/Minimal'; +import Button from '../../../../../../../elements/Button'; +import RenderFields from '../../../../../../RenderFields'; +import fieldTypes from '../../../../..'; +import Form from '../../../../../../Form'; +import reduceFieldsToValues from '../../../../../../Form/reduceFieldsToValues'; +import Submit from '../../../../../../Submit'; +import { Field } from '../../../../../../../../../fields/config/types'; + +import './index.scss'; + +const baseClass = 'edit-upload-modal'; + +type Props = { + slug: string + closeModal: () => void + relatedCollectionConfig: SanitizedCollectionConfig + fieldSchema: Field[] + element: Element & { + fields: Field[] + } +} +export const EditModal: React.FC = ({ slug, closeModal, relatedCollectionConfig, fieldSchema, element }) => { + const editor = useSlateStatic(); + const [initialState, setInitialState] = useState({}); + + const handleUpdateEditData = useCallback((fields) => { + const newNode = { + fields: reduceFieldsToValues(fields, true), + }; + + const elementPath = ReactEditor.findPath(editor, element); + + Transforms.setNodes( + editor, + newNode, + { at: elementPath }, + ); + closeModal(); + }, [closeModal, editor, element]); + + useEffect(() => { + const awaitInitialState = async () => { + const state = await buildStateFromSchema(fieldSchema, element?.fields); + setInitialState(state); + }; + + awaitInitialState(); + }, [fieldSchema, element.fields]); + + return ( + + +
+

+ Edit + {' '} + {relatedCollectionConfig.labels.singular} + {' '} + data +

+
+ +
+
+ + + + Save changes + + +
+
+
+ ); +}; diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.scss b/src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.scss new file mode 100644 index 0000000000..03b114662b --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.scss @@ -0,0 +1,25 @@ +@import '../../../../../../../../scss/styles.scss'; + +.swap-upload-modal { + @include blur-bg; + display: flex; + align-items: center; + + .template-minimal { + padding-top: base(4); + align-items: flex-start; + } + + &__header { + margin-bottom: $baseline; + display: flex; + + h1 { + margin: 0 auto 0 0; + } + + .btn { + margin: 0 0 0 $baseline; + } + } +} diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.tsx new file mode 100644 index 0000000000..e62d553fd7 --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.tsx @@ -0,0 +1,184 @@ +import * as React from 'react'; +import { Modal } from '@faceless-ui/modal'; +import { useConfig } from '@payloadcms/config-provider'; +import { Element, Transforms } from 'slate'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { SanitizedCollectionConfig } from '../../../../../../../../../collections/config/types'; +import usePayloadAPI from '../../../../../../../../hooks/usePayloadAPI'; +import MinimalTemplate from '../../../../../../../templates/Minimal'; +import Button from '../../../../../../../elements/Button'; +import Label from '../../../../../../Label'; +import ReactSelect from '../../../../../../../elements/ReactSelect'; +import ListControls from '../../../../../../../elements/ListControls'; +import UploadGallery from '../../../../../../../elements/UploadGallery'; +import Paginator from '../../../../../../../elements/Paginator'; +import PerPage from '../../../../../../../elements/PerPage'; +import formatFields from '../../../../../../../views/collections/List/formatFields'; + +import './index.scss'; + +const baseClass = 'swap-upload-modal'; + +type Props = { + slug: string + element: Element + closeModal: () => void + setRelatedCollectionConfig: (collectionConfig: SanitizedCollectionConfig) => void + relatedCollectionConfig: SanitizedCollectionConfig +} +export const SwapUploadModal: React.FC = ({ closeModal, element, setRelatedCollectionConfig, relatedCollectionConfig, slug }) => { + const { collections, serverURL, routes: { api } } = useConfig(); + const editor = useSlateStatic(); + + const [modalCollection, setModalCollection] = React.useState(relatedCollectionConfig); + const [modalCollectionOption, setModalCollectionOption] = React.useState<{ label: string, value: string }>({ label: relatedCollectionConfig.labels.singular, value: relatedCollectionConfig.slug }); + const [availableCollections] = React.useState(() => collections.filter(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship))); + const [fields, setFields] = React.useState(() => formatFields(modalCollection)); + + const [limit, setLimit] = React.useState(); + const [sort, setSort] = React.useState(null); + const [where, setWhere] = React.useState(null); + const [page, setPage] = React.useState(null); + + const moreThanOneAvailableCollection = availableCollections.length > 1; + + const apiURL = `${serverURL}${api}/${modalCollection.slug}`; + const [{ data }, { setParams }] = usePayloadAPI(apiURL, {}); + + const handleUpdateUpload = React.useCallback((doc) => { + const newNode = { + type: 'upload', + value: { id: doc.id }, + relationTo: modalCollection.slug, + children: [ + { text: ' ' }, + ], + }; + + const elementPath = ReactEditor.findPath(editor, element); + + Transforms.setNodes( + editor, + newNode, + { at: elementPath }, + ); + closeModal(); + }, [closeModal, editor, element, modalCollection]); + + React.useEffect(() => { + const params: { + page?: number + sort?: string + where?: unknown + limit?: number + } = {}; + + if (page) params.page = page; + if (where) params.where = where; + if (sort) params.sort = sort; + if (limit) params.limit = limit; + + setParams(params); + }, [setParams, page, sort, where, limit]); + + React.useEffect(() => { + setFields(formatFields(modalCollection)); + setLimit(modalCollection.admin.pagination.defaultLimit); + }, [modalCollection]); + + React.useEffect(() => { + setModalCollection(collections.find(({ slug: collectionSlug }) => modalCollectionOption.value === collectionSlug)); + }, [modalCollectionOption, collections]); + + return ( + + +
+

+ Choose + {' '} + {modalCollection.labels.singular} +

+
+ { + moreThanOneAvailableCollection && ( +
+
+ ) + } + + { + handleUpdateUpload(doc); + setRelatedCollectionConfig(modalCollection); + closeModal(); + }} + /> +
+ + {data?.totalDocs > 0 && ( + +
+ {data.page} + - + {data.totalPages > 1 ? data.limit : data.totalDocs} + {' '} + of + {' '} + {data.totalDocs} +
+ +
+ )} +
+
+
+ ); +}; diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.scss b/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.scss index aa157ff4b9..7c2e8784ca 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.scss @@ -4,14 +4,68 @@ max-width: base(15); display: flex; align-items: center; - background: $color-background-gray; + background: white; position: relative; - &__button { + &__card { + @include soft-shadow-bottom; + display: flex; + flex-direction: column; + width: 100%; + } + + &__topRow { + display: flex; + } + + &__thumbnail { + width: base(3.25); + height: auto; + position: relative; + + img, svg { + position: absolute; + object-fit: cover; + width: 100%; + height: 100%; + background-color: $color-dark-gray; + } + } + + &__topRowRightPanel { + flex-grow: 1; + display: flex; + align-items: center; + padding: base(.75) base(1); + justify-content: space-between; + } + + &__actions { + display: flex; + align-items: center; + } + + &__actionButton { margin: 0; - position: absolute; - top: base(.5); - right: base(.5); + margin-right: base(.5); + border-radius: 0; + + line { + stroke-width: $style-stroke-width-m; + } + + &:last-child { + margin-right: 0; + } + } + + &__collectionLabel { + margin-right: base(3); + } + + &__bottomRow { + padding: base(.5); + border-top: 1px solid $color-background-gray; } h5 { @@ -20,17 +74,6 @@ overflow: hidden; } - &__thumbnail { - width: base(4); - max-height: base(4); - - img, svg { - object-fit: cover; - width: 100%; - height: 100%; - } - } - &__wrap { padding: base(.5) base(.5) base(.5) base(1); text-align: left; diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.tsx index c18432eacf..233e518380 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.tsx @@ -1,55 +1,36 @@ -import React, { Fragment, useState, useEffect, useCallback } from 'react'; -import { Modal, useModal } from '@faceless-ui/modal'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useModal } from '@faceless-ui/modal'; import { Transforms } from 'slate'; import { ReactEditor, useSlateStatic, useFocused, useSelected } from 'slate-react'; import { useConfig } from '@payloadcms/config-provider'; import usePayloadAPI from '../../../../../../../hooks/usePayloadAPI'; import FileGraphic from '../../../../../../graphics/File'; import useThumbnail from '../../../../../../../hooks/useThumbnail'; -import MinimalTemplate from '../../../../../../templates/Minimal'; -import UploadGallery from '../../../../../../elements/UploadGallery'; -import ListControls from '../../../../../../elements/ListControls'; import Button from '../../../../../../elements/Button'; -import ReactSelect from '../../../../../../elements/ReactSelect'; -import Paginator from '../../../../../../elements/Paginator'; -import formatFields from '../../../../../../views/collections/List/formatFields'; import { SanitizedCollectionConfig } from '../../../../../../../../collections/config/types'; -import PerPage from '../../../../../../elements/PerPage'; -import Label from '../../../../../Label'; +import { SwapUploadModal } from './SwapUploadModal'; import './index.scss'; -import '../modal.scss'; +import { EditModal } from './EditModal'; const baseClass = 'rich-text-upload'; -const baseModalClass = 'rich-text-upload-modal'; const initialParams = { depth: 0, }; -const Element = ({ attributes, children, element, path }) => { +const Element = ({ attributes, children, element, path, fieldProps }) => { const { relationTo, value } = element; - const { closeAll, currentModal, open } = useModal(); + const { closeAll, open } = useModal(); const { collections, serverURL, routes: { api } } = useConfig(); - const [availableCollections] = useState(() => collections.filter(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship))); - const [renderModal, setRenderModal] = useState(false); + const [modalToRender, setModalToRender] = useState(undefined); const [relatedCollection, setRelatedCollection] = useState(() => collections.find((coll) => coll.slug === relationTo)); - const [modalCollectionOption, setModalCollectionOption] = useState<{ label: string, value: string}>({ label: relatedCollection.labels.singular, value: relatedCollection.slug }); - const [modalCollection, setModalCollection] = useState(relatedCollection); - - const [fields, setFields] = useState(() => formatFields(modalCollection)); - const [limit, setLimit] = useState(); - const [sort, setSort] = useState(null); - const [where, setWhere] = useState(null); - const [page, setPage] = useState(null); const editor = useSlateStatic(); const selected = useSelected(); const focused = useFocused(); - const modalSlug = `${path}-edit-upload`; - const isOpen = currentModal === modalSlug; - const moreThanOneAvailableCollection = availableCollections.length > 1; + const modalSlug = `${path}-edit-upload-${modalToRender}`; // Get the referenced document const [{ data: upload }] = usePayloadAPI( @@ -57,62 +38,29 @@ const Element = ({ attributes, children, element, path }) => { { initialParams }, ); - // If modal is open, get active page of upload gallery - const apiURL = isOpen ? `${serverURL}${api}/${modalCollection.slug}` : null; - const [{ data }, { setParams }] = usePayloadAPI(apiURL, {}); - const thumbnailSRC = useThumbnail(relatedCollection, upload); - const handleUpdateUpload = useCallback((doc) => { - const newNode = { - type: 'upload', - value: { id: doc.id }, - relationTo: modalCollection.slug, - children: [ - { text: ' ' }, - ], - }; - + const removeUpload = useCallback(() => { const elementPath = ReactEditor.findPath(editor, element); - Transforms.setNodes( + Transforms.removeNodes( editor, - newNode, { at: elementPath }, ); + }, [editor, element]); + + const closeModal = useCallback(() => { closeAll(); - }, [closeAll, editor, element, modalCollection]); + setModalToRender(null); + }, [closeAll]); useEffect(() => { - setFields(formatFields(modalCollection)); - setLimit(modalCollection.admin.pagination.defaultLimit); - }, [modalCollection]); - - useEffect(() => { - if (renderModal && modalSlug) { - open(modalSlug); + if (modalToRender && modalSlug) { + open(`${modalSlug}`); } - }, [renderModal, open, modalSlug]); + }, [modalToRender, open, modalSlug]); - useEffect(() => { - const params: { - page?: number - sort?: string - where?: unknown - limit?: number - } = {}; - - if (page) params.page = page; - if (where) params.where = where; - if (sort) params.sort = sort; - if (limit) params.limit = limit; - - setParams(params); - }, [setParams, page, sort, where, limit]); - - useEffect(() => { - setModalCollection(collections.find(({ slug }) => modalCollectionOption.value === slug)); - }, [modalCollectionOption, collections]); + const fieldSchema = fieldProps?.admin?.upload?.collections?.[relatedCollection.slug]?.fields; return (
{ contentEditable={false} {...attributes} > -
- {thumbnailSRC && ( - {upload?.filename} - )} - {!thumbnailSRC && ( - - )} -
-
-
- {relatedCollection.labels.singular} -
-
{upload?.filename}
-
-
+ + + +
+
{upload?.filename}
+
+ + {children} + + {modalToRender === 'swap' && ( + + )} + + {(modalToRender === 'edit' && fieldSchema) && ( + )} ); diff --git a/src/admin/scss/vars.scss b/src/admin/scss/vars.scss index 1c75379db7..f16dea02d9 100644 --- a/src/admin/scss/vars.scss +++ b/src/admin/scss/vars.scss @@ -107,6 +107,10 @@ $focus-box-shadow: 0 0 0 $style-stroke-width-m $color-green; } } +@mixin soft-shadow-bottom { + box-shadow: 0 7px 14px 0px rgb(0 0 0 / 5%); +} + ////////////////////////////// // STYLE MIXINS ////////////////////////////// diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index a655ce4d0e..bacb00ebf0 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -263,6 +263,11 @@ export const richText = baseField.keys({ ), ), hideGutter: joi.boolean().default(false), + upload: joi.object({ + collections: joi.object().pattern(joi.string(), joi.object().keys({ + fields: joi.array().items(joi.link('#field')), + })), + }), }), }); diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index c492999003..2f3a402a7d 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -223,6 +223,13 @@ export type RichTextField = FieldBase & { elements?: RichTextElement[]; leaves?: RichTextLeaf[]; hideGutter?: boolean; + upload?: { + collections: { + [collection: string]: { + fields: Field[]; + } + } + } } } diff --git a/src/fields/richText/populate.ts b/src/fields/richText/populate.ts new file mode 100644 index 0000000000..37d6f9cc72 --- /dev/null +++ b/src/fields/richText/populate.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { Collection } from '../../collections/config/types'; +import { Payload } from '../..'; +import { RichTextField, Field } from '../config/types'; +import { PayloadRequest } from '../../express/types'; + +type Arguments = { + data: unknown + overrideAccess?: boolean + depth: number + currentDepth?: number + payload: Payload + field: RichTextField + req: PayloadRequest + showHiddenFields: boolean +} + +export const populate = async ({ + id, + collection, + data, + overrideAccess, + depth, + currentDepth, + payload, + req, + showHiddenFields, +}: Omit & { + id: string, + field: Field + collection: Collection +}): Promise => { + let dataRef = data as Record; + + const doc = await payload.operations.collections.findByID({ + req: { + ...req, + payloadAPI: 'local', + }, + collection, + id, + currentDepth: currentDepth + 1, + overrideAccess, + disableErrors: true, + depth, + showHiddenFields, + }); + + if (doc) { + dataRef = doc; + } else { + dataRef = null; + } +}; diff --git a/src/fields/richText/recurseNestedFields.ts b/src/fields/richText/recurseNestedFields.ts new file mode 100644 index 0000000000..accc6e1caf --- /dev/null +++ b/src/fields/richText/recurseNestedFields.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { Payload } from '../..'; +import { Field, fieldHasSubFields, fieldIsArrayType, fieldAffectsData } from '../config/types'; +import { PayloadRequest } from '../../express/types'; +import { populate } from './populate'; +import { recurseRichText } from './relationshipPromise'; + +type NestedRichTextFieldsArgs = { + promises: Promise[] + data: unknown + fields: Field[] + req: PayloadRequest + payload: Payload + overrideAccess: boolean + depth: number + currentDepth?: number + showHiddenFields: boolean +} + +export const recurseNestedFields = ({ + promises, + data, + fields, + req, + payload, + overrideAccess = false, + depth, + currentDepth = 0, + showHiddenFields, +}: NestedRichTextFieldsArgs): void => { + fields.forEach((field) => { + if (field.type === 'relationship' || field.type === 'upload') { + if (field.type === 'relationship') { + if (field.hasMany && Array.isArray(data[field.name])) { + if (Array.isArray(field.relationTo)) { + data[field.name].forEach(({ relationTo, value }, i) => { + const collection = payload.collections[relationTo]; + if (collection) { + promises.push(populate({ + id: value, + field, + collection, + data: data[field.name][i], + overrideAccess, + depth, + currentDepth, + payload, + req, + showHiddenFields, + })); + } + }); + } else { + data[field.name].forEach((id, i) => { + const collection = payload.collections[field.relationTo as string]; + if (collection) { + promises.push(populate({ + id, + field, + collection, + data: data[field.name][i], + overrideAccess, + depth, + currentDepth, + payload, + req, + showHiddenFields, + })); + } + }); + } + } else if (Array.isArray(field.relationTo) && data[field.name]?.value && data[field.name]?.relationTo) { + const collection = payload.collections[data[field.name].relationTo]; + promises.push(populate({ + id: data[field.name].value, + field, + collection, + data: data[field.name].value, + overrideAccess, + depth, + currentDepth, + payload, + req, + showHiddenFields, + })); + } + } else if (typeof data[field.name] !== undefined) { + const collection = payload.collections[field.relationTo]; + promises.push(populate({ + id: data[field.name], + field, + collection, + data: data[field.name], + overrideAccess, + depth, + currentDepth, + payload, + req, + showHiddenFields, + })); + } + } else if (fieldHasSubFields(field) && !fieldIsArrayType(field)) { + if (fieldAffectsData(field) && typeof data[field.name] === 'object') { + recurseNestedFields({ + promises, + data: data[field.name], + fields: field.fields, + req, + payload, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } else { + recurseNestedFields({ + promises, + data, + fields: field.fields, + req, + payload, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } + } else if (Array.isArray(data[field.name])) { + if (field.type === 'blocks') { + data[field.name].forEach((row, i) => { + const block = field.blocks.find(({ slug }) => slug === row?.blockType); + if (block) { + recurseNestedFields({ + promises, + data: data[field.name][i], + fields: block.fields, + req, + payload, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } + }); + } + + if (field.type === 'array') { + data[field.name].forEach((_, i) => { + recurseNestedFields({ + promises, + data: data[field.name][i], + fields: field.fields, + req, + payload, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + }); + } + } + + if (field.type === 'richText' && Array.isArray(data[field.name])) { + data[field.name].forEach((node) => { + if (Array.isArray(node.children)) { + recurseRichText({ + req, + children: node.children, + payload, + overrideAccess, + depth, + currentDepth, + field, + promises, + showHiddenFields, + }); + } + }); + } + }); +}; diff --git a/src/fields/richTextRelationshipPromise.ts b/src/fields/richText/relationshipPromise.ts similarity index 68% rename from src/fields/richTextRelationshipPromise.ts rename to src/fields/richText/relationshipPromise.ts index e148a3cc42..1a4d34f20c 100644 --- a/src/fields/richTextRelationshipPromise.ts +++ b/src/fields/richText/relationshipPromise.ts @@ -1,7 +1,8 @@ -import { Collection } from '../collections/config/types'; -import { Payload } from '..'; -import { RichTextField } from './config/types'; -import { PayloadRequest } from '../express/types'; +import { Payload } from '../..'; +import { RichTextField } from '../config/types'; +import { PayloadRequest } from '../../express/types'; +import { recurseNestedFields } from './recurseNestedFields'; +import { populate } from './populate'; type Arguments = { data: unknown @@ -26,44 +27,7 @@ type RecurseRichTextArgs = { showHiddenFields: boolean } -const populate = async ({ - id, - collection, - data, - overrideAccess, - depth, - currentDepth, - payload, - req, - showHiddenFields, -}: Arguments & { - id: string, - collection: Collection -}) => { - const dataRef = data as Record; - - const doc = await payload.operations.collections.findByID({ - req: { - ...req, - payloadAPI: 'local', - }, - collection, - id, - currentDepth: currentDepth + 1, - overrideAccess, - disableErrors: true, - depth, - showHiddenFields, - }); - - if (doc) { - dataRef.value = doc; - } else { - dataRef.value = null; - } -}; - -const recurseRichText = ({ +export const recurseRichText = ({ req, children, payload, @@ -73,7 +37,7 @@ const recurseRichText = ({ field, promises, showHiddenFields, -}: RecurseRichTextArgs) => { +}: RecurseRichTextArgs): void => { if (Array.isArray(children)) { (children as any[]).forEach((element) => { const collection = payload.collections[element?.relationTo]; @@ -82,10 +46,23 @@ const recurseRichText = ({ && element?.value?.id && collection && (depth && currentDepth <= depth)) { + if (element.type === 'upload' && Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)) { + recurseNestedFields({ + promises, + data: element.fields || {}, + fields: field.admin.upload.collections[element.relationTo].fields, + req, + payload, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } promises.push(populate({ req, id: element.value.id, - data: element, + data: element.value, overrideAccess, depth, currentDepth, diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts index bdd5647b43..0ab7cbdedb 100644 --- a/src/fields/traverseFields.ts +++ b/src/fields/traverseFields.ts @@ -5,7 +5,7 @@ import { Field, fieldHasSubFields, fieldIsArrayType, fieldIsBlockType, fieldAffe import { Operation } from '../types'; import { PayloadRequest } from '../express/types'; import { Payload } from '..'; -import richTextRelationshipPromise from './richTextRelationshipPromise'; +import richTextRelationshipPromise from './richText/relationshipPromise'; type Arguments = { fields: Field[] @@ -28,7 +28,7 @@ type Arguments = { fullOriginalDoc: Record fullData: Record validationPromises: (() => Promise)[] - errors: {message: string, field: string}[] + errors: { message: string, field: string }[] payload: Payload showHiddenFields: boolean unflattenLocales: boolean @@ -96,7 +96,7 @@ const traverseFields = (args: Arguments): void => { } if ((field.type === 'upload' || field.type === 'relationship') - && (data[field.name] === '' || data[field.name] === 'none' || data[field.name] === 'null')) { + && (data[field.name] === '' || data[field.name] === 'none' || data[field.name] === 'null')) { if (field.type === 'relationship' && field.hasMany === true) { dataCopy[field.name] = []; } else { @@ -304,7 +304,7 @@ const traverseFields = (args: Arguments): void => { if (field.type === 'relationship' || field.type === 'upload') { if (Array.isArray(field.relationTo)) { if (Array.isArray(dataCopy[field.name])) { - dataCopy[field.name].forEach((relatedDoc: {value: unknown, relationTo: string}, i) => { + dataCopy[field.name].forEach((relatedDoc: { value: unknown, relationTo: string }, i) => { const relatedCollection = payload.config.collections.find((collection) => collection.slug === relatedDoc.relationTo); const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id'); if (relationshipIDField?.type === 'number') { diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index 1d22be100c..51600ef333 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -20,7 +20,7 @@ import combineParentName from '../utilities/combineParentName'; import withNullableType from './withNullableType'; import { BaseFields } from '../../collections/graphql/types'; import { toWords } from '../../utilities/formatLabels'; -import createRichTextRelationshipPromise from '../../fields/richTextRelationshipPromise'; +import createRichTextRelationshipPromise from '../../fields/richText/relationshipPromise'; type LocaleInputType = { locale: {