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 {
<template>
<div class="d-flex flex-grow-1 flex-column h-100">
<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
class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
:return-url="returnUrl"
......
......@@ -24,6 +24,7 @@ const TOOLBAR_ITEM_CONFIGS = [
{ isDivider: true },
{ icon: 'dash', command: 'HR', tooltip: __('Add a line') },
{ icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
{ icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
{ isDivider: true },
{ icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
];
......
......@@ -34,3 +34,10 @@ export const addCustomEventListener = (editorInstance, event, handler) => {
editorInstance.eventManager.addEventType(event);
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 @@
import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
import AddImageModal from './modals/add_image_modal.vue';
import {
EDITOR_OPTIONS,
EDITOR_TYPES,
......@@ -10,7 +11,12 @@ import {
CUSTOM_EVENTS,
} from './constants';
import { addCustomEventListener } from './editor_service';
import {
addCustomEventListener,
removeCustomEventListener,
addImage,
getMarkdown,
} from './editor_service';
export default {
components: {
......@@ -18,6 +24,7 @@ export default {
import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
toast => toast.Editor,
),
AddImageModal,
},
props: {
value: {
......@@ -49,13 +56,20 @@ export default {
editorOptions() {
return { ...EDITOR_OPTIONS, ...this.options };
},
editorInstance() {
return this.$refs.editor;
},
},
beforeDestroy() {
removeCustomEventListener(
this.editorInstance,
CUSTOM_EVENTS.openAddImageModal,
this.onOpenAddImageModal,
);
},
methods: {
onContentChanged() {
this.$emit('input', this.getMarkdown());
},
getMarkdown() {
return this.$refs.editor.invoke('getMarkdown');
this.$emit('input', getMarkdown(this.editorInstance));
},
onLoad(editorInstance) {
addCustomEventListener(
......@@ -65,12 +79,16 @@ export default {
);
},
onOpenAddImageModal() {
// TODO - add image modal (next MR)
this.$refs.addImageModal.show();
},
onAddImage(image) {
addImage(this.editorInstance, image);
},
},
};
</script>
<template>
<div>
<toast-editor
ref="editor"
:initial-value="value"
......@@ -81,4 +99,6 @@ export default {
@change="onContentChanged"
@load="onLoad"
/>
<add-image-modal ref="addImageModal" @addImage="onAddImage" />
</div>
</template>
---
title: Add ability to insert an image via SSE
merge_request: 33029
author:
type: added
......@@ -11600,6 +11600,12 @@ msgstr ""
msgid "Ignored"
msgstr ""
msgid "Image Details"
msgstr ""
msgid "Image URL"
msgstr ""
msgid "Image: %{image}"
msgstr ""
......@@ -11858,12 +11864,18 @@ msgstr ""
msgid "Input your repository URL"
msgstr ""
msgid "Insert"
msgstr ""
msgid "Insert a code block"
msgstr ""
msgid "Insert a quote"
msgstr ""
msgid "Insert an image"
msgstr ""
msgid "Insert code"
msgstr ""
......@@ -16038,6 +16050,9 @@ msgstr ""
msgid "Please provide a name"
msgstr ""
msgid "Please provide a valid URL"
msgstr ""
msgid "Please provide a valid email address."
msgstr ""
......@@ -26260,6 +26275,9 @@ msgstr ""
msgid "https://your-bitbucket-server"
msgstr ""
msgid "image"
msgstr ""
msgid "image diff"
msgstr ""
......
import {
generateToolbarItem,
addCustomEventListener,
removeCustomEventListener,
addImage,
getMarkdown,
} from '~/vue_shared/components/rich_content_editor/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', () => {
const config = {
icon: 'bold',
......@@ -11,6 +22,7 @@ describe('Editor Service', () => {
tooltip: 'Some Tooltip',
event: 'some-event',
};
const generatedItem = generateToolbarItem(config);
it('generates the correct command', () => {
......@@ -33,10 +45,6 @@ describe('Editor Service', () => {
});
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', () => {
addCustomEventListener(mockInstance, event, handler);
......@@ -44,4 +52,30 @@ describe('Editor Service', () => {
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 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 {
EDITOR_OPTIONS,
EDITOR_TYPES,
......@@ -8,11 +9,17 @@ import {
CUSTOM_EVENTS,
} 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.requireActual('~/vue_shared/components/rich_content_editor/editor_service'),
addCustomEventListener: jest.fn(),
removeCustomEventListener: jest.fn(),
addImage: jest.fn(),
}));
describe('Rich Content Editor', () => {
......@@ -20,6 +27,7 @@ describe('Rich Content Editor', () => {
const value = '## Some Markdown';
const findEditor = () => wrapper.find({ ref: 'editor' });
const findAddImageModal = () => wrapper.find(AddImageModal);
beforeEach(() => {
wrapper = shallowMount(RichContentEditor, {
......@@ -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