Commit e8550c5d authored by Mark Florian's avatar Mark Florian

Merge branch '216640-insert-image-modal' into 'master'

Insert an image using the Static Site Editor

See merge request gitlab-org/gitlab!33029
parents 6973f413 5e45da27
...@@ -49,7 +49,7 @@ export default { ...@@ -49,7 +49,7 @@ export default {
<template> <template>
<div class="d-flex flex-grow-1 flex-column h-100"> <div class="d-flex flex-grow-1 flex-column h-100">
<edit-header class="py-2" :title="title" /> <edit-header class="py-2" :title="title" />
<rich-content-editor v-model="editableContent" class="mb-9" /> <rich-content-editor v-model="editableContent" class="mb-9 h-100" />
<publish-toolbar <publish-toolbar
class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full" class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
:return-url="returnUrl" :return-url="returnUrl"
......
...@@ -24,6 +24,7 @@ const TOOLBAR_ITEM_CONFIGS = [ ...@@ -24,6 +24,7 @@ const TOOLBAR_ITEM_CONFIGS = [
{ isDivider: true }, { isDivider: true },
{ icon: 'dash', command: 'HR', tooltip: __('Add a line') }, { icon: 'dash', command: 'HR', tooltip: __('Add a line') },
{ icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') }, { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
{ icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
{ isDivider: true }, { isDivider: true },
{ icon: 'code', command: 'Code', tooltip: __('Insert inline code') }, { icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
]; ];
......
...@@ -34,3 +34,10 @@ export const addCustomEventListener = (editorInstance, event, handler) => { ...@@ -34,3 +34,10 @@ export const addCustomEventListener = (editorInstance, event, handler) => {
editorInstance.eventManager.addEventType(event); editorInstance.eventManager.addEventType(event);
editorInstance.eventManager.listen(event, handler); editorInstance.eventManager.listen(event, handler);
}; };
export const removeCustomEventListener = (editorInstance, event, handler) =>
editorInstance.eventManager.removeEventHandler(event, handler);
export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown');
<script>
import { isSafeURL } from '~/lib/utils/url_utility';
import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlModal,
GlFormGroup,
GlFormInput,
},
data() {
return {
error: null,
imageUrl: null,
altText: null,
modalTitle: __('Image Details'),
okTitle: __('Insert'),
urlLabel: __('Image URL'),
descriptionLabel: __('Description'),
};
},
methods: {
show() {
this.error = null;
this.imageUrl = null;
this.altText = null;
this.$refs.modal.show();
},
onOk(event) {
if (!this.isValid()) {
event.preventDefault();
return;
}
const { imageUrl, altText } = this;
this.$emit('addImage', { imageUrl, altText: altText || __('image') });
},
isValid() {
if (!isSafeURL(this.imageUrl)) {
this.error = __('Please provide a valid URL');
this.$refs.urlInput.$el.focus();
return false;
}
return true;
},
},
};
</script>
<template>
<gl-modal
ref="modal"
modal-id="add-image-modal"
:title="modalTitle"
:ok-title="okTitle"
@ok="onOk"
>
<gl-form-group
:label="urlLabel"
label-for="url-input"
:state="!Boolean(error)"
:invalid-feedback="error"
>
<gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
</gl-form-group>
<gl-form-group :label="descriptionLabel" label-for="description-input">
<gl-form-input id="description-input" ref="descriptionInput" v-model="altText" />
</gl-form-group>
</gl-modal>
</template>
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import 'codemirror/lib/codemirror.css'; import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css'; import '@toast-ui/editor/dist/toastui-editor.css';
import AddImageModal from './modals/add_image_modal.vue';
import { import {
EDITOR_OPTIONS, EDITOR_OPTIONS,
EDITOR_TYPES, EDITOR_TYPES,
...@@ -10,7 +11,12 @@ import { ...@@ -10,7 +11,12 @@ import {
CUSTOM_EVENTS, CUSTOM_EVENTS,
} from './constants'; } from './constants';
import { addCustomEventListener } from './editor_service'; import {
addCustomEventListener,
removeCustomEventListener,
addImage,
getMarkdown,
} from './editor_service';
export default { export default {
components: { components: {
...@@ -18,6 +24,7 @@ export default { ...@@ -18,6 +24,7 @@ export default {
import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then( import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
toast => toast.Editor, toast => toast.Editor,
), ),
AddImageModal,
}, },
props: { props: {
value: { value: {
...@@ -49,13 +56,20 @@ export default { ...@@ -49,13 +56,20 @@ export default {
editorOptions() { editorOptions() {
return { ...EDITOR_OPTIONS, ...this.options }; return { ...EDITOR_OPTIONS, ...this.options };
}, },
editorInstance() {
return this.$refs.editor;
},
},
beforeDestroy() {
removeCustomEventListener(
this.editorInstance,
CUSTOM_EVENTS.openAddImageModal,
this.onOpenAddImageModal,
);
}, },
methods: { methods: {
onContentChanged() { onContentChanged() {
this.$emit('input', this.getMarkdown()); this.$emit('input', getMarkdown(this.editorInstance));
},
getMarkdown() {
return this.$refs.editor.invoke('getMarkdown');
}, },
onLoad(editorInstance) { onLoad(editorInstance) {
addCustomEventListener( addCustomEventListener(
...@@ -65,20 +79,26 @@ export default { ...@@ -65,20 +79,26 @@ export default {
); );
}, },
onOpenAddImageModal() { onOpenAddImageModal() {
// TODO - add image modal (next MR) this.$refs.addImageModal.show();
},
onAddImage(image) {
addImage(this.editorInstance, image);
}, },
}, },
}; };
</script> </script>
<template> <template>
<toast-editor <div>
ref="editor" <toast-editor
:initial-value="value" ref="editor"
:options="editorOptions" :initial-value="value"
:preview-style="previewStyle" :options="editorOptions"
:initial-edit-type="initialEditType" :preview-style="previewStyle"
:height="height" :initial-edit-type="initialEditType"
@change="onContentChanged" :height="height"
@load="onLoad" @change="onContentChanged"
/> @load="onLoad"
/>
<add-image-modal ref="addImageModal" @addImage="onAddImage" />
</div>
</template> </template>
---
title: Add ability to insert an image via SSE
merge_request: 33029
author:
type: added
...@@ -11600,6 +11600,12 @@ msgstr "" ...@@ -11600,6 +11600,12 @@ msgstr ""
msgid "Ignored" msgid "Ignored"
msgstr "" msgstr ""
msgid "Image Details"
msgstr ""
msgid "Image URL"
msgstr ""
msgid "Image: %{image}" msgid "Image: %{image}"
msgstr "" msgstr ""
...@@ -11858,12 +11864,18 @@ msgstr "" ...@@ -11858,12 +11864,18 @@ msgstr ""
msgid "Input your repository URL" msgid "Input your repository URL"
msgstr "" msgstr ""
msgid "Insert"
msgstr ""
msgid "Insert a code block" msgid "Insert a code block"
msgstr "" msgstr ""
msgid "Insert a quote" msgid "Insert a quote"
msgstr "" msgstr ""
msgid "Insert an image"
msgstr ""
msgid "Insert code" msgid "Insert code"
msgstr "" msgstr ""
...@@ -16038,6 +16050,9 @@ msgstr "" ...@@ -16038,6 +16050,9 @@ msgstr ""
msgid "Please provide a name" msgid "Please provide a name"
msgstr "" msgstr ""
msgid "Please provide a valid URL"
msgstr ""
msgid "Please provide a valid email address." msgid "Please provide a valid email address."
msgstr "" msgstr ""
...@@ -26260,6 +26275,9 @@ msgstr "" ...@@ -26260,6 +26275,9 @@ msgstr ""
msgid "https://your-bitbucket-server" msgid "https://your-bitbucket-server"
msgstr "" msgstr ""
msgid "image"
msgstr ""
msgid "image diff" msgid "image diff"
msgstr "" msgstr ""
......
import { import {
generateToolbarItem, generateToolbarItem,
addCustomEventListener, addCustomEventListener,
removeCustomEventListener,
addImage,
getMarkdown,
} from '~/vue_shared/components/rich_content_editor/editor_service'; } from '~/vue_shared/components/rich_content_editor/editor_service';
describe('Editor Service', () => { describe('Editor Service', () => {
const mockInstance = {
eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() },
editor: { exec: jest.fn() },
invoke: jest.fn(),
};
const event = 'someCustomEvent';
const handler = jest.fn();
describe('generateToolbarItem', () => { describe('generateToolbarItem', () => {
const config = { const config = {
icon: 'bold', icon: 'bold',
...@@ -11,6 +22,7 @@ describe('Editor Service', () => { ...@@ -11,6 +22,7 @@ describe('Editor Service', () => {
tooltip: 'Some Tooltip', tooltip: 'Some Tooltip',
event: 'some-event', event: 'some-event',
}; };
const generatedItem = generateToolbarItem(config); const generatedItem = generateToolbarItem(config);
it('generates the correct command', () => { it('generates the correct command', () => {
...@@ -33,10 +45,6 @@ describe('Editor Service', () => { ...@@ -33,10 +45,6 @@ describe('Editor Service', () => {
}); });
describe('addCustomEventListener', () => { describe('addCustomEventListener', () => {
const mockInstance = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } };
const event = 'someCustomEvent';
const handler = jest.fn();
it('registers an event type on the instance and adds an event handler', () => { it('registers an event type on the instance and adds an event handler', () => {
addCustomEventListener(mockInstance, event, handler); addCustomEventListener(mockInstance, event, handler);
...@@ -44,4 +52,30 @@ describe('Editor Service', () => { ...@@ -44,4 +52,30 @@ describe('Editor Service', () => {
expect(mockInstance.eventManager.listen).toHaveBeenCalledWith(event, handler); expect(mockInstance.eventManager.listen).toHaveBeenCalledWith(event, handler);
}); });
}); });
describe('removeCustomEventListener', () => {
it('removes an event handler from the instance', () => {
removeCustomEventListener(mockInstance, event, handler);
expect(mockInstance.eventManager.removeEventHandler).toHaveBeenCalledWith(event, handler);
});
});
describe('addImage', () => {
it('calls the exec method on the instance', () => {
const mockImage = { imageUrl: 'some/url.png', description: 'some description' };
addImage(mockInstance, mockImage);
expect(mockInstance.editor.exec).toHaveBeenCalledWith('AddImage', mockImage);
});
});
describe('getMarkdown', () => {
it('calls the invoke method on the instance', () => {
getMarkdown(mockInstance);
expect(mockInstance.invoke).toHaveBeenCalledWith('getMarkdown');
});
});
}); });
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue';
describe('Add Image Modal', () => {
let wrapper;
const findModal = () => wrapper.find(GlModal);
const findUrlInput = () => wrapper.find({ ref: 'urlInput' });
const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' });
beforeEach(() => {
wrapper = shallowMount(AddImageModal);
});
describe('when content is loaded', () => {
it('renders a modal component', () => {
expect(findModal().exists()).toBe(true);
});
it('renders an input to add an image URL', () => {
expect(findUrlInput().exists()).toBe(true);
});
it('renders an input to add an image description', () => {
expect(findDescriptionInput().exists()).toBe(true);
});
});
describe('add image', () => {
it('emits an addImage event when a valid URL is specified', () => {
const preventDefault = jest.fn();
const mockImage = { imageUrl: '/some/valid/url.png', altText: 'some description' };
wrapper.setData({ ...mockImage });
findModal().vm.$emit('ok', { preventDefault });
expect(preventDefault).not.toHaveBeenCalled();
expect(wrapper.emitted('addImage')).toEqual([[mockImage]]);
});
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue';
import { import {
EDITOR_OPTIONS, EDITOR_OPTIONS,
EDITOR_TYPES, EDITOR_TYPES,
...@@ -8,11 +9,17 @@ import { ...@@ -8,11 +9,17 @@ import {
CUSTOM_EVENTS, CUSTOM_EVENTS,
} from '~/vue_shared/components/rich_content_editor/constants'; } from '~/vue_shared/components/rich_content_editor/constants';
import { addCustomEventListener } from '~/vue_shared/components/rich_content_editor/editor_service'; import {
addCustomEventListener,
removeCustomEventListener,
addImage,
} from '~/vue_shared/components/rich_content_editor/editor_service';
jest.mock('~/vue_shared/components/rich_content_editor/editor_service', () => ({ jest.mock('~/vue_shared/components/rich_content_editor/editor_service', () => ({
...jest.requireActual('~/vue_shared/components/rich_content_editor/editor_service'), ...jest.requireActual('~/vue_shared/components/rich_content_editor/editor_service'),
addCustomEventListener: jest.fn(), addCustomEventListener: jest.fn(),
removeCustomEventListener: jest.fn(),
addImage: jest.fn(),
})); }));
describe('Rich Content Editor', () => { describe('Rich Content Editor', () => {
...@@ -20,6 +27,7 @@ describe('Rich Content Editor', () => { ...@@ -20,6 +27,7 @@ describe('Rich Content Editor', () => {
const value = '## Some Markdown'; const value = '## Some Markdown';
const findEditor = () => wrapper.find({ ref: 'editor' }); const findEditor = () => wrapper.find({ ref: 'editor' });
const findAddImageModal = () => wrapper.find(AddImageModal);
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(RichContentEditor, { wrapper = shallowMount(RichContentEditor, {
...@@ -77,4 +85,34 @@ describe('Rich Content Editor', () => { ...@@ -77,4 +85,34 @@ describe('Rich Content Editor', () => {
); );
}); });
}); });
describe('when editor is destroyed', () => {
it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => {
const mockInstance = { eventManager: { removeEventHandler: jest.fn() } };
wrapper.vm.$refs.editor = mockInstance;
wrapper.vm.$destroy();
expect(removeCustomEventListener).toHaveBeenCalledWith(
mockInstance,
CUSTOM_EVENTS.openAddImageModal,
wrapper.vm.onOpenAddImageModal,
);
});
});
describe('add image modal', () => {
it('renders an addImageModal component', () => {
expect(findAddImageModal().exists()).toBe(true);
});
it('calls the onAddImage method when the addImage event is emitted', () => {
const mockImage = { imageUrl: 'some/url.png', description: 'some description' };
const mockInstance = { exec: jest.fn() };
wrapper.vm.$refs.editor = mockInstance;
findAddImageModal().vm.$emit('addImage', mockImage);
expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage);
});
});
}); });
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