Commit 231c5066 authored by Simon Knox's avatar Simon Knox

Merge branch '329650-content-instrument-keyboard-shortcuts-and-inputrules' into 'master'

Instrument keyboard shortcuts and input rules in the Content Editor

See merge request gitlab-org/gitlab!61248
parents cc314885 bae70c72
......@@ -6,3 +6,5 @@ export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__(
export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor';
export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control';
export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut';
export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule';
......@@ -23,6 +23,7 @@ import * as Text from '../extensions/text';
import buildSerializerConfig from './build_serializer_config';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
const builtInContentEditorExtensions = [
Blockquote,
......@@ -66,7 +67,7 @@ export const createContentEditor = ({ renderMarkdown, extensions = [], tiptapOpt
}
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
const tiptapExtensions = collectTiptapExtensions(allExtensions);
const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts);
const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions });
const serializerConfig = buildSerializerConfig(allExtensions);
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
......
import { mapValues, omit } from 'lodash';
import { InputRule } from 'prosemirror-inputrules';
import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys';
import Tracking from '~/tracking';
import {
CONTENT_EDITOR_TRACKING_LABEL,
KEYBOARD_SHORTCUT_TRACKING_ACTION,
INPUT_RULE_TRACKING_ACTION,
} from '../constants';
const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => {
Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
label: CONTENT_EDITOR_TRACKING_LABEL,
property: `${contentType}.${shortcut}`,
});
return commandFn();
};
const trackInputRule = (contentType, inputRule) => {
return new InputRule(inputRule.match, (...args) => {
const result = inputRule.handler(...args);
if (result) {
Tracking.event(undefined, INPUT_RULE_TRACKING_ACTION, {
label: CONTENT_EDITOR_TRACKING_LABEL,
property: contentType,
});
}
return result;
});
};
const trackInputRulesAndShortcuts = (tiptapExtension) => {
return tiptapExtension.extend({
addKeyboardShortcuts() {
const shortcuts = this.parent?.() || {};
const { name } = this;
/**
* We don’t want to track keyboard shortcuts
* that are not deliberately executed to create
* new types of content
*/
const withoutEnterShortcut = omit(shortcuts, [ENTER_KEY, BACKSPACE_KEY]);
const decorated = mapValues(withoutEnterShortcut, (commandFn, shortcut) =>
trackKeyboardShortcut(name, commandFn, shortcut),
);
return decorated;
},
addInputRules() {
const inputRules = this.parent?.() || [];
const { name } = this;
return inputRules.map((inputRule) => trackInputRule(name, inputRule));
},
});
};
export default trackInputRulesAndShortcuts;
export const ESC_KEY = 'Escape';
export const ENTER_KEY = 'Enter';
export const BACKSPACE_KEY = 'Backspace';
import { BulletList } from '@tiptap/extension-bullet-list';
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { Document } from '@tiptap/extension-document';
import { Heading } from '@tiptap/extension-heading';
import { ListItem } from '@tiptap/extension-list-item';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Editor, EditorContent } from '@tiptap/vue-2';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { mockTracking } from 'helpers/tracking_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import {
KEYBOARD_SHORTCUT_TRACKING_ACTION,
INPUT_RULE_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_rules_and_shortcuts';
import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys';
describe('content_editor/services/track_input_rules_and_shortcuts', () => {
let wrapper;
let trackingSpy;
let editor;
const HEADING_TEXT = 'Heading text';
const buildWrapper = () => {
wrapper = extendedWrapper(
mount(EditorContent, {
propsData: {
editor,
},
}),
);
};
beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
});
afterEach(() => {
wrapper.destroy();
});
describe('given the heading extension is instrumented', () => {
beforeEach(() => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Heading,
CodeBlockLowlight,
BulletList,
ListItem,
].map(trackInputRulesAndShortcuts),
});
});
beforeEach(async () => {
buildWrapper();
await nextTick();
});
describe('when creating a heading using an keyboard shortcut', () => {
it('sends a tracking event indicating that a heading was created using an input rule', async () => {
const shortcuts = Heading.config.addKeyboardShortcuts.call(Heading);
const [firstShortcut] = Object.keys(shortcuts);
const nodeName = Heading.name;
editor.chain().keyboardShortcut(firstShortcut).insertContent(HEADING_TEXT).run();
expect(trackingSpy).toHaveBeenCalledWith(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
label: CONTENT_EDITOR_TRACKING_LABEL,
property: `${nodeName}.${firstShortcut}`,
});
});
});
it.each`
extension | shortcut
${ListItem.name} | ${ENTER_KEY}
${CodeBlockLowlight.name} | ${BACKSPACE_KEY}
`('does not track $shortcut shortcut for $extension extension', ({ shortcut }) => {
editor.chain().keyboardShortcut(shortcut).run();
expect(trackingSpy).not.toHaveBeenCalled();
});
describe('when creating a heading using an input rule', () => {
it('sends a tracking event indicating that a heading was created using an input rule', async () => {
const nodeName = Heading.name;
const { view } = editor;
const { selection } = view.state;
// Triggers the event handler that input rules listen to
view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, '## '));
editor.chain().insertContent(HEADING_TEXT).run();
expect(trackingSpy).toHaveBeenCalledWith(undefined, INPUT_RULE_TRACKING_ACTION, {
label: CONTENT_EDITOR_TRACKING_LABEL,
property: `${nodeName}`,
});
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment