Commit 946ef760 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '336032-basic-formatting-menu' into 'master'

Add a basic formatting bubble menu in the Content Editor

See merge request gitlab-org/gitlab!67363
parents c4111450 758cb0c2
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { ContentEditor } from '../services/content_editor'; import { ContentEditor } from '../services/content_editor';
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue'; import TopToolbar from './top_toolbar.vue';
export default { export default {
...@@ -9,6 +10,7 @@ export default { ...@@ -9,6 +10,7 @@ export default {
GlAlert, GlAlert,
TiptapEditorContent, TiptapEditorContent,
TopToolbar, TopToolbar,
FormattingBubbleMenu,
}, },
provide() { provide() {
return { return {
...@@ -44,6 +46,7 @@ export default { ...@@ -44,6 +46,7 @@ export default {
:class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }" :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"
> >
<top-toolbar ref="toolbar" class="gl-mb-4" /> <top-toolbar ref="toolbar" class="gl-mb-4" />
<formatting-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
</div> </div>
</div> </div>
......
<script>
import { GlButtonGroup } from '@gitlab/ui';
import { BubbleMenu } from '@tiptap/vue-2';
import { BUBBLE_MENU_TRACKING_ACTION } from '../constants';
import trackUIControl from '../services/track_ui_control';
import ToolbarButton from './toolbar_button.vue';
export default {
components: {
BubbleMenu,
GlButtonGroup,
ToolbarButton,
},
inject: ['tiptapEditor'],
methods: {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value });
},
},
};
</script>
<template>
<bubble-menu class="gl-shadow gl-rounded-base" :editor="tiptapEditor">
<gl-button-group>
<toolbar-button
data-testid="bold"
content-type="bold"
icon-name="bold"
editor-command="toggleBold"
category="primary"
size="medium"
:label="__('Bold text')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="italic"
content-type="italic"
icon-name="italic"
editor-command="toggleItalic"
category="primary"
size="medium"
:label="__('Italic text')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="strike"
content-type="strike"
icon-name="strikethrough"
editor-command="toggleStrike"
category="primary"
size="medium"
:label="__('Strikethrough')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="code"
content-type="code"
icon-name="code"
editor-command="toggleCode"
category="primary"
size="medium"
:label="__('Code')"
@execute="trackToolbarControlExecution"
/>
</gl-button-group>
</bubble-menu>
</template>
...@@ -29,6 +29,21 @@ export default { ...@@ -29,6 +29,21 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
variant: {
type: String,
required: false,
default: 'default',
},
category: {
type: String,
required: false,
default: 'tertiary',
},
size: {
type: String,
required: false,
default: 'small',
},
}, },
data() { data() {
return { return {
...@@ -55,9 +70,9 @@ export default { ...@@ -55,9 +70,9 @@ export default {
<editor-state-observer @transaction="updateActive"> <editor-state-observer @transaction="updateActive">
<gl-button <gl-button
v-gl-tooltip v-gl-tooltip
category="tertiary" :variant="variant"
size="small" :category="category"
class="gl-mx-2" :size="size"
:class="{ active: isActive }" :class="{ active: isActive }"
:aria-label="label" :aria-label="label"
:title="label" :title="label"
......
<script> <script>
import Tracking from '~/tracking'; import trackUIControl from '../services/track_ui_control';
import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
import Divider from './divider.vue'; import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue'; import ToolbarButton from './toolbar_button.vue';
import ToolbarImageButton from './toolbar_image_button.vue'; import ToolbarImageButton from './toolbar_image_button.vue';
...@@ -8,10 +7,6 @@ import ToolbarLinkButton from './toolbar_link_button.vue'; ...@@ -8,10 +7,6 @@ import ToolbarLinkButton from './toolbar_link_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue'; import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue'; import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
const trackingMixin = Tracking.mixin({
label: CONTENT_EDITOR_TRACKING_LABEL,
});
export default { export default {
components: { components: {
ToolbarButton, ToolbarButton,
...@@ -21,13 +16,9 @@ export default { ...@@ -21,13 +16,9 @@ export default {
ToolbarImageButton, ToolbarImageButton,
Divider, Divider,
}, },
mixins: [trackingMixin],
methods: { methods: {
trackToolbarControlExecution({ contentType: property, value }) { trackToolbarControlExecution({ contentType, value }) {
this.track(TOOLBAR_CONTROL_TRACKING_ACTION, { trackUIControl({ property: contentType, value });
property,
value,
});
}, },
}, },
}; };
...@@ -45,6 +36,7 @@ export default { ...@@ -45,6 +36,7 @@ export default {
data-testid="bold" data-testid="bold"
content-type="bold" content-type="bold"
icon-name="bold" icon-name="bold"
class="gl-mx-2"
editor-command="toggleBold" editor-command="toggleBold"
:label="__('Bold text')" :label="__('Bold text')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
...@@ -53,6 +45,7 @@ export default { ...@@ -53,6 +45,7 @@ export default {
data-testid="italic" data-testid="italic"
content-type="italic" content-type="italic"
icon-name="italic" icon-name="italic"
class="gl-mx-2"
editor-command="toggleItalic" editor-command="toggleItalic"
:label="__('Italic text')" :label="__('Italic text')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
...@@ -61,6 +54,7 @@ export default { ...@@ -61,6 +54,7 @@ export default {
data-testid="strike" data-testid="strike"
content-type="strike" content-type="strike"
icon-name="strikethrough" icon-name="strikethrough"
class="gl-mx-2"
editor-command="toggleStrike" editor-command="toggleStrike"
:label="__('Strikethrough')" :label="__('Strikethrough')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
...@@ -69,6 +63,7 @@ export default { ...@@ -69,6 +63,7 @@ export default {
data-testid="code" data-testid="code"
content-type="code" content-type="code"
icon-name="code" icon-name="code"
class="gl-mx-2"
editor-command="toggleCode" editor-command="toggleCode"
:label="__('Code')" :label="__('Code')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
...@@ -84,6 +79,7 @@ export default { ...@@ -84,6 +79,7 @@ export default {
data-testid="blockquote" data-testid="blockquote"
content-type="blockquote" content-type="blockquote"
icon-name="quote" icon-name="quote"
class="gl-mx-2"
editor-command="toggleBlockquote" editor-command="toggleBlockquote"
:label="__('Insert a quote')" :label="__('Insert a quote')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
...@@ -92,6 +88,7 @@ export default { ...@@ -92,6 +88,7 @@ export default {
data-testid="code-block" data-testid="code-block"
content-type="codeBlock" content-type="codeBlock"
icon-name="doc-code" icon-name="doc-code"
class="gl-mx-2"
editor-command="toggleCodeBlock" editor-command="toggleCodeBlock"
:label="__('Insert a code block')" :label="__('Insert a code block')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
...@@ -100,6 +97,7 @@ export default { ...@@ -100,6 +97,7 @@ export default {
data-testid="bullet-list" data-testid="bullet-list"
content-type="bulletList" content-type="bulletList"
icon-name="list-bulleted" icon-name="list-bulleted"
class="gl-mx-2"
editor-command="toggleBulletList" editor-command="toggleBulletList"
:label="__('Add a bullet list')" :label="__('Add a bullet list')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
...@@ -108,6 +106,7 @@ export default { ...@@ -108,6 +106,7 @@ export default {
data-testid="ordered-list" data-testid="ordered-list"
content-type="orderedList" content-type="orderedList"
icon-name="list-numbered" icon-name="list-numbered"
class="gl-mx-2"
editor-command="toggleOrderedList" editor-command="toggleOrderedList"
:label="__('Add a numbered list')" :label="__('Add a numbered list')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
...@@ -116,6 +115,7 @@ export default { ...@@ -116,6 +115,7 @@ export default {
data-testid="horizontal-rule" data-testid="horizontal-rule"
content-type="horizontalRule" content-type="horizontalRule"
icon-name="dash" icon-name="dash"
class="gl-mx-2"
editor-command="setHorizontalRule" editor-command="setHorizontalRule"
:label="__('Add a horizontal rule')" :label="__('Add a horizontal rule')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
......
...@@ -6,6 +6,7 @@ export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__( ...@@ -6,6 +6,7 @@ export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__(
export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor'; export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor';
export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control'; export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control';
export const BUBBLE_MENU_TRACKING_ACTION = 'execute_bubble_menu_control';
export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut'; export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut';
export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule'; export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule';
......
import Tracking from '~/tracking';
import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
export default ({ action = TOOLBAR_CONTROL_TRACKING_ACTION, property, value } = {}) =>
Tracking.event(undefined, action, {
label: CONTENT_EDITOR_TRACKING_LABEL,
property,
value,
});
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`content_editor/components/toolbar_button displays tertiary, small button with a provided label and icon 1`] = ` exports[`content_editor/components/toolbar_button displays tertiary, small button with a provided label and icon 1`] = `
"<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-mx-2 gl-button btn-default-tertiary btn-icon\\"> "<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-button btn-default-tertiary btn-icon\\">
<!----> <!---->
<gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub> <gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub>
<!----> <!---->
......
import { BubbleMenu } from '@tiptap/vue-2';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue';
import {
BUBBLE_MENU_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/top_toolbar', () => {
let wrapper;
let trackingSpy;
let tiptapEditor;
const buildEditor = () => {
tiptapEditor = createTestEditor();
jest.spyOn(tiptapEditor, 'isActive');
};
const buildWrapper = () => {
wrapper = shallowMountExtended(FormattingBubbleMenu, {
provide: {
tiptapEditor,
},
});
};
beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
buildEditor();
});
afterEach(() => {
wrapper.destroy();
});
it('renders bubble menu component', () => {
buildWrapper();
const bubbleMenu = wrapper.findComponent(BubbleMenu);
expect(bubbleMenu.props().editor).toBe(tiptapEditor);
expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']);
});
describe.each`
testId | controlProps
${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'primary' }}
${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'primary' }}
${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'primary' }}
${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'primary' }}
`('given a $testId toolbar control', ({ testId, controlProps }) => {
beforeEach(() => {
buildWrapper();
});
it('renders the toolbar control with the provided properties', () => {
expect(wrapper.findByTestId(testId).exists()).toBe(true);
Object.keys(controlProps).forEach((propName) => {
expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]);
});
});
it('tracks the execution of toolbar controls', () => {
const eventData = { contentType: 'italic', value: 1 };
const { contentType, value } = eventData;
wrapper.findByTestId(testId).vm.$emit('execute', eventData);
expect(trackingSpy).toHaveBeenCalledWith(undefined, BUBBLE_MENU_TRACKING_ACTION, {
label: CONTENT_EDITOR_TRACKING_LABEL,
property: contentType,
value,
});
});
});
});
...@@ -50,6 +50,24 @@ describe('content_editor/components/toolbar_button', () => { ...@@ -50,6 +50,24 @@ describe('content_editor/components/toolbar_button', () => {
expect(findButton().html()).toMatchSnapshot(); expect(findButton().html()).toMatchSnapshot();
}); });
it('allows customizing the variant, category, size of the button', () => {
const variant = 'danger';
const category = 'secondary';
const size = 'medium';
buildWrapper({
variant,
category,
size,
});
expect(findButton().props()).toMatchObject({
variant,
category,
size,
});
});
it.each` it.each`
editorState | outcomeDescription | outcome editorState | outcomeDescription | outcome
${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true} ${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true}
......
import { shallowMount } from '@vue/test-utils';
import { mockTracking } from 'helpers/tracking_helper'; import { mockTracking } from 'helpers/tracking_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import { import {
TOOLBAR_CONTROL_TRACKING_ACTION, TOOLBAR_CONTROL_TRACKING_ACTION,
...@@ -12,7 +11,7 @@ describe('content_editor/components/top_toolbar', () => { ...@@ -12,7 +11,7 @@ describe('content_editor/components/top_toolbar', () => {
let trackingSpy; let trackingSpy;
const buildWrapper = () => { const buildWrapper = () => {
wrapper = extendedWrapper(shallowMount(TopToolbar)); wrapper = shallowMountExtended(TopToolbar);
}; };
beforeEach(() => { beforeEach(() => {
...@@ -43,17 +42,17 @@ describe('content_editor/components/top_toolbar', () => { ...@@ -43,17 +42,17 @@ describe('content_editor/components/top_toolbar', () => {
}); });
it('renders the toolbar control with the provided properties', () => { it('renders the toolbar control with the provided properties', () => {
expect(wrapper.findByTestId(testId).props()).toEqual({ expect(wrapper.findByTestId(testId).exists()).toBe(true);
...controlProps,
Object.keys(controlProps).forEach((propName) => {
expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]);
}); });
}); });
it.each` it('tracks the execution of toolbar controls', () => {
eventData const eventData = { contentType: 'blockquote', value: 1 };
${{ contentType: 'bold' }}
${{ contentType: 'blockquote', value: 1 }}
`('tracks the execution of toolbar controls', ({ eventData }) => {
const { contentType, value } = eventData; const { contentType, value } = eventData;
wrapper.findByTestId(testId).vm.$emit('execute', eventData); wrapper.findByTestId(testId).vm.$emit('execute', eventData);
expect(trackingSpy).toHaveBeenCalledWith(undefined, TOOLBAR_CONTROL_TRACKING_ACTION, { expect(trackingSpy).toHaveBeenCalledWith(undefined, TOOLBAR_CONTROL_TRACKING_ACTION, {
......
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