Commit 97a0cae1 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '328625-content-editor-text-style-dropdown' into 'master'

Allow to edit headings in the Content Editor

See merge request gitlab-org/gitlab!62244
parents c7126c29 94b0ee9d
...@@ -17,8 +17,8 @@ export default { ...@@ -17,8 +17,8 @@ export default {
}; };
</script> </script>
<template> <template>
<div class="md md-area" :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"> <div class="md-area" :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }">
<top-toolbar class="gl-mb-4" :content-editor="contentEditor" /> <top-toolbar class="gl-mb-4" :content-editor="contentEditor" />
<tiptap-editor-content :editor="contentEditor.tiptapEditor" /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
</div> </div>
</template> </template>
<script>
import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { __ } from '~/locale';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '../constants';
export default {
components: {
GlDropdown,
GlDropdownItem,
},
directives: {
GlTooltip,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
computed: {
activeItem() {
return TEXT_STYLE_DROPDOWN_ITEMS.find((item) =>
this.tiptapEditor.isActive(item.contentType, item.commandParams),
);
},
activeItemLabel() {
const { activeItem } = this;
return activeItem ? activeItem.label : this.$options.i18n.placeholder;
},
},
methods: {
execute(item) {
const { editorCommand, contentType, commandParams } = item;
const value = commandParams?.level;
if (editorCommand) {
this.tiptapEditor
.chain()
.focus()
[editorCommand](commandParams || {})
.run();
}
this.$emit('execute', { contentType, value });
},
isActive(item) {
return this.tiptapEditor.isActive(item.contentType, item.commandParams);
},
},
items: TEXT_STYLE_DROPDOWN_ITEMS,
i18n: {
placeholder: __('Text style'),
},
};
</script>
<template>
<gl-dropdown
v-gl-tooltip="$options.i18n.placeholder"
size="small"
:disabled="!activeItem"
:text="activeItemLabel"
>
<gl-dropdown-item
v-for="(item, index) in $options.items"
:key="index"
is-check-item
:is-checked="isActive(item)"
@click="execute(item)"
>
{{ item.label }}
</gl-dropdown-item>
</gl-dropdown>
</template>
...@@ -4,6 +4,7 @@ import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from ' ...@@ -4,6 +4,7 @@ import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '
import { ContentEditor } from '../services/content_editor'; import { ContentEditor } from '../services/content_editor';
import Divider from './divider.vue'; import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue'; import ToolbarButton from './toolbar_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
const trackingMixin = Tracking.mixin({ const trackingMixin = Tracking.mixin({
label: CONTENT_EDITOR_TRACKING_LABEL, label: CONTENT_EDITOR_TRACKING_LABEL,
...@@ -12,6 +13,7 @@ const trackingMixin = Tracking.mixin({ ...@@ -12,6 +13,7 @@ const trackingMixin = Tracking.mixin({
export default { export default {
components: { components: {
ToolbarButton, ToolbarButton,
ToolbarTextStyleDropdown,
Divider, Divider,
}, },
mixins: [trackingMixin], mixins: [trackingMixin],
...@@ -35,6 +37,12 @@ export default { ...@@ -35,6 +37,12 @@ export default {
<div <div
class="gl-display-flex gl-justify-content-end gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" class="gl-display-flex gl-justify-content-end gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
> >
<toolbar-text-style-dropdown
data-testid="text-styles"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<divider />
<toolbar-button <toolbar-button
data-testid="bold" data-testid="bold"
content-type="bold" content-type="bold"
......
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__( export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__(
'ContentEditor|You have to provide a renderMarkdown function or a custom serializer', 'ContentEditor|You have to provide a renderMarkdown function or a custom serializer',
...@@ -8,3 +8,29 @@ export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor'; ...@@ -8,3 +8,29 @@ 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 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';
export const TEXT_STYLE_DROPDOWN_ITEMS = [
{
contentType: 'heading',
commandParams: { level: 1 },
editorCommand: 'setHeading',
label: __('Heading 1'),
},
{
contentType: 'heading',
editorCommand: 'setHeading',
commandParams: { level: 2 },
label: __('Heading 2'),
},
{
contentType: 'heading',
editorCommand: 'setHeading',
commandParams: { level: 3 },
label: __('Heading 3'),
},
{
contentType: 'paragraph',
editorCommand: 'setParagraph',
label: __('Normal text'),
},
];
...@@ -16244,6 +16244,15 @@ msgstr "" ...@@ -16244,6 +16244,15 @@ msgstr ""
msgid "Header must be associated with a request or response" msgid "Header must be associated with a request or response"
msgstr "" msgstr ""
msgid "Heading 1"
msgstr ""
msgid "Heading 2"
msgstr ""
msgid "Heading 3"
msgstr ""
msgid "Headings" msgid "Headings"
msgstr "" msgstr ""
...@@ -22469,6 +22478,9 @@ msgstr "" ...@@ -22469,6 +22478,9 @@ msgstr ""
msgid "None of the group milestones have the same project as the release" msgid "None of the group milestones have the same project as the release"
msgstr "" msgstr ""
msgid "Normal text"
msgstr ""
msgid "Not Implemented" msgid "Not Implemented"
msgstr "" msgstr ""
...@@ -32220,6 +32232,9 @@ msgstr "" ...@@ -32220,6 +32232,9 @@ msgstr ""
msgid "Tests" msgid "Tests"
msgstr "" msgstr ""
msgid "Text style"
msgstr ""
msgid "Thank you for signing up for your free trial! You will get additional instructions in your inbox shortly." msgid "Thank you for signing up for your free trial! You will get additional instructions in your inbox shortly."
msgstr "" msgstr ""
......
...@@ -27,7 +27,10 @@ describe('ContentEditor', () => { ...@@ -27,7 +27,10 @@ describe('ContentEditor', () => {
it('renders editor content component and attaches editor instance', () => { it('renders editor content component and attaches editor instance', () => {
createWrapper(editor); createWrapper(editor);
expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor.tiptapEditor); const editorContent = wrapper.findComponent(EditorContent);
expect(editorContent.props().editor).toBe(editor.tiptapEditor);
expect(editorContent.classes()).toContain('md');
}); });
it('renders top toolbar component and attaches editor instance', () => { it('renders top toolbar component and attaches editor instance', () => {
...@@ -38,8 +41,8 @@ describe('ContentEditor', () => { ...@@ -38,8 +41,8 @@ describe('ContentEditor', () => {
it.each` it.each`
isFocused | classes isFocused | classes
${true} | ${['md', 'md-area', 'is-focused']} ${true} | ${['md-area', 'is-focused']}
${false} | ${['md', 'md-area']} ${false} | ${['md-area']}
`( `(
'has $classes class selectors when tiptapEditor.isFocused = $isFocused', 'has $classes class selectors when tiptapEditor.isFocused = $isFocused',
({ isFocused, classes }) => { ({ isFocused, classes }) => {
......
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
import { createTestContentEditorExtension, createTestEditor } from '../test_utils';
describe('content_editor/components/toolbar_headings_dropdown', () => {
let wrapper;
let tiptapEditor;
let commandMocks;
const buildEditor = () => {
const testExtension = createTestContentEditorExtension({
commands: TEXT_STYLE_DROPDOWN_ITEMS.map((item) => item.editorCommand),
});
commandMocks = testExtension.commandMocks;
tiptapEditor = createTestEditor({
extensions: [testExtension.tiptapExtension],
});
jest.spyOn(tiptapEditor, 'isActive');
};
const buildWrapper = (propsData = {}) => {
wrapper = shallowMountExtended(ToolbarTextStyleDropdown, {
stubs: {
GlDropdown,
GlDropdownItem,
},
propsData: {
tiptapEditor,
...propsData,
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
beforeEach(() => {
buildEditor();
});
afterEach(() => {
wrapper.destroy();
});
it('renders all text styles as dropdown items', () => {
buildWrapper();
TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle) => {
expect(wrapper.findByText(textStyle.label).exists()).toBe(true);
});
});
describe('when there is an active item ', () => {
let activeTextStyle;
beforeEach(() => {
[, activeTextStyle] = TEXT_STYLE_DROPDOWN_ITEMS;
tiptapEditor.isActive.mockImplementation(
(contentType, params) =>
activeTextStyle.contentType === contentType && activeTextStyle.commandParams === params,
);
buildWrapper();
});
it('displays the active text style label as the dropdown toggle text ', () => {
expect(findDropdown().props().text).toBe(activeTextStyle.label);
});
it('sets dropdown as enabled', () => {
expect(findDropdown().props().disabled).toBe(false);
});
it('sets active item as active', () => {
const activeItem = wrapper
.findAllComponents(GlDropdownItem)
.filter((item) => item.text() === activeTextStyle.label)
.at(0);
expect(activeItem.props().isChecked).toBe(true);
});
});
describe('when there isn’t an active item', () => {
beforeEach(() => {
tiptapEditor.isActive.mockReturnValue(false);
buildWrapper();
});
it('sets dropdown as disabled', () => {
expect(findDropdown().props().disabled).toBe(true);
});
it('sets dropdown toggle text to Text style', () => {
expect(findDropdown().props().text).toBe('Text style');
});
});
describe('when a text style is selected', () => {
it('executes the tiptap command related to that text style', () => {
buildWrapper();
TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle, index) => {
const { editorCommand, commandParams } = textStyle;
wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click');
expect(commandMocks[editorCommand]).toHaveBeenCalledWith(commandParams || {});
});
});
it('emits execute event with contentType and value params that indicates the heading level', () => {
TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle, index) => {
buildWrapper();
const { contentType, commandParams } = textStyle;
wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click');
expect(wrapper.emitted('execute')).toEqual([
[
{
contentType,
value: commandParams?.level,
},
],
]);
wrapper.destroy();
});
});
});
});
...@@ -39,32 +39,33 @@ describe('content_editor/components/top_toolbar', () => { ...@@ -39,32 +39,33 @@ describe('content_editor/components/top_toolbar', () => {
}); });
describe.each` describe.each`
testId | buttonProps testId | controlProps
${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }}
${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }}
${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }}
`('given a $testId toolbar control', ({ testId, buttonProps }) => { ${'text-styles'} | ${{}}
`('given a $testId toolbar control', ({ testId, controlProps }) => {
beforeEach(() => { beforeEach(() => {
buildWrapper(); buildWrapper();
}); });
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).props()).toEqual({
...buttonProps, ...controlProps,
tiptapEditor: contentEditor.tiptapEditor, tiptapEditor: contentEditor.tiptapEditor,
}); });
}); });
it.each` it.each`
control | eventData eventData
${'bold'} | ${{ contentType: 'bold' }} ${{ contentType: 'bold' }}
${'blockquote'} | ${{ contentType: 'blockquote', value: 1 }} ${{ contentType: 'blockquote', value: 1 }}
`('tracks the execution of toolbar controls', ({ control, eventData }) => { `('tracks the execution of toolbar controls', ({ eventData }) => {
const { contentType, value } = eventData; const { contentType, value } = eventData;
wrapper.findByTestId(control).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, {
label: CONTENT_EDITOR_TRACKING_LABEL, label: CONTENT_EDITOR_TRACKING_LABEL,
......
import { Node } from '@tiptap/core'; import { Node } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Editor } from '@tiptap/vue-2';
export const createTestContentEditorExtension = () => ({ /**
tiptapExtension: Node.create({ * Creates an instance of the Tiptap Editor class
name: 'label', * with a minimal configuration for testing purposes.
priority: 101, *
inline: true, * It only includes the Document, Text, and Paragraph
group: 'inline', * extensions.
addAttributes() { *
return { * @param {Array} config.extensions One or more extensions to
labelName: { * include in the editor
default: null, * @returns An instance of a Tiptap’s Editor class
parseHTML: (element) => { */
return { labelName: element.dataset.labelName }; export const createTestEditor = ({ extensions = [] }) => {
return new Editor({
extensions: [Document, Text, Paragraph, ...extensions],
});
};
/**
* Creates a Content Editor extension for testing
* purposes.
*
* @param {Array} config.commands A list of command names
* to include in the test extension. This utility will create
* Jest mock functions for each command name.
* @returns An object with the following properties:
*
* tiptapExtension A Node tiptap extension
* commandMocks Jest mock functions for each created command
* serializer A markdown serializer for the extension
*/
export const createTestContentEditorExtension = ({ commands = [] } = {}) => {
const commandMocks = commands.reduce(
(accum, commandName) => ({
...accum,
[commandName]: jest.fn(),
}),
{},
);
return {
commandMocks,
tiptapExtension: Node.create({
name: 'label',
priority: 101,
inline: true,
group: 'inline',
addCommands() {
return commands.reduce(
(accum, commandName) => ({
...accum,
[commandName]: (...params) => () => commandMocks[commandName](...params),
}),
{},
);
},
addAttributes() {
return {
labelName: {
default: null,
parseHTML: (element) => {
return { labelName: element.dataset.labelName };
},
}, },
}, };
}; },
}, parseHTML() {
parseHTML() { return [
return [ {
{ tag: 'span[data-reference="label"]',
tag: 'span[data-reference="label"]', },
}, ];
]; },
}, renderHTML({ HTMLAttributes }) {
renderHTML({ HTMLAttributes }) { return ['span', HTMLAttributes, 0];
return ['span', HTMLAttributes, 0]; },
}),
serializer: (state, node) => {
state.write(`~${node.attrs.labelName}`);
state.closeBlock(node);
}, },
}), };
serializer: (state, node) => { };
state.write(`~${node.attrs.labelName}`);
state.closeBlock(node);
},
});
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