Commit 5a2cf1b5 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '325741-create-rich-text-editor-toolbar' into 'master'

Content Editor toolbar

See merge request gitlab-org/gitlab!59303
parents 9d308f71 0112a50b
<script>
import { EditorContent } from 'tiptap';
import createEditor from '../services/create_editor';
import { EditorContent, Editor } from 'tiptap';
import TopToolbar from './top_toolbar.vue';
export default {
components: {
EditorContent,
TopToolbar,
},
data() {
return {
editor: createEditor(),
};
props: {
editor: {
type: Object,
required: true,
validator: (editor) => editor instanceof Editor,
},
},
};
</script>
<template>
<editor-content :editor="editor" />
<div
class="gl-display-flex gl-flex-direction-column gl-p-3 gl-border-solid gl-border-1 gl-border-gray-200 gl-rounded-base"
>
<top-toolbar class="gl-mb-3" :editor="editor" />
<editor-content class="md" :editor="editor" />
</div>
</template>
<template>
<span class="gl-mx-3 gl-border-r-solid gl-border-r-1 gl-border-gray-200"></span>
</template>
<script>
import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
export default {
components: {
GlButton,
},
directives: {
GlTooltip,
},
props: {
iconName: {
type: String,
required: true,
},
editor: {
type: Object,
required: true,
},
contentType: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
executeCommand: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
isActive() {
return this.editor.isActive[this.contentType]() && this.editor.focused;
},
},
methods: {
execute() {
const { contentType } = this;
if (this.executeCommand) {
this.editor.commands[contentType]();
}
this.$emit('click', { contentType });
},
},
};
</script>
<template>
<gl-button
v-gl-tooltip
category="tertiary"
size="small"
class="gl-mx-2"
:class="{ active: isActive }"
:aria-label="label"
:title="label"
:icon="iconName"
@click="execute"
/>
</template>
<script>
import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue';
export default {
components: {
ToolbarButton,
Divider,
},
props: {
editor: {
type: Object,
required: true,
},
},
};
</script>
<template>
<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"
>
<toolbar-button
data-testid="bold"
content-type="bold"
icon-name="bold"
:label="__('Bold text')"
:editor="editor"
/>
<toolbar-button
data-testid="italic"
content-type="italic"
icon-name="italic"
:label="__('Italic text')"
:editor="editor"
/>
<toolbar-button
data-testid="code"
content-type="code"
icon-name="code"
:label="__('Code')"
:editor="editor"
/>
<divider />
<toolbar-button
data-testid="blockquote"
content-type="blockquote"
icon-name="quote"
:label="__('Insert a quote')"
:editor="editor"
/>
<toolbar-button
data-testid="bullet-list"
content-type="bullet_list"
icon-name="list-bulleted"
:label="__('Add a bullet list')"
:editor="editor"
/>
<toolbar-button
data-testid="ordered-list"
content-type="ordered_list"
icon-name="list-numbered"
:label="__('Add a numbered list')"
:editor="editor"
/>
</div>
</template>
......@@ -37,6 +37,16 @@ const createEditor = async ({ content, renderMarkdown, serializer: customSeriali
new OrderedList(),
new CodeBlockHighlight(),
],
editorProps: {
attributes: {
/*
* Adds some padding to the contenteditable element where the user types.
* Otherwise, the text cursor is not visible when its position is at the
* beginning of a line.
*/
class: 'gl-py-4 gl-px-5',
},
},
});
const serializer = customSerializer || createMarkdownSerializer({ render: renderMarkdown });
......
// 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 event=\\"click\\" routertag=\\"a\\" size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-mx-2 gl-button btn-default-tertiary btn-icon\\">
<!---->
<gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub>
<!---->
</b-button-stub>"
`;
import { shallowMount } from '@vue/test-utils';
import { EditorContent } from 'tiptap';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import createEditor from '~/content_editor/services/create_editor';
jest.mock('~/content_editor/services/create_editor');
import createMarkdownSerializer from '~/content_editor/services/markdown_serializer';
describe('ContentEditor', () => {
let wrapper;
let editor;
const buildWrapper = () => {
wrapper = shallowMount(ContentEditor);
const buildWrapper = async () => {
editor = await createEditor({ serializer: createMarkdownSerializer({ toHTML: () => '' }) });
wrapper = shallowMount(ContentEditor, {
propsData: {
editor,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('renders editor content component and attaches editor instance', () => {
const editor = {};
it('renders editor content component and attaches editor instance', async () => {
await buildWrapper();
createEditor.mockReturnValueOnce(editor);
buildWrapper();
expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor);
});
it('renders top toolbar component and attaches editor instance', async () => {
await buildWrapper();
expect(wrapper.findComponent(TopToolbar).props().editor).toBe(editor);
});
});
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
describe('content_editor/components/toolbar_button', () => {
let wrapper;
let editor;
const CONTENT_TYPE = 'bold';
const ICON_NAME = 'bold';
const LABEL = 'Bold';
const buildEditor = () => {
editor = {
isActive: {
[CONTENT_TYPE]: jest.fn(),
},
commands: {
[CONTENT_TYPE]: jest.fn(),
},
};
};
const buildWrapper = (propsData = {}) => {
wrapper = shallowMount(ToolbarButton, {
stubs: {
GlButton,
},
propsData: {
editor,
contentType: CONTENT_TYPE,
iconName: ICON_NAME,
label: LABEL,
...propsData,
},
});
};
const findButton = () => wrapper.findComponent(GlButton);
beforeEach(() => {
buildEditor();
});
afterEach(() => {
wrapper.destroy();
});
it('displays tertiary, small button with a provided label and icon', () => {
buildWrapper();
expect(findButton().html()).toMatchSnapshot();
});
it.each`
editorState | outcomeDescription | outcome
${{ isActive: true, focused: true }} | ${'button is active'} | ${true}
${{ isActive: false, focused: true }} | ${'button is not active'} | ${false}
${{ isActive: true, focused: false }} | ${'button is not active '} | ${false}
`('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => {
editor.isActive[CONTENT_TYPE].mockReturnValueOnce(editorState.isActive);
editor.focused = editorState.focused;
buildWrapper();
expect(findButton().classes().includes('active')).toBe(outcome);
});
describe('when button is clicked', () => {
it('executes the content type command when executeCommand = true', async () => {
buildWrapper({ executeCommand: true });
await findButton().trigger('click');
expect(editor.commands[CONTENT_TYPE]).toHaveBeenCalled();
expect(wrapper.emitted().click).toHaveLength(1);
});
it('does not executes the content type command when executeCommand = false', async () => {
buildWrapper({ executeCommand: false });
await findButton().trigger('click');
expect(editor.commands[CONTENT_TYPE]).not.toHaveBeenCalled();
expect(wrapper.emitted().click).toHaveLength(1);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
describe('content_editor/components/top_toolbar', () => {
let wrapper;
let editor;
const buildEditor = () => {
editor = {};
};
const buildWrapper = () => {
wrapper = extendedWrapper(
shallowMount(TopToolbar, {
propsData: {
editor,
},
}),
);
};
beforeEach(() => {
buildEditor();
});
afterEach(() => {
wrapper.destroy();
});
it.each`
testId | button
${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold' }}
${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic' }}
${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code' }}
${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote' }}
${'bullet-list'} | ${{ contentType: 'bullet_list', iconName: 'list-bulleted', label: 'Add a bullet list' }}
${'ordered-list'} | ${{ contentType: 'ordered_list', iconName: 'list-numbered', label: 'Add a numbered list' }}
`('renders $testId button', ({ testId, buttonProps }) => {
buildWrapper();
expect(wrapper.findByTestId(testId).props()).toMatchObject({
...buttonProps,
editor,
});
});
});
......@@ -5,14 +5,24 @@ import createMarkdownSerializer from '~/content_editor/services/markdown_seriali
jest.mock('~/content_editor/services/markdown_serializer');
describe('content_editor/services/create_editor', () => {
const renderMarkdown = () => true;
const buildMockSerializer = () => ({
serialize: jest.fn(),
deserialize: jest.fn(),
});
it('sets gl-py-4 gl-px-5 class selectors to editor attributes', async () => {
const editor = await createEditor({ renderMarkdown });
expect(editor.options.editorProps).toMatchObject({
attributes: {
class: 'gl-py-4 gl-px-5',
},
});
});
describe('creating an editor', () => {
it('uses markdown serializer when a renderMarkdown function is provided', async () => {
const renderMarkdown = () => true;
const mockSerializer = buildMockSerializer();
createMarkdownSerializer.mockReturnValueOnce(mockSerializer);
......
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