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 @@
import { GlAlert } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { ContentEditor } from '../services/content_editor';
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
export default {
......@@ -9,6 +10,7 @@ export default {
GlAlert,
TiptapEditorContent,
TopToolbar,
FormattingBubbleMenu,
},
provide() {
return {
......@@ -44,6 +46,7 @@ export default {
:class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"
>
<top-toolbar ref="toolbar" class="gl-mb-4" />
<formatting-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
</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 {
required: false,
default: '',
},
variant: {
type: String,
required: false,
default: 'default',
},
category: {
type: String,
required: false,
default: 'tertiary',
},
size: {
type: String,
required: false,
default: 'small',
},
},
data() {
return {
......@@ -55,9 +70,9 @@ export default {
<editor-state-observer @transaction="updateActive">
<gl-button
v-gl-tooltip
category="tertiary"
size="small"
class="gl-mx-2"
:variant="variant"
:category="category"
:size="size"
:class="{ active: isActive }"
:aria-label="label"
:title="label"
......
<script>
import Tracking from '~/tracking';
import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
import trackUIControl from '../services/track_ui_control';
import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue';
import ToolbarImageButton from './toolbar_image_button.vue';
......@@ -8,10 +7,6 @@ import ToolbarLinkButton from './toolbar_link_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
const trackingMixin = Tracking.mixin({
label: CONTENT_EDITOR_TRACKING_LABEL,
});
export default {
components: {
ToolbarButton,
......@@ -21,13 +16,9 @@ export default {
ToolbarImageButton,
Divider,
},
mixins: [trackingMixin],
methods: {
trackToolbarControlExecution({ contentType: property, value }) {
this.track(TOOLBAR_CONTROL_TRACKING_ACTION, {
property,
value,
});
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ property: contentType, value });
},
},
};
......@@ -45,6 +36,7 @@ export default {
data-testid="bold"
content-type="bold"
icon-name="bold"
class="gl-mx-2"
editor-command="toggleBold"
:label="__('Bold text')"
@execute="trackToolbarControlExecution"
......@@ -53,6 +45,7 @@ export default {
data-testid="italic"
content-type="italic"
icon-name="italic"
class="gl-mx-2"
editor-command="toggleItalic"
:label="__('Italic text')"
@execute="trackToolbarControlExecution"
......@@ -61,6 +54,7 @@ export default {
data-testid="strike"
content-type="strike"
icon-name="strikethrough"
class="gl-mx-2"
editor-command="toggleStrike"
:label="__('Strikethrough')"
@execute="trackToolbarControlExecution"
......@@ -69,6 +63,7 @@ export default {
data-testid="code"
content-type="code"
icon-name="code"
class="gl-mx-2"
editor-command="toggleCode"
:label="__('Code')"
@execute="trackToolbarControlExecution"
......@@ -84,6 +79,7 @@ export default {
data-testid="blockquote"
content-type="blockquote"
icon-name="quote"
class="gl-mx-2"
editor-command="toggleBlockquote"
:label="__('Insert a quote')"
@execute="trackToolbarControlExecution"
......@@ -92,6 +88,7 @@ export default {
data-testid="code-block"
content-type="codeBlock"
icon-name="doc-code"
class="gl-mx-2"
editor-command="toggleCodeBlock"
:label="__('Insert a code block')"
@execute="trackToolbarControlExecution"
......@@ -100,6 +97,7 @@ export default {
data-testid="bullet-list"
content-type="bulletList"
icon-name="list-bulleted"
class="gl-mx-2"
editor-command="toggleBulletList"
:label="__('Add a bullet list')"
@execute="trackToolbarControlExecution"
......@@ -108,6 +106,7 @@ export default {
data-testid="ordered-list"
content-type="orderedList"
icon-name="list-numbered"
class="gl-mx-2"
editor-command="toggleOrderedList"
:label="__('Add a numbered list')"
@execute="trackToolbarControlExecution"
......@@ -116,6 +115,7 @@ export default {
data-testid="horizontal-rule"
content-type="horizontalRule"
icon-name="dash"
class="gl-mx-2"
editor-command="setHorizontalRule"
:label="__('Add a horizontal rule')"
@execute="trackToolbarControlExecution"
......
......@@ -6,6 +6,7 @@ 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 BUBBLE_MENU_TRACKING_ACTION = 'execute_bubble_menu_control';
export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut';
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
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>
<!---->
......
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', () => {
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`
editorState | outcomeDescription | outcome
${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true}
......
import { shallowMount } from '@vue/test-utils';
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 {
TOOLBAR_CONTROL_TRACKING_ACTION,
......@@ -12,7 +11,7 @@ describe('content_editor/components/top_toolbar', () => {
let trackingSpy;
const buildWrapper = () => {
wrapper = extendedWrapper(shallowMount(TopToolbar));
wrapper = shallowMountExtended(TopToolbar);
};
beforeEach(() => {
......@@ -43,17 +42,17 @@ describe('content_editor/components/top_toolbar', () => {
});
it('renders the toolbar control with the provided properties', () => {
expect(wrapper.findByTestId(testId).props()).toEqual({
...controlProps,
expect(wrapper.findByTestId(testId).exists()).toBe(true);
Object.keys(controlProps).forEach((propName) => {
expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]);
});
});
it.each`
eventData
${{ contentType: 'bold' }}
${{ contentType: 'blockquote', value: 1 }}
`('tracks the execution of toolbar controls', ({ eventData }) => {
it('tracks the execution of toolbar controls', () => {
const eventData = { contentType: 'blockquote', value: 1 };
const { contentType, value } = eventData;
wrapper.findByTestId(testId).vm.$emit('execute', eventData);
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