From 4d2b78fe1da6c6b5edb5c6231eb8717af70f445a Mon Sep 17 00:00:00 2001 From: roman Date: Tue, 18 Aug 2026 16:04:14 +0200 Subject: [PATCH] refactor(draft-js-mention-selector): migrate DraftJSMentionSelector from Flow to TypeScript --- ...ctor.js => DraftJSMentionSelector.js.flow} | 0 .../DraftJSMentionSelector.tsx | 581 ++++++++++++++++++ ....js => DraftJSMentionSelectorCore.js.flow} | 0 .../DraftJSMentionSelectorCore.tsx | 326 ++++++++++ ...rator.js => DraftMentionDecorator.js.flow} | 0 .../DraftMentionDecorator.ts | 25 + ...entionItem.js => DraftMentionItem.js.flow} | 0 .../DraftMentionItem.tsx | 24 + .../DraftTimestampItem.tsx | 7 +- ...est.js => DraftJSMentionSelector.test.tsx} | 106 ++-- ...js => DraftJSMentionSelectorCore.test.tsx} | 99 +-- ...eateMentionTimestampSelectorState.test.ts} | 0 .../{utils.test.js => utils.test.ts} | 7 +- ...eateMentionTimestampSelectorState.js.flow} | 0 .../createMentionTimestampSelectorState.ts | 116 ++++ .../{index.js => index.js.flow} | 0 .../draft-js-mention-selector/index.ts | 5 + .../{messages.js => messages.js.flow} | 0 .../draft-js-mention-selector/messages.ts | 26 + .../{utils.js => utils.js.flow} | 0 .../draft-js-mention-selector/utils.ts | 186 ++++++ 21 files changed, 1406 insertions(+), 102 deletions(-) rename src/components/form-elements/draft-js-mention-selector/{DraftJSMentionSelector.js => DraftJSMentionSelector.js.flow} (100%) create mode 100644 src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelector.tsx rename src/components/form-elements/draft-js-mention-selector/{DraftJSMentionSelectorCore.js => DraftJSMentionSelectorCore.js.flow} (100%) create mode 100644 src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelectorCore.tsx rename src/components/form-elements/draft-js-mention-selector/{DraftMentionDecorator.js => DraftMentionDecorator.js.flow} (100%) create mode 100644 src/components/form-elements/draft-js-mention-selector/DraftMentionDecorator.ts rename src/components/form-elements/draft-js-mention-selector/{DraftMentionItem.js => DraftMentionItem.js.flow} (100%) create mode 100644 src/components/form-elements/draft-js-mention-selector/DraftMentionItem.tsx rename src/components/form-elements/draft-js-mention-selector/__tests__/{DraftJSMentionSelector.test.js => DraftJSMentionSelector.test.tsx} (91%) rename src/components/form-elements/draft-js-mention-selector/__tests__/{DraftJSMentionSelectorCore.test.js => DraftJSMentionSelectorCore.test.tsx} (77%) rename src/components/form-elements/draft-js-mention-selector/__tests__/{createMentionTimestampSelectorState.test.js => createMentionTimestampSelectorState.test.ts} (100%) rename src/components/form-elements/draft-js-mention-selector/__tests__/{utils.test.js => utils.test.ts} (97%) rename src/components/form-elements/draft-js-mention-selector/{createMentionTimestampSelectorState.js => createMentionTimestampSelectorState.js.flow} (100%) create mode 100644 src/components/form-elements/draft-js-mention-selector/createMentionTimestampSelectorState.ts rename src/components/form-elements/draft-js-mention-selector/{index.js => index.js.flow} (100%) create mode 100644 src/components/form-elements/draft-js-mention-selector/index.ts rename src/components/form-elements/draft-js-mention-selector/{messages.js => messages.js.flow} (100%) create mode 100644 src/components/form-elements/draft-js-mention-selector/messages.ts rename src/components/form-elements/draft-js-mention-selector/{utils.js => utils.js.flow} (100%) create mode 100644 src/components/form-elements/draft-js-mention-selector/utils.ts diff --git a/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelector.js b/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelector.js.flow similarity index 100% rename from src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelector.js rename to src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelector.js.flow diff --git a/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelector.tsx b/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelector.tsx new file mode 100644 index 0000000000..4b98cd5055 --- /dev/null +++ b/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelector.tsx @@ -0,0 +1,581 @@ +import * as React from 'react'; +import { CompositeDecorator, ContentBlock, EditorState, Modifier, SelectionState, ContentState } from 'draft-js'; +import noop from 'lodash/noop'; + +import DraftJSMentionSelectorCore from './DraftJSMentionSelectorCore'; +import DraftMentionItem from './DraftMentionItem'; +import DraftTimestampItem from './DraftTimestampItem'; +import FormInput from '../form/FormInput'; +import * as messages from '../input-messages'; +import type { SelectorItems } from '../../../common/types/core'; +import Toggle from '../../toggle/Toggle'; +import { UNEDITABLE_TIMESTAMP_TEXT } from './utils'; +import { convertSecondsToHMMSS } from '../../../utils/timestamp'; + +interface VideoTimestamp { + timestamp: string; + timestampInMilliseconds: number; +} + +/** + * Scans a Draft ContentBlock for entity ranges, so they can be annotated + * @see docs at {@link https://draftjs.org/docs/advanced-topics-decorators.html#compositedecorator} + * @param {ContentBlock} contentBlock + * @param {function} callback + * @param {ContentState} contentState + */ +const mentionStrategy = ( + contentBlock: ContentBlock, + callback: (start: number, end: number) => void, + contentState: ContentState, +) => { + contentBlock.findEntityRanges(character => { + const entityKey = character.getEntity(); + const ret = entityKey !== null && contentState.getEntity(entityKey).getType() === 'MENTION'; + return ret; + }, callback); +}; + +/** + * Scans a Draft ContentBlock for timestamp entity ranges + * @see docs at {@link https://draftjs.org/docs/advanced-topics-decorators.html#compositedecorator} + * @param {ContentBlock} contentBlock + * @param {function} callback + * @param {ContentState} contentState + */ +const timestampStrategy = ( + contentBlock: ContentBlock, + callback: (start: number, end: number) => void, + contentState: ContentState, +) => { + if (!contentBlock || !contentState) { + return; + } + contentBlock.findEntityRanges(character => { + const entityKey = character.getEntity(); + const hasEntityKey = entityKey !== null; + const entityType = hasEntityKey && contentState?.getEntity(entityKey)?.getType(); + const timeStampEntityFound = entityType === UNEDITABLE_TIMESTAMP_TEXT; + return timeStampEntityFound; + }, callback); +}; + +export interface DraftJSMentionSelectorProps { + /** Additional CSS class for the container */ + className?: string; + /** Contact list used to populate mention suggestions */ + contacts: SelectorItems; + /** Whether mention contacts have finished loading */ + contactsLoaded?: boolean; + /** Description announced to screen-reader users */ + description?: React.ReactNode; + /** External DraftJS editor state when used as a controlled component */ + editorState?: EditorState; + /** File version id attached to timestamp entities */ + fileVersionId?: string; + /** Whether the visible label should be hidden */ + hideLabel?: boolean; + /** Whether the editor is disabled */ + isDisabled?: boolean; + /** Whether the editor is required */ + isRequired?: boolean; + /** Editor label */ + label: React.ReactNode; + /** Maximum allowed comment length */ + maxLength?: number; + /** Characters that start a mention */ + mentionTriggers?: Array; + /** Minimum required comment length */ + minLength?: number; + /** Form input name */ + name: string; + /** Called when the editor state changes */ + onChange: Function; + /** Called when the editor receives focus */ + onFocus?: Function; + /** Called with the current mention query string */ + onMention?: Function; + /** Called before DraftJS handles the return key */ + onReturn?: Function; + /** Editor placeholder */ + placeholder?: string; + /** Custom row renderer for mention suggestions */ + selectorRow?: React.ReactElement; + /** Message shown when a mention is started */ + startMentionMessage?: React.ReactNode; + /** Label for the video timestamp toggle; empty or null disables timestamps */ + timestampLabel?: string | null; + /** Whether validity is checked on blur */ + validateOnBlur?: boolean; +} + +interface DraftJSMentionSelectorState { + contacts: SelectorItems; + error: object | null | undefined; + internalEditorState: EditorState | null | undefined; + isTouched: boolean; + isTimestampToggledOn: boolean; +} + +class DraftJSMentionSelector extends React.Component { + compositeDecorator: CompositeDecorator; + + static defaultProps = { + isRequired: false, + onChange: noop, + validateOnBlur: true, + }; + + constructor(props: DraftJSMentionSelectorProps) { + super(props); + this.compositeDecorator = new CompositeDecorator([ + { + strategy: mentionStrategy, + component: DraftMentionItem, + }, + { + strategy: timestampStrategy, + component: DraftTimestampItem, + }, + ]); + + // @NOTE: + // This component might be either own its EditorState (in which case it lives in `this.state.internalEditorState`) + // or be a controlled component whose EditorState is passed in via the `editorState` prop. + // If `props.editorState` is set, `internalEditorState` is `null`, + // otherwise we initialize it here + this.state = { + contacts: [], + isTouched: false, + internalEditorState: props.editorState ? null : EditorState.createEmpty(this.compositeDecorator), + error: null, + isTimestampToggledOn: false, + }; + } + + static getDerivedStateFromProps(nextProps: DraftJSMentionSelectorProps) { + const { contacts } = nextProps; + return contacts ? { contacts } : null; + } + + componentDidMount() { + // if video timestamping is enabled we need to check if a timestamp entity is present in the editor state passed in via props + // and if it is then set the isTimestampToggledOn state to true. This will happen when the user is editing a comment + // that has a timestamp entity. + if (this.getIsVideoTimestampEnabled()) { + const { isTimestampToggledOn, internalEditorState } = this.state; + const { editorState: externalEditorState } = this.props; + const currentEditorState = internalEditorState || externalEditorState; + // if video timestamping is enabled and the editor state is being passed in check if a timestamp entity is present + // and if it is then set the isTimestampToggledOn state to true. + if (!isTimestampToggledOn && currentEditorState) { + const currentContent = currentEditorState.getCurrentContent(); + const isTimeStampEntityPresent = this.getIsTimestampEntityPresent(currentContent); + if (isTimeStampEntityPresent) { + this.setState({ isTimestampToggledOn: true }); + } + } + } + } + + componentDidUpdate(prevProps: DraftJSMentionSelectorProps, prevState: DraftJSMentionSelectorState) { + const { internalEditorState: prevInternalEditorState } = prevState; + const { internalEditorState } = this.state; + const { editorState: prevEditorStateFromProps, isRequired: prevIsRequiredFromProps } = prevProps; + const { editorState, isRequired } = this.props; + + // Determine whether we're working with the internal editor state or + // external editor state passed in from props + const prevEditorState = prevInternalEditorState || prevEditorStateFromProps; + const currentEditorState = internalEditorState || editorState; + + // Only handle isTouched state transitions and check validity if the + // editorState references are different. This is to avoid getting stuck + // in an infinite loop of checking validity because checkValidity always + // calls setState({ error }) + if (prevEditorState && currentEditorState && prevEditorState !== currentEditorState) { + const newState = this.getDerivedStateFromEditorState(currentEditorState, prevEditorState); + if (newState) { + this.setState(newState, this.checkValidityIfAllowed); + } else { + this.checkValidityIfAllowed(); + } + } + + // if isRequired is false then the comment box will be closed and we want + // to make sure that isTimestampToggledOn is always set to false in this casee + if (this.getIsVideoTimestampEnabled() && isRequired !== prevIsRequiredFromProps && isRequired === false) { + this.setState({ isTimestampToggledOn: false }); + } + + // If timestamplabel is set and isRequired is true then force the timestamp + // to be added to the editor state as that is the specified default behavior for video comments + if (this.getIsVideoTimestampEnabled() && isRequired !== prevIsRequiredFromProps && isRequired === true) { + this.toggleTimestamp(currentEditorState, true); + } + } + + getIsVideoTimestampEnabled = () => { + const { timestampLabel } = this.props; + return !!timestampLabel && timestampLabel.trim() !== ''; + }; + + getDerivedStateFromEditorState(currentEditorState: EditorState, previousEditorState: EditorState) { + const isPreviousEditorStateEmpty = this.isEditorStateEmpty(previousEditorState); + const isCurrentEditorStateEmpty = this.isEditorStateEmpty(currentEditorState); + const isNewEditorState = isCurrentEditorStateEmpty && !isPreviousEditorStateEmpty; + const isEditorStateDirty = isPreviousEditorStateEmpty && !isCurrentEditorStateEmpty; + + let newState = null; + // Detect case where controlled EditorState is created anew and empty. + // If next editorState is empty and the current editorState is not empty + // that means it is a new empty state and this component should not be marked dirty + if (isNewEditorState) { + newState = { isTouched: false, error: null }; + } else if (isEditorStateDirty) { + // Detect case where controlled EditorState has been made dirty + // If the current editorState is empty and the next editorState is not + // empty then this is the first interaction so mark this component dirty + newState = { isTouched: true }; + } + + return newState; + } + + toggleTimestamp = (editorState?: EditorState | null, forceOn: boolean = false) => { + if (!editorState) return; + const currentContent = editorState.getCurrentContent(); + + let updatedContent; + let newIsTimestampToggledOn; + const { isTimestampToggledOn } = this.state; + + // If timestamp is already prepended and forceOn is true, do not toggle it. + if (isTimestampToggledOn && forceOn) { + return; + } + + const timestampLengthIncludingSpace = this.getTimestampLength(currentContent); + const isTimestampEntityPresent = timestampLengthIncludingSpace > 0; + + // check if we need to toggle the timestamp on and that the timestamp entity is not already present in the content + if ((!isTimestampToggledOn || forceOn) && !isTimestampEntityPresent) { + // get the current timestamp + const { timestamp, timestampInMilliseconds } = this.getVideoTimestamp(); + const { fileVersionId } = this.props; + const timestampText = `${timestamp}`; + // Create a new entity for the timestamp. It is immutable so it will not be editable. Adding + // timestampInMilliseconds, and fileVersionId to the entity data which will be used when the comment form is submitted + // and will be added to the text of the comment. This will let us filter out timetsamped comments based on version and also + // be able to click the timestamp button in comments in the sidebar and got to the proper place in the video. + const contentWithTimestampEntity = currentContent.createEntity( + UNEDITABLE_TIMESTAMP_TEXT, // Entity type + 'IMMUTABLE', + { timestampInMilliseconds, fileVersionId }, + ); + + // Create a selection at the very beginning of the input box for the timestamp + const selectionAtStart = SelectionState.createEmpty( + contentWithTimestampEntity.getFirstBlock().getKey(), + ).merge({ + anchorOffset: 0, + focusOffset: 0, + }); + + // First insert the timestamp text followed by a space + updatedContent = Modifier.insertText(contentWithTimestampEntity, selectionAtStart, `${timestampText} `); + + // Then select the timestamp text not including the space + const selectionWithTimestamp = SelectionState.createEmpty(updatedContent.getFirstBlock().getKey()).merge({ + anchorOffset: 0, + focusOffset: timestampText.length, + }); + + // Get the entity key for the timestamp entity + const entityKey = contentWithTimestampEntity.getLastCreatedEntityKey(); + + // Apply the timestamp entity to selected timestamp text. This will ensure that the timestamp is uneditable and that + // the decorator will apply the proper styling to the timestamp. + updatedContent = Modifier.applyEntity(updatedContent, selectionWithTimestamp, entityKey); + + newIsTimestampToggledOn = true; + } else { + // Create a selection range for the timestamp text and space so that we know what to remove and + // remove it from the beginning of the input box. This uses the timestsamp length that we calculated earlier. + const selectionToRemove = SelectionState.createEmpty(currentContent.getFirstBlock().getKey()).merge({ + anchorOffset: 0, + focusOffset: timestampLengthIncludingSpace, + }); + + // Remove the timestamp text and space. No need for an entity key because we are not applying any entity to the text. + updatedContent = Modifier.replaceText(currentContent, selectionToRemove, ''); + newIsTimestampToggledOn = false; + } + + // Position cursor after the timestamp and space (if adding) or at the beginning (if removing) + const cursorOffset = newIsTimestampToggledOn ? timestampLengthIncludingSpace : 0; + // Create a selection that ensures the cursor is outside any entity. This is important because we want to ensure + // that the cursor is not inside the timestamp component when it is displayed + const finalSelection = SelectionState.createEmpty(updatedContent.getFirstBlock().getKey()).merge({ + anchorOffset: cursorOffset, + focusOffset: cursorOffset, + }); + + // Create a new EditorState with the updated content + let newEditorState = EditorState.push(editorState, updatedContent, 'insert-characters'); + // Apply selection first + newEditorState = EditorState.forceSelection(newEditorState, finalSelection); + + // Update state with new timestamp status + this.setState({ + isTimestampToggledOn: newIsTimestampToggledOn, + }); + + // handle the change in the editor state + this.handleChange(newEditorState); + }; + + checkValidityIfAllowed() { + const { validateOnBlur }: DraftJSMentionSelectorProps = this.props; + + if (!validateOnBlur) { + this.checkValidity(); + } + } + + isEditorStateEmpty(editorState: EditorState): boolean { + const text = editorState.getCurrentContent().getPlainText().trim(); + const lastChangeType = editorState.getLastChangeType(); + + return text.length === 0 && lastChangeType === null; + } + + /** + * @returns {string} + */ + getErrorFromValidityState() { + const { editorState: externalEditorState, isRequired, maxLength, minLength } = this.props; + const { internalEditorState } = this.state; + + // manually check for content length if isRequired is true + const editorState: EditorState = internalEditorState || externalEditorState; + const { length } = editorState.getCurrentContent().getPlainText().trim(); + + if (isRequired && !length) { + return messages.valueMissing(); + } + + if (typeof minLength !== 'undefined' && length < minLength) { + return messages.tooShort(minLength); + } + + if (typeof maxLength !== 'undefined' && length > maxLength) { + return messages.tooLong(maxLength); + } + + return null; + } + + containerEl: HTMLDivElement | null | undefined; + + /** + * Event handler called on blur. Triggers validation + * @param {SyntheticFocusEvent} event The event object + * @returns {void} + */ + handleBlur = (event: React.FocusEvent) => { + if ( + this.props.validateOnBlur && + this.containerEl && + event.relatedTarget instanceof Node && + !this.containerEl.contains(event.relatedTarget) + ) { + this.checkValidity(); + } + }; + + handleFocus = (event: React.SyntheticEvent) => { + const { onFocus } = this.props; + + if (onFocus) { + onFocus(event); + } + }; + + getIsTimestampEntityPresent = (currentContent: ContentState): boolean => { + return this.getTimestampLength(currentContent) > 0; + }; + + /** + * Calculates the length of the timestamp entity in the current block + * @param {ContentState} currentContent The current content state + * @param {ContentBlock} block The content block to analyze + * @returns {number} The length of the timestamp entity (including the space after it) + */ + getTimestampLength = (currentContent: ContentState): number => { + const block = currentContent?.getFirstBlock(); + if (!currentContent || !block) { + return 0; + } + let timestampLength = 0; + const characterList = block.getCharacterList(); + + // get the length of the timestamp entity. This will include the space after the timestamp. + for (let i = 0; i < characterList.size; i += 1) { + const char = characterList.get(i); + if (char && char.getEntity()) { + const entity = currentContent.getEntity(char.getEntity()); + if (entity.getType() === UNEDITABLE_TIMESTAMP_TEXT) { + timestampLength = i + 1; + } + } + } + // Include the space after the timestamp + return timestampLength ? timestampLength + 1 : 0; + }; + + /** + * Updates editorState, rechecks validity + * @param {EditorState} nextEditorState The new editor state to set in the state + * @returns {void} + */ + handleChange = (nextEditorState: EditorState) => { + const { internalEditorState, isTimestampToggledOn }: DraftJSMentionSelectorState = this.state; + const { onChange }: DraftJSMentionSelectorProps = this.props; + + // Check if timestamp entity is still present in the content if video timestamping is enabled. + // Update the timestamp prepended state to false if the timestamp entity is no longer present in the editor content + // This can happen when the user deletes it with the backspace key. + if (this.getIsVideoTimestampEnabled() && isTimestampToggledOn) { + const currentContent = nextEditorState.getCurrentContent(); + const firstBlock = currentContent.getFirstBlock(); + const timestampLength = this.getTimestampLength(currentContent); + const timestampEntityFound = timestampLength > 0; + // If timestamp entity is no longer present, update the state + if (!timestampEntityFound) { + this.setState({ isTimestampToggledOn: false }); + } else { + // Check if the timestamp entity is at the beginning of the content, if not do not update the editor state. + // This is to prevent the user from inserting text before the timestamp entity. + const characterList = firstBlock.getCharacterList(); + const firstChar = characterList.get(0); + if (firstChar && !firstChar.getEntity()) { + return; + } + } + } + + onChange(nextEditorState); + + if (internalEditorState) { + const newState = { internalEditorState: nextEditorState }; + this.setState(newState); + } + }; + + handleValidityStateUpdateHandler = () => { + const { isTouched } = this.state; + + if (!isTouched) { + return; + } + + const error = this.getErrorFromValidityState(); + + this.setState({ error }); + }; + + checkValidity = () => { + this.handleValidityStateUpdateHandler(); + }; + + getVideoTimestamp = (): VideoTimestamp => { + const videoContainer: HTMLElement | null | undefined = document.querySelector('.bp-media-container'); + const video: HTMLVideoElement | null | undefined = videoContainer?.querySelector('video'); + + const currentTime = video?.currentTime || 0; + + // We need to get the nubmer of seconds in HMMSS format to display in the timestamp button + // and the timestamp in milliseconds to use when the comment form is submitted. This is because + // milliseconds are more precise than seconds and we need to make sure that we go to the right frame + // when the comment timestamp is clicked in the sidebar. + const totalSeconds = Math.floor(currentTime); + const timestampToDisplay = convertSecondsToHMMSS(totalSeconds); + const timestampInMilliseconds = Math.floor(currentTime * 1000); + + return { timestamp: timestampToDisplay, timestampInMilliseconds }; + }; + + render() { + const { + className = '', + contactsLoaded, + editorState: externalEditorState, + hideLabel, + isDisabled, + isRequired, + label, + description, + mentionTriggers, + name, + onMention, + placeholder, + selectorRow, + startMentionMessage, + onReturn, + timestampLabel, + } = this.props; + const { contacts, internalEditorState, error, isTimestampToggledOn: timestampToggledOn } = this.state; + const { handleBlur, handleChange, handleFocus, toggleTimestamp } = this; + let editorState: EditorState = internalEditorState || externalEditorState; + + // Ensure the editor state has the composite decorator + if (editorState.getDecorator() !== this.compositeDecorator) { + editorState = EditorState.set(editorState, { decorator: this.compositeDecorator }); + } + + return ( +
{ + this.containerEl = containerEl; + }} + className={className} + > + + + + {isRequired && this.getIsVideoTimestampEnabled() && ( + toggleTimestamp(editorState)} + /> + )} + +
+ ); + } +} + +export default DraftJSMentionSelector; diff --git a/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelectorCore.js b/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelectorCore.js.flow similarity index 100% rename from src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelectorCore.js rename to src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelectorCore.js.flow diff --git a/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelectorCore.tsx b/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelectorCore.tsx new file mode 100644 index 0000000000..d881cd456d --- /dev/null +++ b/src/components/form-elements/draft-js-mention-selector/DraftJSMentionSelectorCore.tsx @@ -0,0 +1,326 @@ +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import { EditorState } from 'draft-js'; + +import DatalistItem from '../../datalist-item'; +import DraftJSEditor from '../../draft-js-editor'; +import SelectorDropdown from '../../selector-dropdown'; +import { addMention, defaultMentionTriggers, getActiveMentionForEditorState } from './utils'; +import type { Mention } from './utils'; + +import messages from './messages'; + +import type { SelectorItems } from '../../../common/types/core'; + +import './MentionSelector.scss'; + +export interface DefaultSelectorRowProps { + /** Contact shown in the default mention dropdown row */ + item?: { + email?: string; + name?: string; + }; +} + +const DefaultSelectorRow = ({ item = {}, ...rest }: DefaultSelectorRowProps) => ( + + {item.name} {item.email} + +); + +const DefaultStartMentionMessage = () => ; + +export interface MentionStartStateProps { + /** Message shown when a mention is started */ + message?: React.ReactNode; +} + +const MentionStartState = ({ message }: MentionStartStateProps) => ( +
+ {message} +
+); + +export interface DraftJSMentionSelectorCoreProps { + /** Additional CSS class for the wrapper */ + className?: string; + /** Contact list used to populate mention suggestions */ + contacts: SelectorItems; + /** Whether mention contacts have finished loading */ + contactsLoaded?: boolean; + /** Description announced to screen-reader users */ + description?: React.ReactNode; + /** Current DraftJS editor state */ + editorState: EditorState; + /** Error displayed in the editor tooltip */ + error?: object | null; + /** Whether the visible label should be hidden */ + hideLabel?: boolean; + /** Whether the editor is disabled */ + isDisabled?: boolean; + /** Whether the editor is required */ + isRequired?: boolean; + /** Editor label */ + label: React.ReactNode; + /** Characters that start a mention */ + mentionTriggers: Array; + /** Called when the editor loses focus */ + onBlur?: Function; + /** Called when the editor state changes */ + onChange?: Function; + /** Called when the editor receives focus */ + onFocus?: Function; + /** Called with the current mention query string */ + onMention?: Function; + /** Called before DraftJS handles the return key */ + onReturn?: Function; + /** Editor placeholder */ + placeholder?: string; + /** Custom row renderer for mention suggestions */ + selectorRow: React.ReactElement; + /** Message shown when a mention is started */ + startMentionMessage?: React.ReactNode; +} + +interface DraftJSMentionSelectorCoreState { + activeMention: Mention | null; + isFocused: boolean; + mentionPattern: RegExp; +} + +class DraftJSMentionSelector extends React.Component { + static defaultProps = { + className: '', + contacts: [], + isDisabled: false, + isRequired: false, + mentionTriggers: defaultMentionTriggers, + selectorRow: , + startMentionMessage: , + }; + + constructor(props: DraftJSMentionSelectorCoreProps) { + super(props); + const mentionTriggers = props.mentionTriggers.reduce((prev, current) => `${prev}\\${current}`, ''); + + this.state = { + activeMention: null, + isFocused: false, + mentionPattern: new RegExp(`([${mentionTriggers}])([^${mentionTriggers}]*)$`), + }; + } + + /** + * Lifecycle method that gets called immediately after an update + * @param {object} lastProps Props the component is receiving + * @returns {void} + */ + componentDidUpdate(prevProps: DraftJSMentionSelectorCoreProps) { + const { contacts: prevContacts } = prevProps; + const { contacts: currentContacts } = this.props; + const { activeMention } = this.state; + + if (activeMention !== null && !currentContacts.length && prevContacts.length !== currentContacts.length) { + // if empty set of contacts get passed in, set active mention to null + this.setState({ + activeMention: null, + }); + } + } + + /** + * Extracts the active mention from the editor state + * + * @param {EditorState} editorState + * @returns {object} + */ + getActiveMentionForEditorState(editorState: EditorState) { + const { mentionPattern } = this.state; + + return getActiveMentionForEditorState(editorState, mentionPattern); + } + + /** + * Called on each keypress when a mention is being composed + * @returns {void} + */ + handleMention = () => { + const { onMention } = this.props; + const { activeMention } = this.state; + + if (onMention) { + onMention(activeMention ? activeMention.mentionString : ''); + } + }; + + /** + * Method that gets called when a mention contact is selected + * @param {number} index The selected index + * @returns {void} + */ + handleContactSelected = (index: number) => { + const { contacts } = this.props; + this.addMention(contacts[index]); + this.setState( + { + activeMention: null, + isFocused: true, + }, + () => { + this.handleMention(); + }, + ); + }; + + handleBlur = (event: React.SyntheticEvent) => { + const { onBlur } = this.props; + + this.setState({ + isFocused: false, + }); + + if (onBlur) { + onBlur(event); + } + }; + + handleFocus = (event: React.SyntheticEvent) => { + const { onFocus } = this.props; + + this.setState({ + isFocused: true, + }); + + if (onFocus) { + onFocus(event); + } + }; + + /** + * Event handler called when DraftJSEditor emits onChange + * Checks current text to see if any mentions were made + * @param {EditorState} editorState The new editor state + * @returns {void} + */ + handleChange = (nextEditorState: EditorState) => { + const { onChange } = this.props; + const activeMention = this.getActiveMentionForEditorState(nextEditorState); + + this.setState( + { + activeMention, + }, + () => { + if (onChange) { + onChange(nextEditorState); + } + + if (activeMention?.mentionString) { + this.handleMention(); + } + }, + ); + }; + + /** + * Inserts a selected mention into the editor + * @param {object} mention The selected mention to insert + */ + addMention(mention: { id: string | number; name: string }) { + const { activeMention } = this.state; + const { editorState } = this.props; + + const editorStateWithLink = addMention(editorState, activeMention, mention); + + this.setState( + { + activeMention: null, + }, + () => { + this.handleChange(editorStateWithLink); + }, + ); + } + + /** + * @returns {boolean} + */ + shouldDisplayMentionLookup = () => { + const { contacts } = this.props; + const { activeMention } = this.state; + + return !!(activeMention?.mentionString && contacts.length); + }; + + render() { + const { + className, + contacts, + contactsLoaded, + editorState, + error, + hideLabel, + isDisabled, + isRequired, + label, + description, + onReturn, + placeholder, + selectorRow, + startMentionMessage, + onMention, + } = this.props; + const { activeMention, isFocused } = this.state; + + const classes = classNames('mention-selector-wrapper', className); + + const showMentionStartState = !!(onMention && activeMention && !activeMention.mentionString && isFocused); + + const usersFoundMessage = this.shouldDisplayMentionLookup() + ? { ...messages.usersFound, values: { usersCount: contacts.length } } + : messages.noUsersFound; + + return ( +
+ + } + > + {this.shouldDisplayMentionLookup() + ? contacts.map(contact => + React.cloneElement(selectorRow, { + ...selectorRow.props, + ...contact, + key: contact.id, + }), + ) + : []} + + {showMentionStartState ? : null} + {contactsLoaded && ( + + + + )} +
+ ); + } +} + +export default DraftJSMentionSelector; diff --git a/src/components/form-elements/draft-js-mention-selector/DraftMentionDecorator.js b/src/components/form-elements/draft-js-mention-selector/DraftMentionDecorator.js.flow similarity index 100% rename from src/components/form-elements/draft-js-mention-selector/DraftMentionDecorator.js rename to src/components/form-elements/draft-js-mention-selector/DraftMentionDecorator.js.flow diff --git a/src/components/form-elements/draft-js-mention-selector/DraftMentionDecorator.ts b/src/components/form-elements/draft-js-mention-selector/DraftMentionDecorator.ts new file mode 100644 index 0000000000..2b2e3df37d --- /dev/null +++ b/src/components/form-elements/draft-js-mention-selector/DraftMentionDecorator.ts @@ -0,0 +1,25 @@ +import { CompositeDecorator, ContentBlock, ContentState } from 'draft-js'; + +import DraftMentionItem from './DraftMentionItem'; + +const mentionStrategy = ( + contentBlock: ContentBlock, + callback: (start: number, end: number) => void, + contentState: ContentState, +) => { + contentBlock.findEntityRanges(character => { + const entityKey = character.getEntity(); + + const ret = entityKey !== null && contentState.getEntity(entityKey).getType() === 'MENTION'; + return ret; + }, callback); +}; + +const DraftMentionDecorator = new CompositeDecorator([ + { + strategy: mentionStrategy, + component: DraftMentionItem, + }, +]); + +export default DraftMentionDecorator; diff --git a/src/components/form-elements/draft-js-mention-selector/DraftMentionItem.js b/src/components/form-elements/draft-js-mention-selector/DraftMentionItem.js.flow similarity index 100% rename from src/components/form-elements/draft-js-mention-selector/DraftMentionItem.js rename to src/components/form-elements/draft-js-mention-selector/DraftMentionItem.js.flow diff --git a/src/components/form-elements/draft-js-mention-selector/DraftMentionItem.tsx b/src/components/form-elements/draft-js-mention-selector/DraftMentionItem.tsx new file mode 100644 index 0000000000..336a2467f4 --- /dev/null +++ b/src/components/form-elements/draft-js-mention-selector/DraftMentionItem.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { ContentState } from 'draft-js'; + +export interface DraftMentionItemProps { + /** Decorated mention text nodes */ + children: React.ReactNode; + /** DraftJS content state that holds the mention entity */ + contentState: ContentState; + /** Decorated mention text provided by DraftJS */ + decoratedText: string; + /** Entity key for the mention */ + entityKey?: string; +} + +const DraftMentionItem = ({ contentState, entityKey, children }: DraftMentionItemProps) => { + let id = ''; + if (entityKey) { + id = contentState.getEntity(entityKey).getData().id; + } + + return {children}; +}; + +export default DraftMentionItem; diff --git a/src/components/form-elements/draft-js-mention-selector/DraftTimestampItem.tsx b/src/components/form-elements/draft-js-mention-selector/DraftTimestampItem.tsx index 4c5fde40a6..f8bc92bfae 100644 --- a/src/components/form-elements/draft-js-mention-selector/DraftTimestampItem.tsx +++ b/src/components/form-elements/draft-js-mention-selector/DraftTimestampItem.tsx @@ -3,11 +3,12 @@ import { useIntl } from 'react-intl'; import messages from './messages'; import './DraftTimestamp.scss'; -interface Props { +export interface DraftTimestampItemProps { + /** Decorated timestamp text nodes */ children: React.ReactNode; } -const DraftTimestampItem: React.FC = ({ children }) => { +const DraftTimestampItem = ({ children }: DraftTimestampItemProps) => { const { formatMessage } = useIntl(); const videoTimestampLabel = formatMessage(messages.commentTimestampLabel); return ( @@ -15,7 +16,7 @@ const DraftTimestampItem: React.FC = ({ children }) => { className="bcs-CommentTimestamp-entity" aria-label={videoTimestampLabel} contentEditable={false} - suppressContentEditableWarning={true} + suppressContentEditableWarning > {children} diff --git a/src/components/form-elements/draft-js-mention-selector/__tests__/DraftJSMentionSelector.test.js b/src/components/form-elements/draft-js-mention-selector/__tests__/DraftJSMentionSelector.test.tsx similarity index 91% rename from src/components/form-elements/draft-js-mention-selector/__tests__/DraftJSMentionSelector.test.js rename to src/components/form-elements/draft-js-mention-selector/__tests__/DraftJSMentionSelector.test.tsx index c04b0edf56..30d8ecd1bd 100644 --- a/src/components/form-elements/draft-js-mention-selector/__tests__/DraftJSMentionSelector.test.js +++ b/src/components/form-elements/draft-js-mention-selector/__tests__/DraftJSMentionSelector.test.tsx @@ -1,5 +1,4 @@ /* eslint-disable jsx-a11y/media-has-caption */ -/* eslint-disable react/jsx-no-comment-textnodes */ import * as React from 'react'; import { mount, shallow } from 'enzyme'; import { ContentState, EditorState, convertToRaw } from 'draft-js'; @@ -9,6 +8,8 @@ import DraftJSMentionSelector from '..'; import * as messages from '../../input-messages'; const sandbox = sinon.sandbox.create(); +const getInstance = (wrapper: { instance: () => React.Component }) => + wrapper.instance() as InstanceType; describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSelector', () => { afterEach(() => { @@ -19,14 +20,17 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele contacts: [], label: 'label', name: 'name', - onMention: () => {}, + onMention: jest.fn(), }; describe('render()', () => { beforeEach(() => { - jest.spyOn(document, 'querySelector').mockImplementation(() => ({ - querySelector: () => ({ currentTime: 70 }), - })); + jest.spyOn(document, 'querySelector').mockImplementation( + () => + ({ + querySelector: () => ({ currentTime: 70 }), + }) as unknown as Element, + ); }); afterEach(() => { @@ -36,36 +40,36 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele test('should correctly render the component', () => { const wrapper = shallow(); - expect(wrapper.find('FormInput').length).toBe(1); + expect(wrapper.find('FormInput')).toHaveLength(1); }); test('should toggle the time stamp if isRequired and timestampedCommentsEnabled is true', () => { const wrapper = shallow( , ); - expect(wrapper.find('Toggle').length).toEqual(1); + expect(wrapper.find('Toggle')).toHaveLength(1); }); test('should not toggle the time stamp if isRequired is false', () => { const wrapper = shallow( , ); - expect(wrapper.find('Toggle').length).toEqual(0); + expect(wrapper.find('Toggle')).toHaveLength(0); }); test('should not toggle the time stamp if timestampLabel is undefined', () => { const wrapper = shallow( , ); - expect(wrapper.find('Toggle').length).toEqual(0); + expect(wrapper.find('Toggle')).toHaveLength(0); }); test('should show timestamp toggle on with timestamp if timestamplabel is defined and isRequired is true', () => { const props = { ...requiredProps }; const wrapper = shallow(); wrapper.setProps({ ...requiredProps, timestampLabel: 'Toggle Timestamp', isRequired: true }); - const instance = wrapper.instance(); + const instance = getInstance(wrapper); expect(instance.state.isTimestampToggledOn).toEqual(true); - expect(wrapper.find('Toggle').length).toEqual(1); + expect(wrapper.find('Toggle')).toHaveLength(1); expect(wrapper.find('Toggle').prop('isOn')).toEqual(true); expect(instance.state.internalEditorState.getCurrentContent().getPlainText()).toContain('0:01:10'); }); @@ -73,11 +77,15 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele describe('getDerivedStateFromProps()', () => { test('should return contacts from props', () => { - expect(DraftJSMentionSelector.getDerivedStateFromProps({ contacts: [] })).toEqual({ contacts: [] }); + expect( + DraftJSMentionSelector.getDerivedStateFromProps({ contacts: [] } as DraftJSMentionSelector['props']), + ).toEqual({ + contacts: [], + }); }); test('should return null if no contacts from props', () => { - expect(DraftJSMentionSelector.getDerivedStateFromProps({})).toEqual(null); + expect(DraftJSMentionSelector.getDerivedStateFromProps({} as DraftJSMentionSelector['props'])).toBeNull(); }); }); @@ -89,7 +97,7 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele const setupInstance = props => { const wrapper = shallow(); - const instance = wrapper.instance(); + const instance = getInstance(wrapper); mockGetDerivedStateFromEditorState = jest.fn(); mockCheckValidityIfAllowed = jest.fn(); @@ -188,7 +196,7 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele beforeEach(() => { wrapper = shallow(); - instance = wrapper.instance(); + instance = getInstance(wrapper); mockIsEditorStateEmpty = jest.fn(); instance.isEditorStateEmpty = mockIsEditorStateEmpty; }); @@ -205,7 +213,7 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele test('should return null if not new editor state nor dirty editor', () => { mockIsEditorStateEmpty.mockReturnValueOnce(true).mockReturnValueOnce(true); - expect(instance.getDerivedStateFromEditorState()).toEqual(null); + expect(instance.getDerivedStateFromEditorState()).toBeNull(); }); }); @@ -262,7 +270,7 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele />, ); - const instance = wrapper.instance(); + const instance = getInstance(wrapper); const result = instance.getErrorFromValidityState(); expect(result).toEqual(expected); @@ -281,12 +289,12 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele ].forEach(({ validateOnBlur }) => { const wrapper = mount(); - const instance = wrapper.instance(); + const instance = getInstance(wrapper); afterEach(() => { instance.handleBlur({ relatedTarget: document.createElement('div'), - }); + } as unknown as React.FocusEvent); }); if (validateOnBlur) { @@ -311,7 +319,7 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele mockOnChange = jest.fn(); wrapper = shallow(); - instance = wrapper.instance(); + instance = getInstance(wrapper); spySetState = jest.spyOn(instance, 'setState'); }; @@ -326,7 +334,7 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele onChange={mockOnChange} />, ); - instance = wrapper.instance(); + instance = getInstance(wrapper); }; test('should call onChange and setState if internal editor state exists', () => { @@ -402,7 +410,7 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele const wrapper = shallow(); - const instance = wrapper.instance(); + const instance = getInstance(wrapper); beforeEach(() => { wrapper.setState({ isTouched }); @@ -429,7 +437,7 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele test('should call handleValidityStateUpdateHandler when called', () => { const wrapper = shallow(); - const instance = wrapper.instance(); + const instance = getInstance(wrapper); sandbox.mock(instance).expects('handleValidityStateUpdateHandler'); instance.checkValidity(); @@ -449,7 +457,7 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele ${'has change type'} | ${editorStateWithChangeType} | ${false} `('should return whether the editor state is empty or not: $testcase', ({ editorState, expectedResult }) => { const wrapper = shallow(); - const instance = wrapper.instance(); + const instance = getInstance(wrapper); expect(instance.isEditorStateEmpty(editorState)).toEqual(expectedResult); }); @@ -461,15 +469,15 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele }); test('should return the correct video timestamp', () => { - jest.spyOn(document, 'querySelector').mockImplementation(() => { + jest.spyOn(document, 'querySelector').mockImplementation((() => { return { querySelector: () => { return { currentTime: 70 }; }, }; - }); + }) as unknown as typeof document.querySelector); const wrapper = shallow(); - const instance = wrapper.instance(); + const instance = getInstance(wrapper); const { timestamp, timestampInMilliseconds } = instance.getVideoTimestamp(); expect(timestamp).toEqual('0:01:10'); expect(timestampInMilliseconds).toEqual(70000); @@ -477,44 +485,44 @@ describe('bcomponents/form-elements/draft-js-mention-selector/DraftJSMentionSele test('should return the correct videoe timestamp if it has not been started yet', () => { const wrapper = shallow(); - jest.spyOn(document, 'querySelector').mockImplementation(() => { + jest.spyOn(document, 'querySelector').mockImplementation((() => { return { querySelector: () => { return