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 {
GlDropdownItem,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { acceptedMimes } from '../extensions/image';
import { getImageAlt } from '../services/utils';
import { acceptedMimes } from '../services/upload_helpers';
import { parseFilename } from '../services/utils';
export default {
components: {
......@@ -41,7 +41,7 @@ export default {
.setImage({
src: this.imgSrc,
canonicalSrc: this.imgSrc,
alt: getImageAlt(this.imgSrc),
alt: parseFilename(this.imgSrc),
})
.run();
......@@ -58,7 +58,7 @@ export default {
this.tiptapEditor
.chain()
.focus()
.uploadImage({
.uploadAttachment({
file: e.target.files[0],
})
.run();
......@@ -67,7 +67,7 @@ export default {
this.emitExecute('upload');
},
},
acceptedMimes,
acceptedMimes: acceptedMimes.image,
};
</script>
<template>
......
......@@ -33,6 +33,13 @@ export default {
};
},
methods: {
resetFields() {
this.imgSrc = '';
this.$refs.fileSelector.value = '';
},
openFileUpload() {
this.$refs.fileSelector.click();
},
updateLinkState({ editor }) {
const { canonicalSrc, href } = editor.getAttributes(Link.name);
......@@ -65,6 +72,18 @@ export default {
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>
......@@ -83,14 +102,25 @@ export default {
<gl-dropdown-form class="gl-px-3!">
<gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
<template #append>
<gl-button variant="confirm" @click="updateLink()">{{ __('Apply') }}</gl-button>
<gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
</template>
</gl-form-input-group>
</gl-dropdown-form>
<gl-dropdown-divider v-if="isActive" />
<gl-dropdown-item v-if="isActive" @click="removeLink()">
<gl-dropdown-divider />
<gl-dropdown-item v-if="isActive" @click="removeLink">
{{ __('Remove link') }}
</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>
</editor-state-observer>
</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 { VueNodeViewRenderer } from '@tiptap/vue-2';
import { Plugin, PluginKey } from 'prosemirror-state';
import { __ } from '~/locale';
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) =>
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({
defaultOptions: {
...Image.options,
uploadsPath: null,
renderMarkdown: null,
inline: true,
},
addAttributes() {
......@@ -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() {
return VueNodeViewRenderer(ImageWrapper);
},
......
......@@ -21,6 +21,10 @@ export const extractHrefFromMarkdownLink = (match) => {
};
export default Link.extend({
defaultOptions: {
...Link.options,
openOnClick: false,
},
addInputRules() {
return [
markInputRule(markdownLinkSyntaxInputRuleRegExp, this.type, extractHrefFromMarkdownLink),
......@@ -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 { isFunction } from 'lodash';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
......@@ -17,6 +18,7 @@ import Image from '../extensions/image';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
import Loading from '../extensions/loading';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import Strike from '../extensions/strike';
......@@ -52,6 +54,7 @@ export const createContentEditor = ({
}
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown }),
Blockquote,
Bold,
BulletList,
......@@ -64,10 +67,11 @@ export const createContentEditor = ({
Heading,
History,
HorizontalRule,
Image.configure({ uploadsPath, renderMarkdown }),
Image,
Italic,
Link,
ListItem,
Loading,
OrderedList,
Paragraph,
Strike,
......
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 parser = new DOMParser();
......@@ -42,3 +48,72 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
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) => {
return from < to;
};
export const getImageAlt = (src) => {
return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' ');
export const parseFilename = (src) => {
return src.replace(/^.*\/|\..+?$/g, '').replace(/\W+/g, ' ');
};
export const readFileAsDataURL = (file) => {
......
......@@ -3804,6 +3804,9 @@ msgstr ""
msgid "An error occurred while updating the notification settings. Please try again."
msgstr ""
msgid "An error occurred while uploading the file. Please try again."
msgstr ""
msgid "An error occurred while uploading the image. Please try again."
msgstr ""
......
......@@ -26,8 +26,21 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen
</div>
</form>
</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>
......
import { GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
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 { createTestEditor, mockChainedCommands } from '../test_utils';
......@@ -31,7 +32,8 @@ describe('content_editor/components/toolbar_image_button', () => {
beforeEach(() => {
editor = createTestEditor({
extensions: [
Image.configure({
Image,
Attachment.configure({
renderMarkdown: jest.fn(),
uploadsPath: '/uploads/',
}),
......@@ -64,13 +66,13 @@ describe('content_editor/components/toolbar_image_button', () => {
});
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' });
await selectFile(file);
expect(commands.focus).toHaveBeenCalled();
expect(commands.uploadImage).toHaveBeenCalledWith({ file });
expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
expect(commands.run).toHaveBeenCalled();
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 ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
import Link from '~/content_editor/extensions/link';
......@@ -19,11 +19,18 @@ describe('content_editor/components/toolbar_link_button', () => {
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]');
const findApplyLinkButton = () => wrapper.findComponent(GlButton);
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(() => {
editor = createTestEditor();
});
......@@ -51,8 +58,11 @@ describe('content_editor/components/toolbar_link_button', () => {
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', () => {
expect(findDropdownDivider().exists()).toBe(true);
expect(wrapper.findByText('Remove link').exists()).toBe(true);
});
......@@ -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(() => {
jest.spyOn(editor, 'isActive');
editor.isActive.mockReturnValueOnce(false);
......@@ -118,8 +128,11 @@ describe('content_editor/components/toolbar_link_button', () => {
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', () => {
expect(findDropdownDivider().exists()).toBe(false);
expect(wrapper.findByText('Remove link').exists()).toBe(false);
});
......@@ -138,6 +151,19 @@ describe('content_editor/components/toolbar_link_button', () => {
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', () => {
......
......@@ -2,7 +2,10 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { once } from 'lodash';
import waitForPromises from 'helpers/wait_for_promises';
import Attachment from '~/content_editor/extensions/attachment';
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 { loadMarkdownApiResult } from '../markdown_processing_examples';
import { createTestEditor, createDocBuilder } from '../test_utils';
......@@ -13,27 +16,32 @@ describe('content_editor/extensions/image', () => {
let doc;
let p;
let image;
let loading;
let link;
let renderMarkdown;
let mock;
const uploadsPath = '/uploads/';
const validFile = new File(['foo'], 'foo.png', { type: 'image/png' });
const invalidFile = new File(['foo'], 'bar.html', { type: 'text/html' });
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' });
beforeEach(() => {
renderMarkdown = jest
.fn()
.mockResolvedValue(loadMarkdownApiResult('project_wiki_attachment_image').body);
renderMarkdown = jest.fn();
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,
} = createDocBuilder({
tiptapEditor,
names: { image: { nodeType: Image.name } },
names: {
loading: { markType: Loading.name },
image: { nodeType: Image.name },
link: { nodeType: Link.name },
},
}));
mock = new MockAdapter(axios);
......@@ -44,70 +52,39 @@ describe('content_editor/extensions/image', () => {
});
it.each`
file | valid | description
${validFile} | ${true} | ${'handles paste event when mime type is valid'}
${invalidFile} | ${false} | ${'does not handle paste event when mime type is invalid'}
`('$description', ({ file, valid }) => {
const pasteEvent = Object.assign(new Event('paste'), {
clipboardData: {
files: [file],
},
});
let handled;
tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
handled = eventHandler(tiptapEditor.view, pasteEvent);
eventType | propName | eventData | output
${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [attachmentFile] } }} | ${true}
${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [] } }} | ${undefined}
${'drop'} | ${'handleDrop'} | ${{ dataTransfer: { files: [attachmentFile] } }} | ${true}
`('handles $eventType properly', ({ eventType, propName, eventData, output }) => {
const event = Object.assign(new Event(eventType), eventData);
const handled = tiptapEditor.view.someProp(propName, (eventHandler) => {
return eventHandler(tiptapEditor.view, event);
});
expect(handled).toBe(valid);
expect(handled).toBe(output);
});
it.each`
file | valid | description
${validFile} | ${true} | ${'handles drop event when mime type is valid'}
${invalidFile} | ${false} | ${'does not handle drop event when mime type is invalid'}
`('$description', ({ file, valid }) => {
const dropEvent = Object.assign(new Event('drop'), {
dataTransfer: {
files: [file],
},
});
let handled;
tiptapEditor.view.someProp('handleDrop', (eventHandler) => {
handled = eventHandler(tiptapEditor.view, dropEvent);
describe('uploadAttachment command', () => {
let initialDoc;
beforeEach(() => {
initialDoc = doc(p(''));
tiptapEditor.commands.setContent(initialDoc.toJSON());
});
expect(handled).toBe(valid);
});
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;
describe('when the file has image mime type', () => {
const base64EncodedFile = 'data:image/png;base64,Zm9v';
beforeEach(() => {
initialDoc = doc(p(''));
tiptapEditor.commands.setContent(initialDoc.toJSON());
renderMarkdown.mockResolvedValue(
loadMarkdownApiResult('project_wiki_attachment_image').body,
);
});
describe('when uploading image succeeds', () => {
describe('when uploading succeeds', () => {
const successResponse = {
link: {
markdown: '[image](/uploads/25265/image.png)',
markdown: '![test-file](test-file.png)',
},
};
......@@ -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 () => {
......@@ -141,7 +118,7 @@ describe('content_editor/extensions/image', () => {
),
);
tiptapEditor.commands.uploadImage({ file: validFile });
tiptapEditor.commands.uploadAttachment({ file: imageFile });
await waitForPromises();
......@@ -149,7 +126,7 @@ describe('content_editor/extensions/image', () => {
});
});
describe('when uploading image request fails', () => {
describe('when uploading request fails', () => {
beforeEach(() => {
mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
});
......@@ -157,7 +134,7 @@ describe('content_editor/extensions/image', () => {
it('resets the doc to orginal state', async () => {
const expectedDoc = doc(p(''));
tiptapEditor.commands.uploadImage({ file: validFile });
tiptapEditor.commands.uploadAttachment({ file: imageFile });
await waitForPromises();
......@@ -165,7 +142,7 @@ describe('content_editor/extensions/image', () => {
});
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) => {
expect(message).toBe('An error occurred while uploading the image. Please try again.');
......@@ -175,18 +152,83 @@ describe('content_editor/extensions/image', () => {
});
});
describe('when file does not have correct mime type', () => {
let initialDoc;
describe('when the file has a zip (or any other attachment) mime type', () => {
const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body;
beforeEach(() => {
initialDoc = doc(p(''));
tiptapEditor.commands.setContent(initialDoc.toJSON());
renderMarkdown.mockResolvedValue(markdownApiResult);
});
it('does not start the upload image process', () => {
tiptapEditor.commands.uploadImage({ file: invalidFile });
describe('when uploading succeeds', () => {
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', () => {
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(
editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'image').options,
editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'attachment').options,
).toMatchObject({
uploadsPath,
renderMarkdown,
......
import axios from 'axios';
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';
describe('content_editor/services/upload_file', () => {
describe('content_editor/services/upload_helpers', () => {
const uploadsPath = '/uploads';
const file = new File(['content'], 'file.txt');
// 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