diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index c30ba2fb1..cadd9f4be 100644 --- a/src/admin/components/forms/field-types/RichText/RichText.tsx +++ b/src/admin/components/forms/field-types/RichText/RichText.tsx @@ -234,6 +234,7 @@ const RichText: React.FC = (props) => { if (Button) { return ( + + + )} + /> + + { if (e.key === 'Enter') setRenderPopup(true); }} + onClick={() => setRenderPopup(true)} + > + {children} + + + ); +}; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/baseFields.ts b/src/admin/components/forms/field-types/RichText/elements/link/Modal/baseFields.ts new file mode 100644 index 000000000..0b0e8a67b --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/baseFields.ts @@ -0,0 +1,59 @@ +import { Config } from '../../../../../../../../config/types'; +import { Field } from '../../../../../../../../fields/config/types'; + +export const getBaseFields = (config: Config): Field[] => [ + { + name: 'text', + label: 'Text to display', + type: 'text', + required: true, + }, + { + name: 'linkType', + label: 'Link Type', + type: 'radio', + required: true, + admin: { + description: 'Choose between entering a custom text URL or linking to another document.', + }, + defaultValue: 'custom', + options: [ + { + label: 'Custom URL', + value: 'custom', + }, + { + label: 'Internal Link', + value: 'internal', + }, + ], + }, + { + name: 'url', + label: 'Enter a URL', + type: 'text', + required: true, + admin: { + condition: ({ linkType, url }) => { + return (typeof linkType === 'undefined' && url) || linkType === 'custom'; + }, + }, + }, + { + name: 'doc', + label: 'Choose a document to link to', + type: 'relationship', + required: true, + relationTo: config.collections.map(({ slug }) => slug), + admin: { + condition: ({ linkType }) => { + return linkType === 'internal'; + }, + }, + }, + { + name: 'newTab', + label: 'Open in new tab', + type: 'checkbox', + }, +]; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx index af41fb376..6c59ff650 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx @@ -1,28 +1,25 @@ import { Modal } from '@faceless-ui/modal'; -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import { MinimalTemplate } from '../../../../../..'; import Button from '../../../../../../elements/Button'; import X from '../../../../../../icons/X'; import Form from '../../../../../Form'; import FormSubmit from '../../../../../Submit'; -import Checkbox from '../../../../Checkbox'; -import Text from '../../../../Text'; import { Props } from './types'; import { modalSlug } from '../shared'; +import fieldTypes from '../../../..'; +import RenderFields from '../../../../../RenderFields'; import './index.scss'; const baseClass = modalSlug; -export const EditModal: React.FC = ({ close, handleModalSubmit, initialData }) => { - const inputRef = useRef(); - - useEffect(() => { - if (inputRef?.current) { - inputRef.current.focus(); - } - }, []); - +export const EditModal: React.FC = ({ + close, + handleModalSubmit, + initialState, + fieldSchema, +}) => { return ( = ({ close, handleModalSubmit, initialDa
- - - Confirm diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts b/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts index 5e85b35cb..5582e102c 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts @@ -1,7 +1,9 @@ +import { Field } from '../../../../../../../../fields/config/types'; import { Fields } from '../../../../../Form/types'; export type Props = { close: () => void handleModalSubmit: (fields: Fields, data: Record) => void - initialData?: Record + initialState?: Fields + fieldSchema: Field[] } diff --git a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx index d7857f43d..63484613d 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx @@ -1,216 +1,10 @@ -import React, { Fragment, useCallback, useEffect, useState } from 'react'; -import { ReactEditor, useSlate } from 'slate-react'; -import { Transforms, Node, Editor, Range } from 'slate'; -import { useModal } from '@faceless-ui/modal'; -import ElementButton from '../Button'; -import { unwrapLink, withLinks } from './utilities'; -import LinkIcon from '../../../../../icons/Link'; -import Popup from '../../../../../elements/Popup'; -import { EditModal } from './Modal'; -import { modalSlug } from './shared'; -import isElementActive from '../isActive'; - -import './index.scss'; - -const baseClass = 'rich-text-link'; - -// TODO: Multiple modal windows stacked go boom (rip). Edit Upload in fields -> rich text - -const Link = ({ attributes, children, element, editorRef }) => { - const editor = useSlate(); - const { open, closeAll } = useModal(); - const [renderModal, setRenderModal] = useState(false); - const [renderPopup, setRenderPopup] = useState(false); - const [initialData, setInitialData] = useState>({}); - - const handleTogglePopup = useCallback((render) => { - if (!render) { - setRenderPopup(render); - } - }, []); - - useEffect(() => { - setInitialData({ - newTab: element.newTab, - text: Node.string(element), - url: element.url, - }); - }, [renderModal, element]); - - return ( - - - {renderModal && ( - { - closeAll(); - setRenderModal(false); - }} - handleModalSubmit={(_, data) => { - closeAll(); - setRenderModal(false); - - const [, parentPath] = Editor.above(editor); - - Transforms.setNodes( - editor, - { - newTab: data.newTab, - url: data.url, - }, - { at: parentPath }, - ); - - Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' }); - Transforms.move(editor, { distance: 1, unit: 'offset' }); - Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); - - ReactEditor.focus(editor); - }} - initialData={initialData} - /> - )} - ( -
- Go to link:  - - {element.url} - - — - - -
- )} - /> -
- { if (e.key === 'Enter') setRenderPopup(true); }} - onClick={() => setRenderPopup(true)} - > - {children} - -
- ); -}; - -const LinkButton = () => { - const editor = useSlate(); - const { open, closeAll } = useModal(); - const [renderModal, setRenderModal] = useState(false); - const [initialData, setInitialData] = useState>({}); - - return ( - - { - if (isElementActive(editor, 'link')) { - unwrapLink(editor); - } else { - open(modalSlug); - setRenderModal(true); - - const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); - - if (!isCollapsed) { - setInitialData({ - text: Editor.string(editor, editor.selection), - }); - } - } - }} - > - - - {renderModal && ( - { - closeAll(); - setRenderModal(false); - }} - handleModalSubmit={(_, data) => { - const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); - - const newLink = { - type: 'link', - url: data.url, - newTab: data.newTab, - children: [], - }; - - if (isCollapsed) { - // If selection anchor and focus are the same, - // Just inject a new node with children already set - Transforms.insertNodes(editor, { - ...newLink, - children: [{ text: String(data.text) }], - }); - } else { - // Otherwise we need to wrap the selected node in a link, - // Delete its old text, - // Move the selection one position forward into the link, - // And insert the text back into the new link - Transforms.wrapNodes(editor, newLink, { split: true }); - Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' }); - Transforms.move(editor, { distance: 1, unit: 'offset' }); - Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); - } - - closeAll(); - setRenderModal(false); - - ReactEditor.focus(editor); - }} - /> - )} - - ); -}; +import { withLinks } from './utilities'; +import { LinkButton } from './Button'; +import { LinkElement } from './Element'; const link = { Button: LinkButton, - Element: Link, + Element: LinkElement, plugins: [ withLinks, ], 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 index 71e61656c..433e1300b 100644 --- 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 @@ -10,7 +10,6 @@ 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 { useLocale } from '../../../../../../../utilities/Locale'; @@ -34,9 +33,9 @@ export const EditModal: React.FC = ({ slug, closeModal, relatedCollection const { user } = useAuth(); const locale = useLocale(); - const handleUpdateEditData = useCallback((fields) => { + const handleUpdateEditData = useCallback((_, data) => { const newNode = { - fields: reduceFieldsToValues(fields, true), + fields: data, }; const elementPath = ReactEditor.findPath(editor, element); @@ -90,7 +89,6 @@ export const EditModal: React.FC = ({ slug, closeModal, relatedCollection fieldTypes={fieldTypes} fieldSchema={fieldSchema} /> - Save changes diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index ada1bdfc5..a1f0b3603 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -346,6 +346,9 @@ export const richText = baseField.keys({ fields: joi.array().items(joi.link('#field')), })), }), + link: joi.object({ + fields: joi.array().items(joi.link('#field')), + }), }), }); diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 824c50eb9..63e410f3a 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -293,6 +293,9 @@ export type RichTextField = FieldBase & { } } } + link?: { + fields?: Field[]; + } } } diff --git a/src/fields/hooks/afterRead/promise.ts b/src/fields/hooks/afterRead/promise.ts index dea6bceae..95c7ff4c5 100644 --- a/src/fields/hooks/afterRead/promise.ts +++ b/src/fields/hooks/afterRead/promise.ts @@ -2,7 +2,7 @@ import { Field, fieldAffectsData } from '../../config/types'; import { PayloadRequest } from '../../../express/types'; import { traverseFields } from './traverseFields'; -import richTextRelationshipPromise from '../../richText/relationshipPromise'; +import richTextRelationshipPromise from '../../richText/richTextRelationshipPromise'; import relationshipPopulationPromise from './relationshipPopulationPromise'; type Args = { @@ -47,11 +47,11 @@ export const promise = async ({ } const hasLocalizedValue = flattenLocales - && fieldAffectsData(field) - && (typeof siblingDoc[field.name] === 'object' && siblingDoc[field.name] !== null) - && field.name - && field.localized - && req.locale !== 'all'; + && fieldAffectsData(field) + && (typeof siblingDoc[field.name] === 'object' && siblingDoc[field.name] !== null) + && field.name + && field.localized + && req.locale !== 'all'; if (hasLocalizedValue) { let localizedValue = siblingDoc[field.name][req.locale]; @@ -110,8 +110,8 @@ export const promise = async ({ await priorHook; const shouldRunHookOnAllLocales = field.localized - && (req.locale === 'all' || !flattenLocales) - && typeof siblingDoc[field.name] === 'object'; + && (req.locale === 'all' || !flattenLocales) + && typeof siblingDoc[field.name] === 'object'; if (shouldRunHookOnAllLocales) { const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) => (async () => { diff --git a/src/fields/richText/recurseNestedFields.ts b/src/fields/richText/recurseNestedFields.ts index ce884fc6f..eaac69edf 100644 --- a/src/fields/richText/recurseNestedFields.ts +++ b/src/fields/richText/recurseNestedFields.ts @@ -2,7 +2,7 @@ import { Field, fieldHasSubFields, fieldIsArrayType, fieldAffectsData } from '../config/types'; import { PayloadRequest } from '../../express/types'; import { populate } from './populate'; -import { recurseRichText } from './relationshipPromise'; +import { recurseRichText } from './richTextRelationshipPromise'; type NestedRichTextFieldsArgs = { promises: Promise[] diff --git a/src/fields/richText/relationshipPromise.ts b/src/fields/richText/richTextRelationshipPromise.ts similarity index 62% rename from src/fields/richText/relationshipPromise.ts rename to src/fields/richText/richTextRelationshipPromise.ts index f6a4bfbc0..e97f474dd 100644 --- a/src/fields/richText/relationshipPromise.ts +++ b/src/fields/richText/richTextRelationshipPromise.ts @@ -36,12 +36,26 @@ export const recurseRichText = ({ }: RecurseRichTextArgs): void => { if (Array.isArray(children)) { (children as any[]).forEach((element) => { - const collection = req.payload.collections[element?.relationTo]; - if ((element.type === 'relationship' || element.type === 'upload') && element?.value?.id - && collection && (depth && currentDepth <= depth)) { + const collection = req.payload.collections[element?.relationTo]; + + if (collection) { + promises.push(populate({ + req, + id: element.value.id, + data: element, + key: 'value', + overrideAccess, + depth, + currentDepth, + field, + collection, + showHiddenFields, + })); + } + if (element.type === 'upload' && Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)) { recurseNestedFields({ promises, @@ -54,18 +68,40 @@ export const recurseRichText = ({ showHiddenFields, }); } - promises.push(populate({ - req, - id: element.value.id, - data: element, - key: 'value', - overrideAccess, - depth, - currentDepth, - field, - collection, - showHiddenFields, - })); + } + + if (element.type === 'link') { + if (element?.doc?.value && element?.doc?.relationTo) { + const collection = req.payload.collections[element?.doc?.relationTo]; + + if (collection) { + promises.push(populate({ + req, + id: element.doc.value, + data: element.doc, + key: 'value', + overrideAccess, + depth, + currentDepth, + field, + collection, + showHiddenFields, + })); + } + } + + if (Array.isArray(field.admin?.link?.fields)) { + recurseNestedFields({ + promises, + data: element.fields || {}, + fields: field.admin?.link?.fields, + req, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } } if (element?.children) { diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index 40cfa5eb1..84aa31229 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -42,7 +42,7 @@ import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; import withNullableType from './withNullableType'; import { toWords } from '../../utilities/formatLabels'; -import createRichTextRelationshipPromise from '../../fields/richText/relationshipPromise'; +import createRichTextRelationshipPromise from '../../fields/richText/richTextRelationshipPromise'; import formatOptions from '../utilities/formatOptions'; import { Payload } from '../..'; import buildWhereInputType from './buildWhereInputType'; diff --git a/test/fields/collections/RichText/index.ts b/test/fields/collections/RichText/index.ts index 538a4c1a3..88585ec30 100644 --- a/test/fields/collections/RichText/index.ts +++ b/test/fields/collections/RichText/index.ts @@ -43,6 +43,22 @@ const RichTextFields: CollectionConfig = { type: 'richText', required: true, admin: { + link: { + fields: [ + { + name: 'rel', + label: 'Rel Attribute', + type: 'select', + hasMany: true, + options: [ + 'noopener', 'noreferrer', 'nofollow', + ], + admin: { + description: 'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.', + }, + }, + ], + }, upload: { collections: { uploads: {