Commit 86ee3478 authored by Himanshu Kapoor's avatar Himanshu Kapoor

Add support for adding attachments in Content Editor

Adds support for uploading any file as attachment in the content
editor for wikis

Changelog: added
parent 64f3d893
...@@ -8,8 +8,8 @@ import { ...@@ -8,8 +8,8 @@ import {
GlDropdownItem, GlDropdownItem,
GlTooltipDirective as GlTooltip, GlTooltipDirective as GlTooltip,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { acceptedMimes } from '../extensions/image'; import { acceptedMimes } from '../services/upload_helpers';
import { getImageAlt } from '../services/utils'; import { parseFilename } from '../services/utils';
export default { export default {
components: { components: {
...@@ -41,7 +41,7 @@ export default { ...@@ -41,7 +41,7 @@ export default {
.setImage({ .setImage({
src: this.imgSrc, src: this.imgSrc,
canonicalSrc: this.imgSrc, canonicalSrc: this.imgSrc,
alt: getImageAlt(this.imgSrc), alt: parseFilename(this.imgSrc),
}) })
.run(); .run();
...@@ -58,7 +58,7 @@ export default { ...@@ -58,7 +58,7 @@ export default {
this.tiptapEditor this.tiptapEditor
.chain() .chain()
.focus() .focus()
.uploadImage({ .uploadAttachment({
file: e.target.files[0], file: e.target.files[0],
}) })
.run(); .run();
...@@ -67,7 +67,7 @@ export default { ...@@ -67,7 +67,7 @@ export default {
this.emitExecute('upload'); this.emitExecute('upload');
}, },
}, },
acceptedMimes, acceptedMimes: acceptedMimes.image,
}; };
</script> </script>
<template> <template>
......
...@@ -33,6 +33,13 @@ export default { ...@@ -33,6 +33,13 @@ export default {
}; };
}, },
methods: { methods: {
resetFields() {
this.imgSrc = '';
this.$refs.fileSelector.value = '';
},
openFileUpload() {
this.$refs.fileSelector.click();
},
updateLinkState({ editor }) { updateLinkState({ editor }) {
const { canonicalSrc, href } = editor.getAttributes(Link.name); const { canonicalSrc, href } = editor.getAttributes(Link.name);
...@@ -65,6 +72,18 @@ export default { ...@@ -65,6 +72,18 @@ export default {
this.$emit('execute', { contentType: Link.name }); this.$emit('execute', { contentType: Link.name });
}, },
onFileSelect(e) {
this.tiptapEditor
.chain()
.focus()
.uploadAttachment({
file: e.target.files[0],
})
.run();
this.resetFields();
this.$emit('execute', { contentType: Link.name });
},
}, },
}; };
</script> </script>
...@@ -83,14 +102,25 @@ export default { ...@@ -83,14 +102,25 @@ export default {
<gl-dropdown-form class="gl-px-3!"> <gl-dropdown-form class="gl-px-3!">
<gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')"> <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
<template #append> <template #append>
<gl-button variant="confirm" @click="updateLink()">{{ __('Apply') }}</gl-button> <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
</template> </template>
</gl-form-input-group> </gl-form-input-group>
</gl-dropdown-form> </gl-dropdown-form>
<gl-dropdown-divider v-if="isActive" /> <gl-dropdown-divider />
<gl-dropdown-item v-if="isActive" @click="removeLink()"> <gl-dropdown-item v-if="isActive" @click="removeLink">
{{ __('Remove link') }} {{ __('Remove link') }}
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-item v-else @click="openFileUpload">
{{ __('Upload file') }}
</gl-dropdown-item>
<input
ref="fileSelector"
type="file"
name="content_editor_attachment"
class="gl-display-none"
@change="onFileSelect"
/>
</gl-dropdown> </gl-dropdown>
</editor-state-observer> </editor-state-observer>
</template> </template>
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from 'prosemirror-state';
import { handleFileEvent } from '../services/upload_helpers';
export default Extension.create({
name: 'attachment',
defaultOptions: {
uploadsPath: null,
renderMarkdown: null,
},
addCommands() {
return {
uploadAttachment: ({ file }) => () => {
const { uploadsPath, renderMarkdown } = this.options;
return handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
},
};
},
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey('attachment'),
props: {
handlePaste: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
return handleFileEvent({
editor,
file: event.clipboardData.files[0],
uploadsPath,
renderMarkdown,
});
},
handleDrop: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
return handleFileEvent({
editor,
file: event.dataTransfer.files[0],
uploadsPath,
renderMarkdown,
});
},
},
}),
];
},
});
import { Image } from '@tiptap/extension-image'; import { Image } from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-2'; import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { Plugin, PluginKey } from 'prosemirror-state';
import { __ } from '~/locale';
import ImageWrapper from '../components/wrappers/image.vue'; import ImageWrapper from '../components/wrappers/image.vue';
import { uploadFile } from '../services/upload_file';
import { getImageAlt, readFileAsDataURL } from '../services/utils';
export const acceptedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'];
const resolveImageEl = (element) => const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img'); element.nodeName === 'IMG' ? element : element.querySelector('img');
const startFileUpload = async ({ editor, file, uploadsPath, renderMarkdown }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
editor.commands.setImage({ uploading: true, src: encodedSrc });
const { state } = view;
const position = state.selection.from - 1;
const { tr } = state;
try {
const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
view.dispatch(
tr.setNodeMarkup(position, undefined, {
uploading: false,
src: encodedSrc,
alt: getImageAlt(src),
canonicalSrc,
}),
);
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
editor.emit('error', __('An error occurred while uploading the image. Please try again.'));
}
};
const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
if (acceptedMimes.includes(file?.type)) {
startFileUpload({ editor, file, uploadsPath, renderMarkdown });
return true;
}
return false;
};
export default Image.extend({ export default Image.extend({
defaultOptions: { defaultOptions: {
...Image.options, ...Image.options,
uploadsPath: null,
renderMarkdown: null,
inline: true, inline: true,
}, },
addAttributes() { addAttributes() {
...@@ -108,47 +63,6 @@ export default Image.extend({ ...@@ -108,47 +63,6 @@ export default Image.extend({
}, },
]; ];
}, },
addCommands() {
return {
...this.parent(),
uploadImage: ({ file }) => () => {
const { uploadsPath, renderMarkdown } = this.options;
handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
},
};
},
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey('handleDropAndPasteImages'),
props: {
handlePaste: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
return handleFileEvent({
editor,
file: event.clipboardData.files[0],
uploadsPath,
renderMarkdown,
});
},
handleDrop: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
return handleFileEvent({
editor,
file: event.dataTransfer.files[0],
uploadsPath,
renderMarkdown,
});
},
},
}),
];
},
addNodeView() { addNodeView() {
return VueNodeViewRenderer(ImageWrapper); return VueNodeViewRenderer(ImageWrapper);
}, },
......
...@@ -21,6 +21,10 @@ export const extractHrefFromMarkdownLink = (match) => { ...@@ -21,6 +21,10 @@ export const extractHrefFromMarkdownLink = (match) => {
}; };
export default Link.extend({ export default Link.extend({
defaultOptions: {
...Link.options,
openOnClick: false,
},
addInputRules() { addInputRules() {
return [ return [
markInputRule(markdownLinkSyntaxInputRuleRegExp, this.type, extractHrefFromMarkdownLink), markInputRule(markdownLinkSyntaxInputRuleRegExp, this.type, extractHrefFromMarkdownLink),
...@@ -48,6 +52,4 @@ export default Link.extend({ ...@@ -48,6 +52,4 @@ export default Link.extend({
}, },
}; };
}, },
}).configure({
openOnClick: false,
}); });
import { Node } from '@tiptap/core';
export default Node.create({
name: 'loading',
inline: true,
group: 'inline',
addAttributes() {
return {
label: {
default: null,
},
};
},
renderHTML({ node }) {
return [
'span',
{ class: 'gl-display-inline-flex gl-align-items-center' },
['span', { class: 'gl-spinner gl-mx-2' }],
['span', { class: 'gl-link' }, node.attrs.label],
];
},
});
import { Editor } from '@tiptap/vue-2'; import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash'; import { isFunction } from 'lodash';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
import Blockquote from '../extensions/blockquote'; import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold'; import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list'; import BulletList from '../extensions/bullet_list';
...@@ -17,6 +18,7 @@ import Image from '../extensions/image'; ...@@ -17,6 +18,7 @@ import Image from '../extensions/image';
import Italic from '../extensions/italic'; import Italic from '../extensions/italic';
import Link from '../extensions/link'; import Link from '../extensions/link';
import ListItem from '../extensions/list_item'; import ListItem from '../extensions/list_item';
import Loading from '../extensions/loading';
import OrderedList from '../extensions/ordered_list'; import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph'; import Paragraph from '../extensions/paragraph';
import Strike from '../extensions/strike'; import Strike from '../extensions/strike';
...@@ -52,6 +54,7 @@ export const createContentEditor = ({ ...@@ -52,6 +54,7 @@ export const createContentEditor = ({
} }
const builtInContentEditorExtensions = [ const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown }),
Blockquote, Blockquote,
Bold, Bold,
BulletList, BulletList,
...@@ -64,10 +67,11 @@ export const createContentEditor = ({ ...@@ -64,10 +67,11 @@ export const createContentEditor = ({
Heading, Heading,
History, History,
HorizontalRule, HorizontalRule,
Image.configure({ uploadsPath, renderMarkdown }), Image,
Italic, Italic,
Link, Link,
ListItem, ListItem,
Loading,
OrderedList, OrderedList,
Paragraph, Paragraph,
Strike, Strike,
......
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { parseFilename, readFileAsDataURL } from './utils';
export const acceptedMimes = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
};
const extractAttachmentLinkUrl = (html) => { const extractAttachmentLinkUrl = (html) => {
const parser = new DOMParser(); const parser = new DOMParser();
...@@ -42,3 +48,72 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => { ...@@ -42,3 +48,72 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
return extractAttachmentLinkUrl(rendered); return extractAttachmentLinkUrl(rendered);
}; };
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
editor.commands.setImage({ uploading: true, src: encodedSrc });
const { state } = view;
const position = state.selection.from - 1;
const { tr } = state;
try {
const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
view.dispatch(
tr.setNodeMarkup(position, undefined, {
uploading: false,
src: encodedSrc,
alt: parseFilename(src),
canonicalSrc,
}),
);
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
editor.emit('error', __('An error occurred while uploading the image. Please try again.'));
}
};
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => {
await Promise.resolve();
const { view } = editor;
const text = parseFilename(file.name);
const { state } = view;
const { from } = state.selection;
editor.commands.insertContent({
type: 'loading',
attrs: { label: text },
});
try {
const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
editor.commands.insertContentAt(
{ from, to: from + 1 },
{ type: 'text', text, marks: [{ type: 'link', attrs: { href: src, canonicalSrc } }] },
);
} catch (e) {
editor.commands.deleteRange({ from, to: from + 1 });
editor.emit('error', __('An error occurred while uploading the file. Please try again.'));
}
};
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
if (!file) return false;
if (acceptedMimes.image.includes(file?.type)) {
uploadImage({ editor, file, uploadsPath, renderMarkdown });
return true;
}
uploadAttachment({ editor, file, uploadsPath, renderMarkdown });
return true;
};
...@@ -4,8 +4,8 @@ export const hasSelection = (tiptapEditor) => { ...@@ -4,8 +4,8 @@ export const hasSelection = (tiptapEditor) => {
return from < to; return from < to;
}; };
export const getImageAlt = (src) => { export const parseFilename = (src) => {
return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' '); return src.replace(/^.*\/|\..+?$/g, '').replace(/\W+/g, ' ');
}; };
export const readFileAsDataURL = (file) => { export const readFileAsDataURL = (file) => {
......
...@@ -3804,6 +3804,9 @@ msgstr "" ...@@ -3804,6 +3804,9 @@ msgstr ""
msgid "An error occurred while updating the notification settings. Please try again." msgid "An error occurred while updating the notification settings. Please try again."
msgstr "" msgstr ""
msgid "An error occurred while uploading the file. Please try again."
msgstr ""
msgid "An error occurred while uploading the image. Please try again." msgid "An error occurred while uploading the image. Please try again."
msgstr "" msgstr ""
......
...@@ -26,8 +26,21 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen ...@@ -26,8 +26,21 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen
</div> </div>
</form> </form>
</li> </li>
<!----> <li role=\\"presentation\\" class=\\"gl-new-dropdown-divider\\">
<!----> <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\">
</li>
<li role=\\"presentation\\" class=\\"gl-new-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\">
<!---->
<!---->
<!---->
<div class=\\"gl-new-dropdown-item-text-wrapper\\">
<p class=\\"gl-new-dropdown-item-text-primary\\">
Upload file
</p>
<!---->
</div>
<!---->
</button></li> <input type=\\"file\\" name=\\"content_editor_attachment\\" class=\\"gl-display-none\\">
</div> </div>
<!----> <!---->
</div> </div>
......
import { GlButton, GlFormInputGroup } from '@gitlab/ui'; import { GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue'; import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue';
import Attachment from '~/content_editor/extensions/attachment';
import Image from '~/content_editor/extensions/image'; import Image from '~/content_editor/extensions/image';
import { createTestEditor, mockChainedCommands } from '../test_utils'; import { createTestEditor, mockChainedCommands } from '../test_utils';
...@@ -31,7 +32,8 @@ describe('content_editor/components/toolbar_image_button', () => { ...@@ -31,7 +32,8 @@ describe('content_editor/components/toolbar_image_button', () => {
beforeEach(() => { beforeEach(() => {
editor = createTestEditor({ editor = createTestEditor({
extensions: [ extensions: [
Image.configure({ Image,
Attachment.configure({
renderMarkdown: jest.fn(), renderMarkdown: jest.fn(),
uploadsPath: '/uploads/', uploadsPath: '/uploads/',
}), }),
...@@ -64,13 +66,13 @@ describe('content_editor/components/toolbar_image_button', () => { ...@@ -64,13 +66,13 @@ describe('content_editor/components/toolbar_image_button', () => {
}); });
it('uploads the selected image when file input changes', async () => { it('uploads the selected image when file input changes', async () => {
const commands = mockChainedCommands(editor, ['focus', 'uploadImage', 'run']); const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']);
const file = new File(['foo'], 'foo.png', { type: 'image/png' }); const file = new File(['foo'], 'foo.png', { type: 'image/png' });
await selectFile(file); await selectFile(file);
expect(commands.focus).toHaveBeenCalled(); expect(commands.focus).toHaveBeenCalled();
expect(commands.uploadImage).toHaveBeenCalledWith({ file }); expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
expect(commands.run).toHaveBeenCalled(); expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]); expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]);
......
import { GlDropdown, GlDropdownDivider, GlButton, GlFormInputGroup } from '@gitlab/ui'; import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
import Link from '~/content_editor/extensions/link'; import Link from '~/content_editor/extensions/link';
...@@ -19,11 +19,18 @@ describe('content_editor/components/toolbar_link_button', () => { ...@@ -19,11 +19,18 @@ describe('content_editor/components/toolbar_link_button', () => {
}); });
}; };
const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]'); const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]');
const findApplyLinkButton = () => wrapper.findComponent(GlButton); const findApplyLinkButton = () => wrapper.findComponent(GlButton);
const findRemoveLinkButton = () => wrapper.findByText('Remove link'); const findRemoveLinkButton = () => wrapper.findByText('Remove link');
const selectFile = async (file) => {
const input = wrapper.find({ ref: 'fileSelector' });
// override the property definition because `input.files` isn't directly modifyable
Object.defineProperty(input.element, 'files', { value: [file], writable: true });
await input.trigger('change');
};
beforeEach(() => { beforeEach(() => {
editor = createTestEditor(); editor = createTestEditor();
}); });
...@@ -51,8 +58,11 @@ describe('content_editor/components/toolbar_link_button', () => { ...@@ -51,8 +58,11 @@ describe('content_editor/components/toolbar_link_button', () => {
expect(findDropdown().props('toggleClass')).toEqual({ active: true }); expect(findDropdown().props('toggleClass')).toEqual({ active: true });
}); });
it('does not display the upload file option', () => {
expect(wrapper.findByText('Upload file').exists()).toBe(false);
});
it('displays a remove link dropdown option', () => { it('displays a remove link dropdown option', () => {
expect(findDropdownDivider().exists()).toBe(true);
expect(wrapper.findByText('Remove link').exists()).toBe(true); expect(wrapper.findByText('Remove link').exists()).toBe(true);
}); });
...@@ -107,7 +117,7 @@ describe('content_editor/components/toolbar_link_button', () => { ...@@ -107,7 +117,7 @@ describe('content_editor/components/toolbar_link_button', () => {
}); });
}); });
describe('when there is not an active link', () => { describe('when there is no active link', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(editor, 'isActive'); jest.spyOn(editor, 'isActive');
editor.isActive.mockReturnValueOnce(false); editor.isActive.mockReturnValueOnce(false);
...@@ -118,8 +128,11 @@ describe('content_editor/components/toolbar_link_button', () => { ...@@ -118,8 +128,11 @@ describe('content_editor/components/toolbar_link_button', () => {
expect(findDropdown().props('toggleClass')).toEqual({ active: false }); expect(findDropdown().props('toggleClass')).toEqual({ active: false });
}); });
it('displays the upload file option', () => {
expect(wrapper.findByText('Upload file').exists()).toBe(true);
});
it('does not display a remove link dropdown option', () => { it('does not display a remove link dropdown option', () => {
expect(findDropdownDivider().exists()).toBe(false);
expect(wrapper.findByText('Remove link').exists()).toBe(false); expect(wrapper.findByText('Remove link').exists()).toBe(false);
}); });
...@@ -138,6 +151,19 @@ describe('content_editor/components/toolbar_link_button', () => { ...@@ -138,6 +151,19 @@ describe('content_editor/components/toolbar_link_button', () => {
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
}); });
it('uploads the selected image when file input changes', async () => {
const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']);
const file = new File(['foo'], 'foo.png', { type: 'image/png' });
await selectFile(file);
expect(commands.focus).toHaveBeenCalled();
expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
});
}); });
describe('when the user displays the dropdown', () => { describe('when the user displays the dropdown', () => {
......
...@@ -2,7 +2,10 @@ import axios from 'axios'; ...@@ -2,7 +2,10 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { once } from 'lodash'; import { once } from 'lodash';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import Attachment from '~/content_editor/extensions/attachment';
import Image from '~/content_editor/extensions/image'; import Image from '~/content_editor/extensions/image';
import Link from '~/content_editor/extensions/link';
import Loading from '~/content_editor/extensions/loading';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { loadMarkdownApiResult } from '../markdown_processing_examples'; import { loadMarkdownApiResult } from '../markdown_processing_examples';
import { createTestEditor, createDocBuilder } from '../test_utils'; import { createTestEditor, createDocBuilder } from '../test_utils';
...@@ -13,27 +16,32 @@ describe('content_editor/extensions/image', () => { ...@@ -13,27 +16,32 @@ describe('content_editor/extensions/image', () => {
let doc; let doc;
let p; let p;
let image; let image;
let loading;
let link;
let renderMarkdown; let renderMarkdown;
let mock; let mock;
const uploadsPath = '/uploads/'; const uploadsPath = '/uploads/';
const validFile = new File(['foo'], 'foo.png', { type: 'image/png' }); const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
const invalidFile = new File(['foo'], 'bar.html', { type: 'text/html' }); const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' });
beforeEach(() => { beforeEach(() => {
renderMarkdown = jest renderMarkdown = jest.fn();
.fn()
.mockResolvedValue(loadMarkdownApiResult('project_wiki_attachment_image').body);
tiptapEditor = createTestEditor({ tiptapEditor = createTestEditor({
extensions: [Image.configure({ renderMarkdown, uploadsPath })], extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })],
}); });
({ ({
builders: { doc, p, image }, builders: { doc, p, image, loading, link },
eq, eq,
} = createDocBuilder({ } = createDocBuilder({
tiptapEditor, tiptapEditor,
names: { image: { nodeType: Image.name } }, names: {
loading: { markType: Loading.name },
image: { nodeType: Image.name },
link: { nodeType: Link.name },
},
})); }));
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
...@@ -44,70 +52,39 @@ describe('content_editor/extensions/image', () => { ...@@ -44,70 +52,39 @@ describe('content_editor/extensions/image', () => {
}); });
it.each` it.each`
file | valid | description eventType | propName | eventData | output
${validFile} | ${true} | ${'handles paste event when mime type is valid'} ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [attachmentFile] } }} | ${true}
${invalidFile} | ${false} | ${'does not handle paste event when mime type is invalid'} ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [] } }} | ${undefined}
`('$description', ({ file, valid }) => { ${'drop'} | ${'handleDrop'} | ${{ dataTransfer: { files: [attachmentFile] } }} | ${true}
const pasteEvent = Object.assign(new Event('paste'), { `('handles $eventType properly', ({ eventType, propName, eventData, output }) => {
clipboardData: { const event = Object.assign(new Event(eventType), eventData);
files: [file], const handled = tiptapEditor.view.someProp(propName, (eventHandler) => {
}, return eventHandler(tiptapEditor.view, event);
});
let handled;
tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
handled = eventHandler(tiptapEditor.view, pasteEvent);
}); });
expect(handled).toBe(valid); expect(handled).toBe(output);
}); });
it.each` describe('uploadAttachment command', () => {
file | valid | description let initialDoc;
${validFile} | ${true} | ${'handles drop event when mime type is valid'} beforeEach(() => {
${invalidFile} | ${false} | ${'does not handle drop event when mime type is invalid'} initialDoc = doc(p(''));
`('$description', ({ file, valid }) => { tiptapEditor.commands.setContent(initialDoc.toJSON());
const dropEvent = Object.assign(new Event('drop'), {
dataTransfer: {
files: [file],
},
});
let handled;
tiptapEditor.view.someProp('handleDrop', (eventHandler) => {
handled = eventHandler(tiptapEditor.view, dropEvent);
}); });
expect(handled).toBe(valid); describe('when the file has image mime type', () => {
});
it('handles paste event when mime type is correct', () => {
const pasteEvent = Object.assign(new Event('paste'), {
clipboardData: {
files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
},
});
const handled = tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
return eventHandler(tiptapEditor.view, pasteEvent);
});
expect(handled).toBe(true);
});
describe('uploadImage command', () => {
describe('when file has correct mime type', () => {
let initialDoc;
const base64EncodedFile = 'data:image/png;base64,Zm9v'; const base64EncodedFile = 'data:image/png;base64,Zm9v';
beforeEach(() => { beforeEach(() => {
initialDoc = doc(p('')); renderMarkdown.mockResolvedValue(
tiptapEditor.commands.setContent(initialDoc.toJSON()); loadMarkdownApiResult('project_wiki_attachment_image').body,
);
}); });
describe('when uploading image succeeds', () => { describe('when uploading succeeds', () => {
const successResponse = { const successResponse = {
link: { link: {
markdown: '[image](/uploads/25265/image.png)', markdown: '![test-file](test-file.png)',
}, },
}; };
...@@ -126,7 +103,7 @@ describe('content_editor/extensions/image', () => { ...@@ -126,7 +103,7 @@ describe('content_editor/extensions/image', () => {
}), }),
); );
tiptapEditor.commands.uploadImage({ file: validFile }); tiptapEditor.commands.uploadAttachment({ file: imageFile });
}); });
it('updates the inserted image with canonicalSrc when upload is successful', async () => { it('updates the inserted image with canonicalSrc when upload is successful', async () => {
...@@ -141,7 +118,7 @@ describe('content_editor/extensions/image', () => { ...@@ -141,7 +118,7 @@ describe('content_editor/extensions/image', () => {
), ),
); );
tiptapEditor.commands.uploadImage({ file: validFile }); tiptapEditor.commands.uploadAttachment({ file: imageFile });
await waitForPromises(); await waitForPromises();
...@@ -149,7 +126,7 @@ describe('content_editor/extensions/image', () => { ...@@ -149,7 +126,7 @@ describe('content_editor/extensions/image', () => {
}); });
}); });
describe('when uploading image request fails', () => { describe('when uploading request fails', () => {
beforeEach(() => { beforeEach(() => {
mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
}); });
...@@ -157,7 +134,7 @@ describe('content_editor/extensions/image', () => { ...@@ -157,7 +134,7 @@ describe('content_editor/extensions/image', () => {
it('resets the doc to orginal state', async () => { it('resets the doc to orginal state', async () => {
const expectedDoc = doc(p('')); const expectedDoc = doc(p(''));
tiptapEditor.commands.uploadImage({ file: validFile }); tiptapEditor.commands.uploadAttachment({ file: imageFile });
await waitForPromises(); await waitForPromises();
...@@ -165,7 +142,7 @@ describe('content_editor/extensions/image', () => { ...@@ -165,7 +142,7 @@ describe('content_editor/extensions/image', () => {
}); });
it('emits an error event that includes an error message', (done) => { it('emits an error event that includes an error message', (done) => {
tiptapEditor.commands.uploadImage({ file: validFile }); tiptapEditor.commands.uploadAttachment({ file: imageFile });
tiptapEditor.on('error', (message) => { tiptapEditor.on('error', (message) => {
expect(message).toBe('An error occurred while uploading the image. Please try again.'); expect(message).toBe('An error occurred while uploading the image. Please try again.');
...@@ -175,18 +152,83 @@ describe('content_editor/extensions/image', () => { ...@@ -175,18 +152,83 @@ describe('content_editor/extensions/image', () => {
}); });
}); });
describe('when file does not have correct mime type', () => { describe('when the file has a zip (or any other attachment) mime type', () => {
let initialDoc; const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body;
beforeEach(() => { beforeEach(() => {
initialDoc = doc(p('')); renderMarkdown.mockResolvedValue(markdownApiResult);
tiptapEditor.commands.setContent(initialDoc.toJSON());
}); });
it('does not start the upload image process', () => { describe('when uploading succeeds', () => {
tiptapEditor.commands.uploadImage({ file: invalidFile }); const successResponse = {
link: {
markdown: '[test file](test-file.zip)',
},
};
beforeEach(() => {
mock.onPost().reply(httpStatus.OK, successResponse);
});
it('inserts a loading mark', (done) => {
const expectedDoc = doc(p(loading({ label: 'test file' })));
tiptapEditor.on(
'update',
once(() => {
expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
done();
}),
);
expect(eq(tiptapEditor.state.doc, initialDoc)).toBe(true); tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
});
it('updates the loading mark with a link with canonicalSrc and href attrs', async () => {
const [, group, project] = markdownApiResult.match(/\/(group[0-9]+)\/(project[0-9]+)\//);
const expectedDoc = doc(
p(
link(
{
canonicalSrc: 'test-file.zip',
href: `/${group}/${project}/-/wikis/test-file.zip`,
},
'test file',
),
),
);
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
await waitForPromises();
expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
});
});
describe('when uploading request fails', () => {
beforeEach(() => {
mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
});
it('resets the doc to orginal state', async () => {
const expectedDoc = doc(p(''));
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
await waitForPromises();
expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
});
it('emits an error event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
tiptapEditor.on('error', (message) => {
expect(message).toBe('An error occurred while uploading the file. Please try again.');
done();
});
});
}); });
}); });
}); });
......
...@@ -52,9 +52,9 @@ describe('content_editor/services/create_editor', () => { ...@@ -52,9 +52,9 @@ describe('content_editor/services/create_editor', () => {
expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}); });
it('provides uploadsPath and renderMarkdown function to Image extension', () => { it('provides uploadsPath and renderMarkdown function to Attachment extension', () => {
expect( expect(
editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'image').options, editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'attachment').options,
).toMatchObject({ ).toMatchObject({
uploadsPath, uploadsPath,
renderMarkdown, renderMarkdown,
......
import axios from 'axios'; import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { uploadFile } from '~/content_editor/services/upload_file'; import { uploadFile } from '~/content_editor/services/upload_helpers';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
describe('content_editor/services/upload_file', () => { describe('content_editor/services/upload_helpers', () => {
const uploadsPath = '/uploads'; const uploadsPath = '/uploads';
const file = new File(['content'], 'file.txt'); const file = new File(['content'], 'file.txt');
// TODO: Replace with automated fixture // TODO: Replace with automated fixture
......
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