Repository: draft-js-plugins/draft-js-plugins
Pull request #1967 (2021-04-07 19:20:22)

[Mention Plugin] Add tests for MentionSuggestions
## Checklist

- [x] Fix any eslint errors that occur
- [x] Update change-log for every plugin you touch
- [x] Add/Update tests if you add/change new functionality
- [x] Add/Update docs if you add/change functionality
- [x] Enable "Allow edits from maintainers" for this PR

## Use-case/Problem

Adding tests for detection if MentionSuggestions should be visible or not.



Patch:
diff --git a/packages/mention/src/MentionSuggestions/MentionSuggestions.tsx b/packages/mention/src/MentionSuggestions/MentionSuggestions.tsx
index e79d19d7..a9dd2e02 100644
--- a/packages/mention/src/MentionSuggestions/MentionSuggestions.tsx
+++ b/packages/mention/src/MentionSuggestions/MentionSuggestions.tsx
@@ -16,12 +16,12 @@ import {
 import { AriaProps, EditorCommand } from '@draft-js-plugins/editor';
 import Entry, { EntryComponentProps } from './Entry/Entry';
 import addMention from '../modifiers/addMention';
-import decodeOffsetKey from '../utils/decodeOffsetKey';
 import getSearchText from '../utils/getSearchText';
 import defaultEntryComponent from './Entry/DefaultEntryComponent';
 import { MentionData, MentionPluginStore } from '..';
 import { PositionSuggestionsFn } from '../utils/positionSuggestions';
 import { MentionPluginTheme } from '../theme';
+import getTriggerForMention from '../utils/getTriggerForMention';
 
 export type { MentionPluginTheme };
 
@@ -139,86 +139,25 @@ export class MentionSuggestions extends Component<MentionSuggestionsProps> {
       return editorState;
     };
 
-    // get the current selection
-    const selection = editorState.getSelection();
-    const anchorKey = selection.getAnchorKey();
-    const anchorOffset = selection.getAnchorOffset();
-
-    // the list should not be visible if a range is selected or the editor has no focus
-    if (!selection.isCollapsed() || !selection.getHasFocus())
-      return removeList();
-
-    // identify the start & end positon of each search-text
-    const offsetDetails = searches.map((offsetKey) =>
-      decodeOffsetKey(offsetKey!)
+    const triggerForMention = getTriggerForMention(
+      editorState,
+      searches,
+      this.props.mentionTriggers
     );
 
-    // a leave can be empty when it is removed due event.g. using backspace
-    // do not check leaves, use full decorated portal text
-    const leaves = offsetDetails
-      .filter((offsetDetail) => offsetDetail!.blockKey === anchorKey)
-      .map((offsetDetail) =>
-        editorState
-          .getBlockTree(offsetDetail!.blockKey)
-          .getIn([offsetDetail!.decoratorKey])
-      );
-
-    // if all leaves are undefined the popover should be removed
-    if (leaves.every((leave) => leave === undefined)) {
+    if (!triggerForMention) {
       return removeList();
     }
 
-    // Checks that the cursor is after the @ character but still somewhere in
-    // the word (search term). Setting it to allow the cursor to be left of
-    // the @ causes troubles due selection confusion.
-    const blockText = editorState
-      .getCurrentContent()
-      .getBlockForKey(anchorKey)
-      .getText();
-    const triggerForSelectionInsideWord = leaves
-      .filter((leave) => leave !== undefined)
-      .map(
-        ({ start, end }) =>
-          this.props.mentionTriggers
-            .map((trigger) =>
-              // @ is the first character
-              (start === 0 &&
-                anchorOffset >= start + trigger.length && //should not trigger if the cursor is before the trigger
-                blockText.substr(0, trigger.length) === trigger &&
-                anchorOffset <= end) ||
-              // @ is in the text or at the end, multi triggers
-              (this.props.mentionTriggers.length > 1 &&
-                anchorOffset >= start + trigger.length &&
-                (blockText.substr(start + 1, trigger.length) === trigger ||
-                  blockText.substr(start, trigger.length) === trigger) &&
-                anchorOffset <= end) ||
-              // @ is in the text or at the end, single trigger
-              (this.props.mentionTriggers.length === 1 &&
-                anchorOffset >= start + trigger.length &&
-                anchorOffset <= end)
-                ? trigger
-                : undefined
-            )
-            .filter((trigger) => trigger !== undefined)[0]
-      )
-      .filter((trigger) => trigger !== undefined);
-
-    if (triggerForSelectionInsideWord.isEmpty()) return removeList();
-
-    const [
-      activeOffsetKey,
-      activeTrigger,
-    ] = triggerForSelectionInsideWord.entrySeq().first();
-
     const lastActiveOffsetKey = this.activeOffsetKey;
-    this.activeOffsetKey = activeOffsetKey;
+    this.activeOffsetKey = triggerForMention.activeOffsetKey;
 
     this.onSearchChange(
       editorState,
-      selection,
+      editorState.getSelection(),
       this.activeOffsetKey,
       lastActiveOffsetKey,
-      activeTrigger
+      triggerForMention.activeTrigger
     );
 
     // make sure the escaped search is reseted in the cursor since the user
diff --git a/packages/mention/src/utils/getTriggerForMention.ts b/packages/mention/src/utils/getTriggerForMention.ts
new file mode 100644
index 00000000..690c9f4d
--- /dev/null
+++ b/packages/mention/src/utils/getTriggerForMention.ts
@@ -0,0 +1,95 @@
+import { EditorState } from 'draft-js';
+import { Map } from 'immutable';
+import decodeOffsetKey from './decodeOffsetKey';
+
+interface TriggerForMentionResult {
+  activeOffsetKey: string;
+  activeTrigger: string;
+}
+
+function filterUndefineds(value: unknown | undefined): boolean {
+  return value !== undefined;
+}
+
+export default function getTriggerForMention(
+  editorState: EditorState,
+  searches: Map<string, string>,
+  mentionTriggers: string[]
+): TriggerForMentionResult | null {
+  // get the current selection
+  const selection = editorState.getSelection();
+  const anchorKey = selection.getAnchorKey();
+  const anchorOffset = selection.getAnchorOffset();
+  // the list should not be visible if a range is selected or the editor has no focus
+  if (!selection.isCollapsed() || !selection.getHasFocus()) {
+    return null;
+  }
+
+  // identify the start & end positon of each search-text
+  const offsetDetails = searches.map((offsetKey) =>
+    decodeOffsetKey(offsetKey!)
+  );
+
+  // a leave can be empty when it is removed due event.g. using backspace
+  // do not check leaves, use full decorated portal text
+  const leaves = offsetDetails
+    .filter((offsetDetail) => offsetDetail!.blockKey === anchorKey)
+    .map((offsetDetail) =>
+      editorState
+        .getBlockTree(offsetDetail!.blockKey)
+        .getIn([offsetDetail!.decoratorKey])
+    );
+
+  // if all leaves are undefined the popover should be removed
+  if (leaves.every((leave) => leave === undefined)) {
+    return null;
+  }
+  // Checks that the cursor is after the @ character but still somewhere in
+  // the word (search term). Setting it to allow the cursor to be left of
+  // the @ causes troubles due selection confusion.
+  const blockText = editorState
+    .getCurrentContent()
+    .getBlockForKey(anchorKey)
+    .getText();
+  const triggerForSelectionInsideWord = leaves
+    .filter(filterUndefineds)
+    .map(
+      ({ start, end }) =>
+        mentionTriggers
+          .map((trigger) =>
+            // @ is the first character
+            (start === 0 &&
+              anchorOffset >= start + trigger.length && //should not trigger if the cursor is before the trigger
+              blockText.substr(0, trigger.length) === trigger &&
+              anchorOffset <= end) ||
+            // @ is in the text or at the end, multi triggers
+            (mentionTriggers.length > 1 &&
+              anchorOffset >= start + trigger.length &&
+              (blockText.substr(start + 1, trigger.length) === trigger ||
+                blockText.substr(start, trigger.length) === trigger) &&
+              anchorOffset <= end) ||
+            // @ is in the text or at the end, single trigger
+            (mentionTriggers.length === 1 &&
+              anchorOffset >= start + trigger.length &&
+              anchorOffset <= end)
+              ? trigger
+              : undefined
+          )
+          .filter(filterUndefineds)[0]
+    )
+    .filter(filterUndefineds);
+
+  if (triggerForSelectionInsideWord.isEmpty()) {
+    return null;
+  }
+
+  const [
+    activeOffsetKey,
+    activeTrigger,
+  ] = triggerForSelectionInsideWord.entrySeq().first();
+
+  return {
+    activeOffsetKey,
+    activeTrigger,
+  };
+}


Test patch:
diff --git a/packages/mention/src/utils/__test__/getTriggerForMention.test.ts b/packages/mention/src/utils/__test__/getTriggerForMention.test.ts
new file mode 100644
index 00000000..27254aae
--- /dev/null
+++ b/packages/mention/src/utils/__test__/getTriggerForMention.test.ts
@@ -0,0 +1,101 @@
+import {
+  EditorState,
+  convertFromRaw,
+  SelectionState,
+  CompositeDecorator,
+} from 'draft-js';
+import { Map } from 'immutable';
+import defaultRegExp from '../../defaultRegExp';
+import MentionSuggestionsPortal from '../../MentionSuggestionsPortal';
+import mentionSuggestionsStrategy from '../../mentionSuggestionsStrategy';
+import getTriggerForMention from '../getTriggerForMention';
+
+function createEditorState(
+  text: string,
+  offset = 0,
+  mentionTriggers = ['@']
+): EditorState {
+  const contentState = convertFromRaw({
+    blocks: [
+      {
+        text,
+        key: 'eai0g',
+        type: 'unstyled',
+        depth: 0,
+        inlineStyleRanges: [],
+        entityRanges: [],
+        data: {},
+      },
+    ],
+    entityMap: {},
+  });
+  const editorState = EditorState.createWithContent(
+    contentState,
+    new CompositeDecorator([
+      {
+        strategy: mentionSuggestionsStrategy(
+          mentionTriggers,
+          false,
+          defaultRegExp
+        ),
+        component: MentionSuggestionsPortal,
+      },
+    ])
+  );
+
+  const selection = new SelectionState({
+    anchorKey: 'eai0g',
+    anchorOffset: offset,
+    focusKey: 'eai0g',
+    focusOffset: offset,
+    hasFocus: true,
+  });
+  return EditorState.acceptSelection(editorState, selection);
+}
+
+describe('test getTriggerForMention', () => {
+  const searchMap = Map({ 'eai0g-0-0': 'eai0g-0-0' });
+
+  test.each([
+    ['test empty', createEditorState(''), searchMap, ['@'], null],
+    [
+      'test match',
+      createEditorState('@test', 4),
+      searchMap,
+      ['@'],
+      {
+        activeOffsetKey: 'eai0g-0-0',
+        activeTrigger: '@',
+      },
+    ],
+    [
+      'test match no match if cursor is on start',
+      createEditorState('@test', 0),
+      searchMap,
+      ['@'],
+      null,
+    ],
+    [
+      'test multitrigger match',
+      createEditorState(
+        'Type @ or ( to make the mention dropdown appear (',
+        49,
+        ['@', '(']
+      ),
+      Map({
+        'eai0g-1-0': 'eai0g-1-0',
+        'eai0g-3-0': 'eai0g-3-0',
+        'eai0g-5-0': 'eai0g-5-0',
+      }),
+      ['@', '('],
+      {
+        activeOffsetKey: 'eai0g-5-0',
+        activeTrigger: '(',
+      },
+    ],
+  ])('%s', (_description, editorState, searches, trigger, result) => {
+    expect(getTriggerForMention(editorState, searches, trigger)).toEqual(
+      result
+    );
+  });
+});