feat: allows rich text links to link to other docs

This commit is contained in:
James
2022-09-07 16:42:43 -07:00
parent cdfc0dec70
commit a99d9c98c3
15 changed files with 492 additions and 273 deletions

View File

@@ -234,6 +234,7 @@ const RichText: React.FC<Props> = (props) => {
if (Button) {
return (
<Button
fieldProps={props}
key={i}
path={path}
/>
@@ -253,6 +254,7 @@ const RichText: React.FC<Props> = (props) => {
if (Button) {
return (
<Button
fieldProps={props}
key={i}
path={path}
/>

View File

@@ -0,0 +1,127 @@
import React, { Fragment, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import { Transforms, Editor, Range } from 'slate';
import { useModal } from '@faceless-ui/modal';
import ElementButton from '../Button';
import { unwrapLink } from './utilities';
import LinkIcon from '../../../../../icons/Link';
import { EditModal } from './Modal';
import { modalSlug } from './shared';
import isElementActive from '../isActive';
import { Fields } from '../../../../Form/types';
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
import { useAuth } from '../../../../../utilities/Auth';
import { useLocale } from '../../../../../utilities/Locale';
import { useConfig } from '../../../../../utilities/Config';
import { getBaseFields } from './Modal/baseFields';
import { Field } from '../../../../../../../fields/config/types';
export const LinkButton = ({ fieldProps }) => {
const customFieldSchema = fieldProps?.admin?.link?.fields;
const config = useConfig();
const editor = useSlate();
const { user } = useAuth();
const locale = useLocale();
const { open, closeAll } = useModal();
const [renderModal, setRenderModal] = useState(false);
const [initialState, setInitialState] = useState<Fields>({});
const [fieldSchema] = useState(() => {
const fields: Field[] = [
...getBaseFields(config),
];
if (customFieldSchema) {
fields.push({
name: 'fields',
type: 'group',
admin: {
style: {
margin: 0,
padding: 0,
borderTop: 0,
borderBottom: 0,
},
},
fields: customFieldSchema,
});
}
return fields;
});
return (
<Fragment>
<ElementButton
format="link"
onClick={async () => {
if (isElementActive(editor, 'link')) {
unwrapLink(editor);
} else {
open(modalSlug);
setRenderModal(true);
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
if (!isCollapsed) {
const data = {
text: Editor.string(editor, editor.selection),
};
const state = await buildStateFromSchema({ fieldSchema, data, user, operation: 'create', locale });
setInitialState(state);
}
}
}}
>
<LinkIcon />
</ElementButton>
{renderModal && (
<EditModal
fieldSchema={fieldSchema}
initialState={initialState}
close={() => {
closeAll();
setRenderModal(false);
}}
handleModalSubmit={(_, data) => {
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
const newLink = {
type: 'link',
linkType: data.linkType,
url: data.url,
doc: data.doc,
newTab: data.newTab,
fields: data.fields,
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);
}}
/>
)}
</Fragment>
);
};

View File

@@ -0,0 +1,198 @@
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import { Transforms, Node, Editor } from 'slate';
import { useModal } from '@faceless-ui/modal';
import { unwrapLink } from './utilities';
import Popup from '../../../../../elements/Popup';
import { EditModal } from './Modal';
import { modalSlug } from './shared';
import { Fields } from '../../../../Form/types';
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
import { useAuth } from '../../../../../utilities/Auth';
import { useLocale } from '../../../../../utilities/Locale';
import { useConfig } from '../../../../../utilities/Config';
import { getBaseFields } from './Modal/baseFields';
import { Field } from '../../../../../../../fields/config/types';
import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues';
import deepCopyObject from '../../../../../../../utilities/deepCopyObject';
import './index.scss';
const baseClass = 'rich-text-link';
// TODO: Multiple modal windows stacked go boom (rip). Edit Upload in fields -> rich text
export const LinkElement = ({ attributes, children, element, editorRef, fieldProps }) => {
const customFieldSchema = fieldProps?.admin?.link?.fields;
const editor = useSlate();
const config = useConfig();
const { user } = useAuth();
const locale = useLocale();
const { open, closeAll } = useModal();
const [renderModal, setRenderModal] = useState(false);
const [renderPopup, setRenderPopup] = useState(false);
const [initialState, setInitialState] = useState<Fields>({});
const [fieldSchema] = useState(() => {
const fields: Field[] = [
...getBaseFields(config),
];
if (customFieldSchema) {
fields.push({
name: 'fields',
type: 'group',
admin: {
style: {
margin: 0,
padding: 0,
borderTop: 0,
borderBottom: 0,
},
},
fields: customFieldSchema,
});
}
return fields;
});
const handleTogglePopup = useCallback((render) => {
if (!render) {
setRenderPopup(render);
}
}, []);
useEffect(() => {
const awaitInitialState = async () => {
const data = {
text: Node.string(element),
linkType: element.linkType,
url: element.url,
doc: element.doc,
newTab: element.newTab,
fields: deepCopyObject(element.fields),
};
const state = await buildStateFromSchema({ fieldSchema, data, user, operation: 'update', locale });
setInitialState(state);
};
awaitInitialState();
}, [renderModal, element, fieldSchema, user, locale]);
return (
<span
className={baseClass}
{...attributes}
>
<span
style={{ userSelect: 'none' }}
contentEditable={false}
>
{renderModal && (
<EditModal
fieldSchema={fieldSchema}
close={() => {
closeAll();
setRenderModal(false);
}}
handleModalSubmit={(fields) => {
closeAll();
setRenderModal(false);
const data = reduceFieldsToValues(fields, true);
const [, parentPath] = Editor.above(editor);
const newNode: Record<string, unknown> = {
newTab: data.newTab,
url: data.url,
linkType: data.linkType,
doc: data.doc,
};
if (customFieldSchema) {
newNode.fields = data.fields;
}
Transforms.setNodes(
editor,
newNode,
{ 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);
}}
initialState={initialState}
/>
)}
<Popup
buttonType="none"
size="small"
forceOpen={renderPopup}
onToggleOpen={handleTogglePopup}
horizontalAlign="left"
verticalAlign="bottom"
boundingRef={editorRef}
render={() => (
<div className={`${baseClass}__popup`}>
{element.linkType === 'internal' && (
<Fragment>
Linked to doc&nbsp;
</Fragment>
)}
{(element.linkType === 'custom' || !element.linkType) && (
<Fragment>
Go to link:&nbsp;
<a
className={`${baseClass}__goto-link`}
href={element.url}
target="_blank"
rel="noreferrer"
>
{element.url}
</a>
</Fragment>
)}
&mdash;
<button
type="button"
onClick={() => {
setRenderPopup(false);
open(modalSlug);
setRenderModal(true);
}}
>
Change
</button>
<button
type="button"
onClick={() => {
unwrapLink(editor);
}}
>
Remove
</button>
</div>
)}
/>
</span>
<span
tabIndex={0}
role="button"
className={[
`${baseClass}__button`,
].filter(Boolean).join(' ')}
onKeyDown={(e) => { if (e.key === 'Enter') setRenderPopup(true); }}
onClick={() => setRenderPopup(true)}
>
{children}
</span>
</span>
);
};

View File

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

View File

@@ -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<Props> = ({ close, handleModalSubmit, initialData }) => {
const inputRef = useRef<HTMLInputElement>();
useEffect(() => {
if (inputRef?.current) {
inputRef.current.focus();
}
}, []);
export const EditModal: React.FC<Props> = ({
close,
handleModalSubmit,
initialState,
fieldSchema,
}) => {
return (
<Modal
slug={modalSlug}
@@ -40,28 +37,12 @@ export const EditModal: React.FC<Props> = ({ close, handleModalSubmit, initialDa
</header>
<Form
onSubmit={handleModalSubmit}
initialData={initialData}
initialState={initialState}
>
<Text
inputRef={inputRef}
required
name="text"
label="Text to display"
admin={{
className: `${baseClass}__input`,
}}
/>
<Text
required
name="url"
label="Enter a URL"
admin={{
className: `${baseClass}__input`,
}}
/>
<Checkbox
label="Open in new tab"
name="newTab"
<RenderFields
fieldTypes={fieldTypes}
readOnly={false}
fieldSchema={fieldSchema}
/>
<FormSubmit>
Confirm

View File

@@ -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<string, unknown>) => void
initialData?: Record<string, unknown>
initialState?: Fields
fieldSchema: Field[]
}

View File

@@ -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<Record<string, unknown>>({});
const handleTogglePopup = useCallback((render) => {
if (!render) {
setRenderPopup(render);
}
}, []);
useEffect(() => {
setInitialData({
newTab: element.newTab,
text: Node.string(element),
url: element.url,
});
}, [renderModal, element]);
return (
<span
className={baseClass}
{...attributes}
>
<span
style={{ userSelect: 'none' }}
contentEditable={false}
>
{renderModal && (
<EditModal
close={() => {
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}
/>
)}
<Popup
buttonType="none"
size="small"
forceOpen={renderPopup}
onToggleOpen={handleTogglePopup}
horizontalAlign="left"
verticalAlign="bottom"
boundingRef={editorRef}
render={() => (
<div className={`${baseClass}__popup`}>
Go to link:&nbsp;
<a
className={`${baseClass}__goto-link`}
href={element.url}
target="_blank"
rel="noreferrer"
>
{element.url}
</a>
&mdash;
<button
type="button"
onClick={() => {
setRenderPopup(false);
open(modalSlug);
setRenderModal(true);
}}
>
Change
</button>
<button
type="button"
onClick={() => {
unwrapLink(editor);
}}
>
Remove
</button>
</div>
)}
/>
</span>
<span
tabIndex={0}
role="button"
className={[
`${baseClass}__button`,
].filter(Boolean).join(' ')}
onKeyDown={(e) => { if (e.key === 'Enter') setRenderPopup(true); }}
onClick={() => setRenderPopup(true)}
>
{children}
</span>
</span>
);
};
const LinkButton = () => {
const editor = useSlate();
const { open, closeAll } = useModal();
const [renderModal, setRenderModal] = useState(false);
const [initialData, setInitialData] = useState<Record<string, unknown>>({});
return (
<Fragment>
<ElementButton
format="link"
onClick={() => {
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),
});
}
}
}}
>
<LinkIcon />
</ElementButton>
{renderModal && (
<EditModal
initialData={initialData}
close={() => {
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);
}}
/>
)}
</Fragment>
);
};
import { withLinks } from './utilities';
import { LinkButton } from './Button';
import { LinkElement } from './Element';
const link = {
Button: LinkButton,
Element: Link,
Element: LinkElement,
plugins: [
withLinks,
],

View File

@@ -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<Props> = ({ 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<Props> = ({ slug, closeModal, relatedCollection
fieldTypes={fieldTypes}
fieldSchema={fieldSchema}
/>
<Submit>
Save changes
</Submit>

View File

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

View File

@@ -293,6 +293,9 @@ export type RichTextField = FieldBase & {
}
}
}
link?: {
fields?: Field[];
}
}
}

View File

@@ -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 () => {

View File

@@ -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<void>[]

View File

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

View File

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

View File

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