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
This commit is contained in:
Jarrod Flesch
2022-01-21 10:15:51 -05:00
committed by GitHub
parent d07bb932ca
commit 0e4eb906f2
23 changed files with 885 additions and 259 deletions

View File

@@ -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;

View File

@@ -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 (
<span className={`${baseClass}__content`}>
{tooltip && (
<Tooltip className={`${baseClass}__tooltip`}>
{tooltip}
</Tooltip>
)}
{children && (
<span className={`${baseClass}__label`}>
{children}
@@ -55,6 +63,7 @@ const Button: React.FC<Props> = (props) => {
size = 'medium',
iconPosition = 'right',
newTab,
tooltip,
} = props;
const classes = [
@@ -68,6 +77,7 @@ const Button: React.FC<Props> = (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> = (props) => {
{...buttonProps}
to={to || url}
>
<ButtonContents icon={icon}>
<ButtonContents
icon={icon}
tooltip={tooltip}
>
{children}
</ButtonContents>
</Link>
@@ -102,7 +115,10 @@ const Button: React.FC<Props> = (props) => {
{...buttonProps}
href={url}
>
<ButtonContents icon={icon}>
<ButtonContents
icon={icon}
tooltip={tooltip}
>
{children}
</ButtonContents>
</a>
@@ -114,7 +130,10 @@ const Button: React.FC<Props> = (props) => {
type="submit"
{...buttonProps}
>
<ButtonContents icon={icon}>
<ButtonContents
icon={icon}
tooltip={tooltip}
>
{children}
</ButtonContents>
</button>

View File

@@ -16,4 +16,5 @@ export type Props = {
size?: 'small' | 'medium',
iconPosition?: 'left' | 'right',
newTab?: boolean
tooltip?: string
}

View File

@@ -12,6 +12,7 @@
line-height: base(.75);
font-weight: normal;
white-space: nowrap;
border-radius: 2px;
span {
position: absolute;

View File

@@ -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;
}

View File

@@ -80,6 +80,7 @@ const RichText: React.FC<Props> = (props) => {
attributes={attributes}
element={element}
path={path}
fieldProps={props}
>
{children}
</Element>
@@ -87,7 +88,7 @@ const RichText: React.FC<Props> = (props) => {
}
return <div {...attributes}>{children}</div>;
}, [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> = (props) => {
attributes={attributes}
leaf={leaf}
path={path}
fieldProps={props}
>
{children}
</Leaf>
@@ -109,7 +111,7 @@ const RichText: React.FC<Props> = (props) => {
return (
<span {...attributes}>{children}</span>
);
}, [enabledLeaves, path]);
}, [enabledLeaves, path, props]);
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });

View File

@@ -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;
}
}
}

View File

@@ -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<Props> = ({ 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 (
<Modal
slug={slug}
className={baseClass}
>
<MinimalTemplate width="normal">
<header className={`${baseClass}__header`}>
<h1>
Edit
{' '}
{relatedCollectionConfig.labels.singular}
{' '}
data
</h1>
<Button
icon="x"
round
buttonStyle="icon-label"
onClick={closeModal}
/>
</header>
<div>
<Form
onSubmit={handleUpdateEditData}
initialState={initialState}
>
<RenderFields
readOnly={false}
fieldTypes={fieldTypes}
fieldSchema={fieldSchema}
/>
<Submit>
Save changes
</Submit>
</Form>
</div>
</MinimalTemplate>
</Modal>
);
};

View File

@@ -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;
}
}
}

View File

@@ -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<Props> = ({ 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<number>();
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 (
<Modal
className={baseClass}
slug={slug}
>
<MinimalTemplate width="wide">
<header className={`${baseClass}__header`}>
<h1>
Choose
{' '}
{modalCollection.labels.singular}
</h1>
<Button
icon="x"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={closeModal}
/>
</header>
{
moreThanOneAvailableCollection && (
<div className={`${baseClass}__select-collection-wrap`}>
<Label label="Select a Collection to Browse" />
<ReactSelect
className={`${baseClass}__select-collection`}
value={modalCollectionOption}
onChange={setModalCollectionOption}
options={availableCollections.map((coll) => ({ label: coll.labels.singular, value: coll.slug }))}
/>
</div>
)
}
<ListControls
collection={
{
...modalCollection,
fields,
}
}
enableColumns={false}
enableSort
modifySearchQuery={false}
handleSortChange={setSort}
handleWhereChange={setWhere}
/>
<UploadGallery
docs={data?.docs}
collection={modalCollection}
onCardClick={(doc) => {
handleUpdateUpload(doc);
setRelatedCollectionConfig(modalCollection);
closeModal();
}}
/>
<div className={`${baseClass}__page-controls`}>
<Paginator
limit={data.limit}
totalPages={data.totalPages}
page={data.page}
hasPrevPage={data.hasPrevPage}
hasNextPage={data.hasNextPage}
prevPage={data.prevPage}
nextPage={data.nextPage}
numberOfNeighbors={1}
onChange={setPage}
disableHistoryChange
/>
{data?.totalDocs > 0 && (
<React.Fragment>
<div className={`${baseClass}__page-info`}>
{data.page}
-
{data.totalPages > 1 ? data.limit : data.totalDocs}
{' '}
of
{' '}
{data.totalDocs}
</div>
<PerPage
collection={modalCollection}
limit={limit}
modifySearchParams={false}
handleChange={setLimit}
/>
</React.Fragment>
)}
</div>
</MinimalTemplate>
</Modal>
);
};

View File

@@ -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;

View File

@@ -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<SanitizedCollectionConfig>(() => 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<SanitizedCollectionConfig>(relatedCollection);
const [fields, setFields] = useState(() => formatFields(modalCollection));
const [limit, setLimit] = useState<number>();
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 (
<div
@@ -123,127 +71,86 @@ const Element = ({ attributes, children, element, path }) => {
contentEditable={false}
{...attributes}
>
<div className={`${baseClass}__thumbnail`}>
{thumbnailSRC && (
<img
src={thumbnailSRC}
alt={upload?.filename}
/>
)}
{!thumbnailSRC && (
<FileGraphic />
)}
</div>
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__label`}>
{relatedCollection.labels.singular}
</div>
<h5>{upload?.filename}</h5>
</div>
<Button
icon="edit"
round
buttonStyle="icon-label"
iconStyle="with-border"
className={`${baseClass}__button`}
onClick={(e) => {
e.preventDefault();
setRenderModal(true);
}}
/>
{children}
{renderModal && (
<Modal
className={baseModalClass}
slug={modalSlug}
>
{isOpen && (
<MinimalTemplate width="wide">
<header className={`${baseModalClass}__header`}>
<h1>
Choose
{' '}
{modalCollection.labels.singular}
</h1>
<div className={`${baseClass}__card`}>
<div className={`${baseClass}__topRow`}>
<div className={`${baseClass}__thumbnail`}>
{thumbnailSRC ? (
<img
src={thumbnailSRC}
alt={upload?.filename}
/>
) : (
<FileGraphic />
)}
</div>
<div className={`${baseClass}__topRowRightPanel`}>
<div className={`${baseClass}__collectionLabel`}>
{relatedCollection.labels.singular}
</div>
<div className={`${baseClass}__actions`}>
{fieldSchema && (
<Button
icon="x"
icon="edit"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={() => {
closeAll();
setRenderModal(false);
className={`${baseClass}__actionButton`}
onClick={(e) => {
e.preventDefault();
setModalToRender('edit');
}}
tooltip="Upload Fields"
/>
</header>
{moreThanOneAvailableCollection && (
<div className={`${baseModalClass}__select-collection-wrap`}>
<Label label="Select a Collection to Browse" />
<ReactSelect
className={`${baseClass}__select-collection`}
value={modalCollectionOption}
onChange={setModalCollectionOption}
options={availableCollections.map((coll) => ({ label: coll.labels.singular, value: coll.slug }))}
/>
</div>
)}
<ListControls
collection={{
...modalCollection,
fields,
<Button
icon="swap"
round
buttonStyle="icon-label"
className={`${baseClass}__actionButton`}
onClick={(e) => {
e.preventDefault();
setModalToRender('swap');
}}
enableColumns={false}
enableSort
modifySearchQuery={false}
handleSortChange={setSort}
handleWhereChange={setWhere}
tooltip="Swap Upload"
/>
<UploadGallery
docs={data?.docs}
collection={modalCollection}
onCardClick={(doc) => {
handleUpdateUpload(doc);
setRelatedCollection(modalCollection);
setRenderModal(false);
closeAll();
<Button
icon="x"
round
buttonStyle="icon-label"
className={`${baseClass}__actionButton`}
onClick={(e) => {
e.preventDefault();
removeUpload();
}}
tooltip="Remove Upload"
/>
<div className={`${baseModalClass}__page-controls`}>
<Paginator
limit={data.limit}
totalPages={data.totalPages}
page={data.page}
hasPrevPage={data.hasPrevPage}
hasNextPage={data.hasNextPage}
prevPage={data.prevPage}
nextPage={data.nextPage}
numberOfNeighbors={1}
onChange={setPage}
disableHistoryChange
/>
{data?.totalDocs > 0 && (
<Fragment>
<div className={`${baseModalClass}__page-info`}>
{data.page}
-
{data.totalPages > 1 ? data.limit : data.totalDocs}
{' '}
of
{' '}
{data.totalDocs}
</div>
<PerPage
collection={modalCollection}
limit={limit}
modifySearchParams={false}
handleChange={setLimit}
/>
</Fragment>
)}
</div>
</MinimalTemplate>
)}
</Modal>
</div>
</div>
</div>
<div className={`${baseClass}__bottomRow`}>
<h5>{upload?.filename}</h5>
</div>
</div>
{children}
{modalToRender === 'swap' && (
<SwapUploadModal
slug={modalSlug}
element={element}
closeModal={closeModal}
setRelatedCollectionConfig={setRelatedCollection}
relatedCollectionConfig={relatedCollection}
/>
)}
{(modalToRender === 'edit' && fieldSchema) && (
<EditModal
slug={modalSlug}
closeModal={closeModal}
relatedCollectionConfig={relatedCollection}
fieldSchema={fieldSchema}
element={element}
/>
)}
</div>
);

View File

@@ -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
//////////////////////////////

View File

@@ -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')),
})),
}),
}),
});

View File

@@ -223,6 +223,13 @@ export type RichTextField = FieldBase & {
elements?: RichTextElement[];
leaves?: RichTextLeaf[];
hideGutter?: boolean;
upload?: {
collections: {
[collection: string]: {
fields: Field[];
}
}
}
}
}

View File

@@ -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<Arguments, 'field'> & {
id: string,
field: Field
collection: Collection
}): Promise<void> => {
let dataRef = data as Record<string, unknown>;
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;
}
};

View File

@@ -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<void>[]
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,
});
}
});
}
});
};

View File

@@ -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<string, unknown>;
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,

View File

@@ -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<string, any>
fullData: Record<string, any>
validationPromises: (() => Promise<string | boolean>)[]
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') {

View File

@@ -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: {