From f91c86dbe4433e0ef76290f1fc7d7daa5383b521 Mon Sep 17 00:00:00 2001 From: Aleksandr Shumilov Date: Thu, 22 Jun 2023 18:36:15 +0200 Subject: [PATCH] feat: integrate default value --- .../src/RichTextEditor/RichTextEditor.tsx | 18 ++++- .../src/RichTextEditor/hooks/index.ts | 1 + .../use-enforce-highlight-autofill.test.tsx | 67 +++++++++++++++++++ .../hooks/use-enforce-highlight-autofill.ts | 34 ++++++++++ packages/picasso/package.json | 3 +- .../src/LexicalEditor/LexicalEditor.tsx | 19 ++++-- .../plugins/TriggerInitialOnChangePlugin.ts | 31 +++++++++ .../src/LexicalEditor/plugins/index.ts | 1 + .../LexicalEditor/utils/getDomValue.test.ts | 18 +++++ .../src/LexicalEditor/utils/getDomValue.ts | 10 +++ .../picasso/src/LexicalEditor/utils/index.ts | 2 + .../src/LexicalEditor/utils/setEditorValue.ts | 36 ++++++++++ .../src/RichTextEditor/RichTextEditor.tsx | 3 +- yarn.lock | 15 ++++- 14 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 packages/picasso-forms/src/RichTextEditor/hooks/index.ts create mode 100644 packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.test.tsx create mode 100644 packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.ts create mode 100644 packages/picasso/src/LexicalEditor/plugins/TriggerInitialOnChangePlugin.ts create mode 100644 packages/picasso/src/LexicalEditor/plugins/index.ts create mode 100644 packages/picasso/src/LexicalEditor/utils/getDomValue.test.ts create mode 100644 packages/picasso/src/LexicalEditor/utils/getDomValue.ts create mode 100644 packages/picasso/src/LexicalEditor/utils/setEditorValue.ts diff --git a/packages/picasso-forms/src/RichTextEditor/RichTextEditor.tsx b/packages/picasso-forms/src/RichTextEditor/RichTextEditor.tsx index b375fa2eb7..fbb92d2f02 100644 --- a/packages/picasso-forms/src/RichTextEditor/RichTextEditor.tsx +++ b/packages/picasso-forms/src/RichTextEditor/RichTextEditor.tsx @@ -7,6 +7,7 @@ import { useForm } from 'react-final-form' import type { FieldProps } from '../FieldWrapper' import InputField from '../InputField' import FieldLabel from '../FieldLabel' +import { useEnforceHighlightAutofill } from './hooks' type OverriddenProps = { defaultValue?: ASTType @@ -21,7 +22,10 @@ export type Props = RichTextEditorProps & type InternalProps = RichTextEditorProps & { value: string } export const RichTextEditor = (props: Props) => { - const { onChange, defaultValue, label, titleCase, ...rest } = props + const { onChange, onFocus, defaultValue, label, titleCase, ...rest } = props + + const { enforceHighlightAutofill, registerChangeOrFocus } = + useEnforceHighlightAutofill() const [value, setValue] = useState('') const { mutators: { setHasMultilineCounter }, @@ -31,17 +35,26 @@ export const RichTextEditor = (props: Props) => { // as an compatibility layer between final-form const handleOnChange = useCallback( (newVal: string) => { + registerChangeOrFocus() setValue(newVal) onChange?.(newVal) }, - [onChange, setValue] + [onChange, setValue, registerChangeOrFocus] ) + + const handleOnFocus = useCallback(() => { + registerChangeOrFocus() + + onFocus?.() + }, [onFocus, registerChangeOrFocus]) + const hiddenInputId = `${props.id}-hidden-input` return ( value={value} onChange={handleOnChange} + onFocus={handleOnFocus} label={ label ? ( { )} diff --git a/packages/picasso-forms/src/RichTextEditor/hooks/index.ts b/packages/picasso-forms/src/RichTextEditor/hooks/index.ts new file mode 100644 index 0000000000..dbcaaa26a9 --- /dev/null +++ b/packages/picasso-forms/src/RichTextEditor/hooks/index.ts @@ -0,0 +1 @@ +export { useEnforceHighlightAutofill } from './use-enforce-highlight-autofill' diff --git a/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.test.tsx b/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.test.tsx new file mode 100644 index 0000000000..87a315730a --- /dev/null +++ b/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.test.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { render, fireEvent } from '@toptal/picasso/test-utils' + +import { useEnforceHighlightAutofill } from './use-enforce-highlight-autofill' +import { useFormConfig } from '../../FormConfig' + +jest.mock('../../FormConfig', () => ({ + useFormConfig: jest.fn(), +})) + +const mockedUseFormConfig = useFormConfig as jest.MockedFunction< + typeof useFormConfig +> + +const TestComponent = () => { + const { enforceHighlightAutofill, registerChangeOrFocus } = + useEnforceHighlightAutofill() + + return ( + <> + +
+ {enforceHighlightAutofill ? 'true' : 'false'} +
+ + ) +} + +describe('useEnforceHighlightAutofill', () => { + describe('when form does not highlight autofilled fields', () => { + it('does not enforce highlighting', () => { + mockedUseFormConfig.mockReturnValue({ highlightAutofill: false }) + const { getByText, getByTestId } = render() + const button = getByText('trigger') + const autofillStatusContainer = getByTestId( + 'enforce-highlight-autofill-status' + ) + + fireEvent.click(button) + + // After first update + expect(autofillStatusContainer).toHaveTextContent('false') + }) + }) + + describe('when form highlights autofilled fields', () => { + it('highlighting is enforced after first update and not enforced after following updates', async () => { + mockedUseFormConfig.mockReturnValue({ highlightAutofill: true }) + const { getByText, getByTestId } = render() + + const button = getByText('trigger') + const autofillStatusContainer = getByTestId( + 'enforce-highlight-autofill-status' + ) + + fireEvent.click(button) + + // After first update + expect(autofillStatusContainer).toHaveTextContent('true') + + fireEvent.click(button) + + // After second update + expect(autofillStatusContainer).toHaveTextContent('false') + }) + }) +}) diff --git a/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.ts b/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.ts new file mode 100644 index 0000000000..a188e245a3 --- /dev/null +++ b/packages/picasso-forms/src/RichTextEditor/hooks/use-enforce-highlight-autofill.ts @@ -0,0 +1,34 @@ +import { useCallback, useMemo, useState } from 'react' + +import { useFormConfig } from '../../FormConfig' + +const TRACKED_EVENTS_LIMIT = 3 + +export const useEnforceHighlightAutofill = () => { + const { highlightAutofill } = useFormConfig() + const [timesChangeOrFocusTriggered, setTimesChangeOrFocusTriggered] = + useState(0) + + const enforceHighlightAutofill = useMemo(() => { + if (!highlightAutofill) { + return false + } + + return timesChangeOrFocusTriggered < 2 + }, [highlightAutofill, timesChangeOrFocusTriggered]) + + const registerChangeOrFocus = useCallback(() => { + if (!highlightAutofill) { + return + } + + if (timesChangeOrFocusTriggered < TRACKED_EVENTS_LIMIT) { + setTimesChangeOrFocusTriggered(timesChangeOrFocusTriggered + 1) + } + }, [timesChangeOrFocusTriggered]) + + return { + enforceHighlightAutofill, + registerChangeOrFocus, + } +} diff --git a/packages/picasso/package.json b/packages/picasso/package.json index 8782f1396e..4a766a4422 100644 --- a/packages/picasso/package.json +++ b/packages/picasso/package.json @@ -56,7 +56,8 @@ "hast-util-from-dom": "^3.0.0", "hast-util-sanitize": "^3.0.2", "hast-util-to-html": "^7.1.3", - "lexical": "^0.9.1", + "hast-util-to-dom": "^3.1.1", + "lexical": "^0.9.2", "quill": "^1.3.7", "quill-emoji": "^0.2.0", "quill-paste-smart": "^1.4.9", diff --git a/packages/picasso/src/LexicalEditor/LexicalEditor.tsx b/packages/picasso/src/LexicalEditor/LexicalEditor.tsx index 9b34644248..52471527d7 100644 --- a/packages/picasso/src/LexicalEditor/LexicalEditor.tsx +++ b/packages/picasso/src/LexicalEditor/LexicalEditor.tsx @@ -5,7 +5,6 @@ import { makeStyles } from '@material-ui/core/styles' import { LexicalComposer } from '@lexical/react/LexicalComposer' import type { InitialConfigType } from '@lexical/react/LexicalComposer' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' -import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin' import { ContentEditable } from '@lexical/react/LexicalContentEditable' import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary' @@ -13,8 +12,11 @@ import { HeadingNode } from '@lexical/rich-text' import { $generateHtmlFromNodes } from '@lexical/html' import { ListItemNode, ListNode } from '@lexical/list' import { $isRootTextContentEmpty } from '@lexical/text' +import type { LexicalEditor as LexicalEditorType } from 'lexical' +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' -import { createLexicalTheme } from './utils' +import { TriggerInitialOnChangePlugin } from './plugins' +import { createLexicalTheme, setEditorValue } from './utils' import noop from '../utils/noop' import Container from '../Container' import Typography from '../Typography' @@ -25,6 +27,7 @@ import ToolbarPlugin from '../LexicalEditorToolbarPlugin' import LexicalTextLengthPlugin from '../LexicalTextLengthPlugin' import LexicalListPlugin from '../LexicalListPlugin' import LexicalHeadingsReplacementPlugin from '../LexicalHeadingsReplacementPlugin' +import type { ASTType } from '../RichText' const useStyles = makeStyles(styles, { name: 'LexicalEditor', @@ -38,7 +41,7 @@ export type Props = BaseProps & { /** Indicates that an element is to be focused on page load */ autoFocus?: boolean /** Default value in [HAST](https://github.com/syntax-tree/hast) format */ - // defaultValue?: ASTType + defaultValue?: ASTType /** * This Boolean attribute indicates that the user cannot interact with the control. */ @@ -85,7 +88,7 @@ const LexicalEditor = forwardRef(function LexicalEditor( const { // plugins, autoFocus = false, - // defaultValue, + defaultValue, disabled = false, id, onChange = noop, @@ -130,6 +133,11 @@ const LexicalEditor = forwardRef(function LexicalEditor( const editorConfig: InitialConfigType = useMemo( () => ({ + editorState: (editor: LexicalEditorType) => { + if (defaultValue) { + setEditorValue(editor, defaultValue) + } + }, theme, onError(error: Error) { throw error @@ -171,6 +179,9 @@ const LexicalEditor = forwardRef(function LexicalEditor( // remount Toolbar when disabled key={`${disabled || !isFocused}`} /> + {defaultValue ? ( + + ) : null} {autoFocus && } diff --git a/packages/picasso/src/LexicalEditor/plugins/TriggerInitialOnChangePlugin.ts b/packages/picasso/src/LexicalEditor/plugins/TriggerInitialOnChangePlugin.ts new file mode 100644 index 0000000000..68b75edb5c --- /dev/null +++ b/packages/picasso/src/LexicalEditor/plugins/TriggerInitialOnChangePlugin.ts @@ -0,0 +1,31 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useIsomorphicLayoutEffect } from '@toptal/picasso-shared' +import type { EditorState, LexicalEditor } from 'lexical' + +const TriggerInitialOnChangePlugin = ({ + onChange, +}: { + onChange: ( + editorState: EditorState, + editor: LexicalEditor, + tags: Set + ) => void +}) => { + const [editor] = useLexicalComposerContext() + + useIsomorphicLayoutEffect(() => { + if (onChange) { + return editor.registerUpdateListener( + ({ editorState, prevEditorState, tags }) => { + if (prevEditorState.isEmpty()) { + onChange(editorState, editor, tags) + } + } + ) + } + }, [editor, onChange]) + + return null +} + +export default TriggerInitialOnChangePlugin diff --git a/packages/picasso/src/LexicalEditor/plugins/index.ts b/packages/picasso/src/LexicalEditor/plugins/index.ts new file mode 100644 index 0000000000..515d4d0f3d --- /dev/null +++ b/packages/picasso/src/LexicalEditor/plugins/index.ts @@ -0,0 +1 @@ +export { default as TriggerInitialOnChangePlugin } from './TriggerInitialOnChangePlugin' diff --git a/packages/picasso/src/LexicalEditor/utils/getDomValue.test.ts b/packages/picasso/src/LexicalEditor/utils/getDomValue.test.ts new file mode 100644 index 0000000000..e019fd5291 --- /dev/null +++ b/packages/picasso/src/LexicalEditor/utils/getDomValue.test.ts @@ -0,0 +1,18 @@ +import type { ASTType } from '../../RichText' +import { getDomValue } from './getDomValue' + +describe('getDomValue', () => { + it('returns DOM structure from AST', () => { + const inputValue: ASTType = { + type: 'root', + children: [{ type: 'text', value: 'Example of default text' }], + } + + const result = getDomValue(inputValue) + const serializer = new XMLSerializer() + + expect(serializer.serializeToString(result)).toBe( + 'Example of default text' + ) + }) +}) diff --git a/packages/picasso/src/LexicalEditor/utils/getDomValue.ts b/packages/picasso/src/LexicalEditor/utils/getDomValue.ts new file mode 100644 index 0000000000..81d55a8a48 --- /dev/null +++ b/packages/picasso/src/LexicalEditor/utils/getDomValue.ts @@ -0,0 +1,10 @@ +import type { HastNode } from 'hast-util-to-dom/lib' +import toHtml from 'hast-util-to-html' + +import type { ASTType } from '../../RichText' + +export const getDomValue = (value: ASTType) => { + const parser = new DOMParser() + + return parser.parseFromString(toHtml(value as HastNode), 'text/html') +} diff --git a/packages/picasso/src/LexicalEditor/utils/index.ts b/packages/picasso/src/LexicalEditor/utils/index.ts index 375dc9d77c..0a07ac5ef9 100644 --- a/packages/picasso/src/LexicalEditor/utils/index.ts +++ b/packages/picasso/src/LexicalEditor/utils/index.ts @@ -3,3 +3,5 @@ export { synchronizeToolbarState } from './synchronizeToolbarState' export { toolbarStateReducer } from './toolbarState' export { getLexicalNode } from './getLexicalNode' export { createLexicalTheme } from './createLexicalTheme' +export { getDomValue } from './getDomValue' +export { setEditorValue } from './setEditorValue' diff --git a/packages/picasso/src/LexicalEditor/utils/setEditorValue.ts b/packages/picasso/src/LexicalEditor/utils/setEditorValue.ts new file mode 100644 index 0000000000..6dd0fec1d9 --- /dev/null +++ b/packages/picasso/src/LexicalEditor/utils/setEditorValue.ts @@ -0,0 +1,36 @@ +import type { LexicalEditor as LexicalEditorType } from 'lexical' +import { + $createParagraphNode, + $isLineBreakNode, + $getRoot, + $isDecoratorNode, + $isElementNode, +} from 'lexical' +import { $generateNodesFromDOM } from '@lexical/html' + +import type { ASTType } from '../../RichText' +import { getDomValue } from './getDomValue' + +export const setEditorValue = (editor: LexicalEditorType, value: ASTType) => { + const domValue = getDomValue(value) + + editor.update(() => { + const root = $getRoot() + const lexicalValueNodes = $generateNodesFromDOM(editor, domValue) + + lexicalValueNodes.forEach(node => { + if ($isElementNode(node) || $isDecoratorNode(node)) { + root.append(node) + } else if ($isLineBreakNode(node)) { + const paragraphNode = $createParagraphNode() + + root.append(paragraphNode) + } else { + const paragraphNode = $createParagraphNode() + + paragraphNode.append(node) + root.append(paragraphNode) + } + }) + }) +} diff --git a/packages/picasso/src/RichTextEditor/RichTextEditor.tsx b/packages/picasso/src/RichTextEditor/RichTextEditor.tsx index a0391f7c38..fba4e2c5df 100644 --- a/packages/picasso/src/RichTextEditor/RichTextEditor.tsx +++ b/packages/picasso/src/RichTextEditor/RichTextEditor.tsx @@ -99,7 +99,7 @@ export const RichTextEditor = forwardRef( // plugins, autoFocus = false, className, - // defaultValue, + defaultValue, disabled, id, onChange = noop, @@ -188,6 +188,7 @@ export const RichTextEditor = forwardRef( testIds={testIds} disabled={disabled} autoFocus={autoFocus} + defaultValue={defaultValue} /> {hiddenInputId && ( // Native `for` attribute on label does not work for div target diff --git a/yarn.lock b/yarn.lock index 94bf4c92f3..0be2f5e5f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12921,6 +12921,14 @@ hast-util-sanitize@^3.0.2: dependencies: xtend "^4.0.0" +hast-util-to-dom@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/hast-util-to-dom/-/hast-util-to-dom-3.1.1.tgz#7cd9f925b0cff8ec2c95474ef896b1865fcefd62" + integrity sha512-hDiYqOapuWzLPDMADCD5z6re/07OQOpQuT2YO5hxPjaxWTtgcbjqCjlv4KtyMuEQiW4wKTIPoK+japvbZ5zqxg== + dependencies: + property-information "^6.0.0" + web-namespaces "^2.0.0" + hast-util-to-html@^7.1.3: version "7.1.3" resolved "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-7.1.3.tgz" @@ -15421,7 +15429,7 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -lexical@^0.9.1: +lexical@^0.9.2: version "0.9.2" resolved "https://registry.yarnpkg.com/lexical/-/lexical-0.9.2.tgz#b4e910322270a7ad06fc7e625066ffa43fa1d4fb" integrity sha512-8S3fNd053/QD5DJ4wePlAY4Rc5k2LFvg2lBcmKLpPUZ+1/1tr35Kdyr5wyFWJKy1rbEUqjP74Hvly/lcXSipag== @@ -23604,6 +23612,11 @@ web-namespaces@^1.0.0: resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz" integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== +web-namespaces@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" + integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"