Skip to content

Commit

Permalink
feat: add embedded entity inline (#786)
Browse files Browse the repository at this point in the history
* chore: add ready only support for the new editor

* fix: key shortcut for adding embedded assets

* chore: create embedded entity inline component

* feat: bump @contentful/field-editor-reference

* feat: add keyboard shortcuts, fixes and tests

* chore: improve deps

* chore: prettier

Co-authored-by: Cezar Sampaio <cezar.sampaio@contentful.com>
  • Loading branch information
Chris Sloop and Cezar Sampaio committed Jul 29, 2021
1 parent 4b35661 commit d986450
Show file tree
Hide file tree
Showing 10 changed files with 479 additions and 20 deletions.
91 changes: 80 additions & 11 deletions cypress/integration/RichTextEditor.spec.ts
Expand Up @@ -1145,17 +1145,13 @@ describe('Rich Text Editor', () => {
cy.wait(100);
},
],

// TODO:
// This works in the browser, but not in Cypress. Maybe upgrading will help.
// For now, it's okay to ensure addition via the toolbar button works.
// [
// 'using the keyboard shortcut',
// () => {
// editor().type(`{${mod}}{shift}a`);
// cy.wait(100);
// }
// ]
[
'using the keyboard shortcut',
() => {
editor().type(`{${mod}}{shift}a`);
cy.wait(100);
}
]
];

for (const [triggerMethod, triggerEmbeddedAsset] of methods) {
Expand Down Expand Up @@ -1228,4 +1224,77 @@ describe('Rich Text Editor', () => {
});
}
});

describe('Embedded Entry Inlines', () => {
const methods: [string, () => void][] = [
[
'using the toolbar button',
() => {
cy.findByTestId('toolbar-entity-dropdown-toggle').click();
cy.findByTestId('toolbar-toggle-embedded-entry-inline').click();
cy.wait(100);
},
],
[
'using the keyboard shortcut',
() => {
editor().type(`{${mod}}{shift}2`);
cy.wait(100);
}
]
];

for (const [triggerMethod, triggerEmbeddedAsset] of methods) {
describe(triggerMethod, () => {
it('adds and removes embedded entries', () => {
editor().click().typeInSlate('hello')
triggerEmbeddedAsset();
editor().click().typeInSlate('world')

cy.wait(500);

expectRichTextFieldValue(
doc(
block(
BLOCKS.PARAGRAPH,
{},
text('hello'),
inline(
INLINES.EMBEDDED_ENTRY,
{
target: {
sys: {
id: 'example-entity-id',
type: 'Link',
linkType: 'Entry',
},
},
},
),
text('world')),
)
);

cy.findByTestId('cf-ui-card-actions').findByTestId('cf-ui-icon-button').click();
cy.findByTestId('card-action-remove').click();

cy.wait(500);

expectRichTextFieldValue(
doc(
block(
BLOCKS.PARAGRAPH,
{},
text('hello'),
text('world')
),
)
);

// TODO: we should also test deletion via {backspace},
// but this breaks in cypress even though it works in the editor
});
});
}
});
});
2 changes: 1 addition & 1 deletion packages/rich-text/package.json
Expand Up @@ -24,7 +24,7 @@
},
"dependencies": {
"@contentful/contentful-slatejs-adapter": "^14.2.0",
"@contentful/field-editor-reference": "^2.13.1",
"@contentful/field-editor-reference": "^2.20.1",
"@contentful/rich-text-types": "^15.0.0",
"@udecode/slate-plugins-common": "^1.0.0-alpha.25",
"@udecode/slate-plugins-core": "^1.0.0-alpha.25",
Expand Down
19 changes: 16 additions & 3 deletions packages/rich-text/src/RichTextEditor.tsx
Expand Up @@ -10,7 +10,13 @@ import deepEquals from 'fast-deep-equal';
import Toolbar from './Toolbar';
import StickyToolbarWrapper from './Toolbar/StickyToolbarWrapper';
import { withListOptions } from './plugins/List';
import { SlatePlugins, createHistoryPlugin, createReactPlugin } from '@udecode/slate-plugins-core';
import {
SlatePlugins,
createHistoryPlugin,
createReactPlugin,
SlatePlugin,
SPEditor,
} from '@udecode/slate-plugins-core';
import { createListPlugin } from '@udecode/slate-plugins-list';
import { createDeserializeHTMLPlugin } from '@udecode/slate-plugins-html-serializer';
import { createHrPlugin, withHrOptions } from './plugins/Hr';
Expand All @@ -30,9 +36,13 @@ import {
withEmbeddedAssetBlockOptions,
withEmbeddedEntryBlockOptions,
} from './plugins/EmbeddedEntityBlock';
import {
createEmbeddedEntityInlinePlugin,
withEmbeddedEntityInlineOptions,
} from './plugins/EmbeddedEntityInline';
import { SdkProvider } from './SdkProvider';
import { sanitizeIncomingSlateDoc, sanitizeSlateDoc } from './helpers/sanitizeSlateDoc';
import { TextOrCustomElement } from 'types';
import { TextOrCustomElement } from './types';

type ConnectedProps = {
editorId?: string;
Expand Down Expand Up @@ -67,6 +77,7 @@ const getPlugins = (sdk: FieldExtensionSDK) => {

// Inline elements
createHyperlinkPlugin(sdk),
createEmbeddedEntityInlinePlugin(sdk),

// Marks
createBoldPlugin(),
Expand All @@ -75,7 +86,7 @@ const getPlugins = (sdk: FieldExtensionSDK) => {
createUnderlinePlugin(),
];

return [...plugins, createDeserializeHTMLPlugin({ plugins })];
return plugins.concat([createDeserializeHTMLPlugin({ plugins })] as SlatePlugin<SPEditor>[]);
};

const options = {
Expand All @@ -91,6 +102,7 @@ const options = {

// Inline elements
...withHyperlinkOptions,
...withEmbeddedEntityInlineOptions,

// Marks
...withBoldOptions,
Expand Down Expand Up @@ -126,6 +138,7 @@ const ConnectedRichTextEditor = (props: ConnectedProps) => {
plugins={plugins}
editableProps={{
className: classNames,
readOnly: props.isDisabled,
}}
onChange={(newValue) => {
const slateDoc = sanitizeSlateDoc(newValue as TextOrCustomElement[]);
Expand Down
5 changes: 5 additions & 0 deletions packages/rich-text/src/Toolbar/index.tsx
Expand Up @@ -17,6 +17,7 @@ import { ToolbarIcon as EmbeddedEntityBlockToolbarIcon } from '../plugins/Embedd
import { SPEditor, useStoreEditor } from '@udecode/slate-plugins-core';
import { BLOCKS } from '@contentful/rich-text-types';
import { isNodeTypeSelected } from '../helpers/editor';
import { ToolbarEmbeddedEntityInlineButton } from '../plugins/EmbeddedEntityInline';

type ToolbarProps = {
isDisabled?: boolean;
Expand Down Expand Up @@ -83,6 +84,10 @@ const Toolbar = ({ isDisabled }: ToolbarProps) => {
nodeType={BLOCKS.EMBEDDED_ENTRY}
onClose={onCloseEntityDropdown}
/>
<ToolbarEmbeddedEntityInlineButton
isDisabled={!!isDisabled}
onClose={onCloseEntityDropdown}
/>
<EmbeddedEntityBlockToolbarIcon
isDisabled={!!isDisabled}
nodeType={BLOCKS.EMBEDDED_ASSET}
Expand Down
Expand Up @@ -5,7 +5,7 @@ import { FetchingWrappedAssetCard } from '../shared/FetchingWrappedAssetCard';
import { useSdkContext } from '../../SdkProvider';
import { useStoreEditor } from '@udecode/slate-plugins-core';
import { CustomElement } from 'types';
import { ReactEditor, useSelected } from 'slate-react';
import { ReactEditor, useSelected, useReadOnly } from 'slate-react';
import { Transforms } from 'slate';

const styles = {
Expand Down Expand Up @@ -38,6 +38,7 @@ export function LinkedEntityBlock(props: LinkedEntityBlockProps) {
const isSelected = useSelected();
const editor = useStoreEditor();
const sdk = useSdkContext();
const isDisabled = useReadOnly();
const { id: entityId, linkType: entityType } = element.data.target.sys;

const handleEditClick = () => {
Expand All @@ -51,8 +52,6 @@ export function LinkedEntityBlock(props: LinkedEntityBlockProps) {
Transforms.removeNodes(editor, { at: pathToElement });
};

// TODO: fixme -- props not available on new editor
const isDisabled = false; // editor.props.readOnly || editor.props.actionsDisabled;
return (
<div {...attributes} className={styles.root}>
<div contentEditable={false}>
Expand Down
4 changes: 2 additions & 2 deletions packages/rich-text/src/plugins/EmbeddedEntityBlock/index.tsx
Expand Up @@ -35,7 +35,7 @@ export const withEmbeddedAssetBlockOptions: CustomSlatePluginOptions = {
},
};

type A = 64;
type A = 65;
type E = 69;
type AEvent = KeyboardEvent & { keyCode: A };
type EEvent = KeyboardEvent & { keyCode: E };
Expand All @@ -46,7 +46,7 @@ type ModEvent = CtrlEvent | MetaEvent;
type EmbeddedAssetEvent = ModEvent & ShiftEvent & AEvent;
type EmbeddedEntryEvent = ModEvent & ShiftEvent & EEvent;

const isA = (event: KeyboardEvent): event is AEvent => event.keyCode === 64;
const isA = (event: KeyboardEvent): event is AEvent => event.keyCode === 65;
const isE = (event: KeyboardEvent): event is EEvent => event.keyCode === 69;
const isMod = (event: KeyboardEvent): event is ModEvent => event.ctrlKey || event.metaKey;
const isShift = (event: KeyboardEvent): event is ShiftEvent => event.shiftKey;
Expand Down
@@ -0,0 +1,116 @@
import React from 'react';
import { css } from 'emotion';
import tokens from '@contentful/forma-36-tokens';
import {
InlineEntryCard,
DropdownListItem,
DropdownList,
Icon,
} from '@contentful/forma-36-react-components';
import { entityHelpers, FieldExtensionSDK } from '@contentful/field-editor-shared';
import { useEntities, ScheduledIconWithTooltip } from '@contentful/field-editor-reference';
import { INLINES } from '@contentful/rich-text-types';

const { getEntryTitle, getEntryStatus } = entityHelpers;

const styles = {
scheduledIcon: css({
verticalAlign: 'text-bottom',
marginRight: tokens.spacing2Xs,
}),
};

interface FetchingWrappedInlineEntryCardProps {
entryId: string;
sdk: FieldExtensionSDK;
isSelected: boolean;
isDisabled: boolean;
onEdit: (event: React.MouseEvent<Element, MouseEvent>) => void;
onRemove: (event: React.MouseEvent<Element, MouseEvent>) => void;
}

export function FetchingWrappedInlineEntryCard(props: FetchingWrappedInlineEntryCardProps) {
const { getOrLoadEntry, loadEntityScheduledActions, entries } = useEntities();
const entry = React.useMemo(() => entries[props.entryId], [entries, props.entryId]);

const allContentTypes = props.sdk.space.getCachedContentTypes();
const contentType = React.useMemo(() => {
if (!entry || !allContentTypes) return undefined;

return allContentTypes.find(
(contentType) => contentType.sys.id === entry.sys.contentType.sys.id
);
}, [allContentTypes, entry]);

const title = React.useMemo(
() =>
getEntryTitle({
entry,
contentType,
localeCode: props.sdk.field.locale,
defaultLocaleCode: props.sdk.locales.default,
defaultTitle: 'Untitled',
}),
[entry, contentType, props.sdk.field.locale, props.sdk.locales.default]
);

React.useEffect(() => {
if (!props.entryId) return;
getOrLoadEntry(props.entryId);
// We don't include getOrLoadEntry below because it's part of the constate-derived
// useEntities(), not props.
// eslint-disable-next-line
}, [props.entryId]);

if (entry === 'failed') {
return (
<InlineEntryCard testId={INLINES.EMBEDDED_ENTRY} isSelected={props.isSelected}>
Entry missing or inaccessible
</InlineEntryCard>
);
}

if (entry === undefined) {
return <InlineEntryCard isLoading={true}>Loading...</InlineEntryCard>;
}

const status = getEntryStatus(entry.sys);
if (status === 'deleted') {
return (
<InlineEntryCard testId={INLINES.EMBEDDED_ENTRY} isSelected={props.isSelected}>
Entry missing or inaccessible
</InlineEntryCard>
);
}

return (
<InlineEntryCard
testId={INLINES.EMBEDDED_ENTRY}
isSelected={props.isSelected}
status={status}
dropdownListElements={
<DropdownList>
<DropdownListItem
onClick={props.onEdit}
isDisabled={props.isDisabled}
testId="card-action-edit">
Edit
</DropdownListItem>
<DropdownListItem
onClick={props.onRemove}
isDisabled={props.isDisabled}
testId="card-action-remove">
Remove
</DropdownListItem>
</DropdownList>
}>
<ScheduledIconWithTooltip
getEntityScheduledActions={loadEntityScheduledActions}
entityType="Entry"
entityId={entry.sys.id}>
<Icon className={styles.scheduledIcon} icon="Clock" color="muted" testId="scheduled-icon" />
</ScheduledIconWithTooltip>
{title}
</InlineEntryCard>
);
}

0 comments on commit d986450

Please sign in to comment.