Commit c416d436 authored by Jacques Erasmus's avatar Jacques Erasmus Committed by Natalia Tepluhina

Add ability to upload images

Add the ability to upload images via the SSE
parent 37d86926
......@@ -5,6 +5,8 @@ import EditHeader from './edit_header.vue';
import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue';
import parseSourceFile from '~/static_site_editor/services/parse_source_file';
import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants';
import { DEFAULT_IMAGE_UPLOAD_PATH } from '../constants';
import imageRepository from '../image_repository';
export default {
components: {
......@@ -31,6 +33,12 @@ export default {
required: false,
default: '',
},
imageRoot: {
type: String,
required: false,
default: DEFAULT_IMAGE_UPLOAD_PATH,
validator: prop => prop.endsWith('/'),
},
},
data() {
return {
......@@ -40,6 +48,7 @@ export default {
isModified: false,
};
},
imageRepository: imageRepository(),
computed: {
editableContent() {
return this.parsedSource.content(this.isWysiwygMode);
......@@ -57,8 +66,14 @@ export default {
this.editorMode = mode;
this.$refs.editor.resetInitialValue(this.editableContent);
},
onUploadImage({ file, imageUrl }) {
this.$options.imageRepository.add(file, imageUrl);
},
onSubmit() {
this.$emit('submit', { content: this.parsedSource.content() });
this.$emit('submit', {
content: this.parsedSource.content(),
images: this.$options.imageRepository.getAll(),
});
},
},
};
......@@ -70,9 +85,11 @@ export default {
ref="editor"
:content="editableContent"
:initial-edit-type="editorMode"
:image-root="imageRoot"
class="mb-9 h-100"
@modeChange="onModeChange"
@input="onInputChange"
@uploadImage="onUploadImage"
/>
<unsaved-changes-confirm-dialog :modified="isModified" />
<publish-toolbar
......
......@@ -19,3 +19,5 @@ export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor');
export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request';
export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor';
export const DEFAULT_IMAGE_UPLOAD_PATH = 'source/images/uploads/';
......@@ -3,10 +3,10 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
const submitContentChangesResolver = (
_,
{ input: { project: projectId, username, sourcePath, content } },
{ input: { project: projectId, username, sourcePath, content, images } },
{ cache },
) => {
return submitContentChanges({ projectId, username, sourcePath, content }).then(
return submitContentChanges({ projectId, username, sourcePath, content, images }).then(
savedContentMeta => {
cache.writeQuery({
query: savedContentMetaQuery,
......
import { __ } from '~/locale';
import Flash from '~/flash';
import { getBinary } from './services/image_service';
const imageRepository = () => {
const images = new Map();
const flash = message => new Flash(message);
const add = (file, url) => {
getBinary(file)
.then(content => images.set(url, content))
.catch(() => flash(__('Something went wrong while inserting your image. Please try again.')));
};
const getAll = () => images;
return { add, getAll };
};
export default imageRepository;
......@@ -67,11 +67,11 @@ export default {
onDismissError() {
this.submitChangesError = null;
},
onSubmit({ content }) {
onSubmit({ content, images }) {
this.content = content;
this.submitChanges();
this.submitChanges(images);
},
submitChanges() {
submitChanges(images) {
this.isSavingChanges = true;
this.$apollo
......@@ -83,6 +83,7 @@ export default {
username: this.appData.username,
sourcePath: this.appData.sourcePath,
content: this.content,
images,
},
},
})
......
// eslint-disable-next-line import/prefer-default-export
export const getBinary = file => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.onerror = error => reject(error);
});
};
......@@ -21,7 +21,32 @@ const createBranch = (projectId, branch) =>
throw new Error(SUBMIT_CHANGES_BRANCH_ERROR);
});
const commitContent = (projectId, message, branch, sourcePath, content) => {
const createImageActions = (images, markdown) => {
const actions = [];
if (!markdown) {
return actions;
}
images.forEach((imageContent, filePath) => {
const imageExistsInMarkdown = path => new RegExp(`!\\[([^[\\]\\n]*)\\](\\(${path})\\)`); // matches the image markdown syntax: ![<any-string-except-newline>](<path>)
if (imageExistsInMarkdown(filePath).test(markdown)) {
actions.push(
convertObjectPropsToSnakeCase({
encoding: 'base64',
action: 'create',
content: imageContent,
filePath,
}),
);
}
});
return actions;
};
const commitContent = (projectId, message, branch, sourcePath, content, images) => {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT);
return Api.commitMultiple(
......@@ -35,6 +60,7 @@ const commitContent = (projectId, message, branch, sourcePath, content) => {
filePath: sourcePath,
content,
}),
...createImageActions(images, content),
],
}),
).catch(() => {
......@@ -62,7 +88,7 @@ const createMergeRequest = (
});
};
const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
const submitContentChanges = ({ username, projectId, sourcePath, content, images }) => {
const branch = generateBranchName(username);
const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
sourcePath,
......@@ -73,7 +99,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
.then(({ data: { web_url: url } }) => {
Object.assign(meta, { branch: { label: branch, url } });
return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content);
return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content, images);
})
.then(({ data: { short_id: label, web_url: url } }) => {
Object.assign(meta, { commit: { label, url } });
......
......@@ -16,8 +16,15 @@ export default {
GlTab,
},
mixins: [glFeatureFlagMixin()],
props: {
imageRoot: {
type: String,
required: true,
},
},
data() {
return {
file: null,
urlError: null,
imageUrl: null,
description: null,
......@@ -38,6 +45,7 @@ export default {
},
methods: {
show() {
this.file = null;
this.urlError = null;
this.imageUrl = null;
this.description = null;
......@@ -66,7 +74,9 @@ export default {
return;
}
this.$emit('addImage', { file, altText: altText || file.name });
const imageUrl = `${this.imageRoot}${file.name}`;
this.$emit('addImage', { imageUrl, file, altText: altText || file.name });
},
submitURL(event) {
if (!this.validateUrl()) {
......
......@@ -19,8 +19,6 @@ import {
getMarkdown,
} from './services/editor_service';
import { getUrl } from './services/image_service';
export default {
components: {
ToastEditor: () =>
......@@ -54,6 +52,11 @@ export default {
required: false,
default: EDITOR_PREVIEW_STYLE,
},
imageRoot: {
type: String,
required: true,
validator: prop => prop.endsWith('/'),
},
},
data() {
return {
......@@ -104,10 +107,8 @@ export default {
const image = { imageUrl, altText };
if (file) {
image.imageUrl = getUrl(file);
// TODO - persist images locally (local image repository)
this.$emit('uploadImage', { file, imageUrl });
// TODO - ensure that the actual repo URL for the image is used in Markdown mode
// TODO - upload images to the project repository (on submit)
}
addImage(this.editorInstance, image);
......@@ -130,6 +131,6 @@ export default {
@change="onContentChanged"
@load="onLoad"
/>
<add-image-modal ref="addImageModal" @addImage="onAddImage" />
<add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
</div>
</template>
// eslint-disable-next-line import/prefer-default-export
export const getUrl = file => URL.createObjectURL(file);
......@@ -21508,6 +21508,9 @@ msgstr ""
msgid "Something went wrong while initializing the OpenAPI viewer"
msgstr ""
msgid "Something went wrong while inserting your image. Please try again."
msgstr ""
msgid "Something went wrong while merging this merge request. Please try again."
msgstr ""
......
......@@ -10,6 +10,8 @@ export const sourceContentBody = `## On this page
- TOC
{:toc .hidden-md .hidden-lg}
![image](path/to/image1.png)
`;
export const sourceContent = `${sourceContentHeader}${sourceContentSpacing}${sourceContentBody}`;
export const sourceContentTitle = 'Handbook';
......@@ -48,3 +50,8 @@ export const createMergeRequestResponse = {
};
export const trackingCategory = 'projects:static_site_editor:show';
export const images = new Map([
['path/to/image1.png', 'image1-content'],
['path/to/image2.png', 'image2-content'],
]);
......@@ -22,6 +22,7 @@ import {
sourcePath,
sourceContent as content,
trackingCategory,
images,
} from '../mock_data';
jest.mock('~/static_site_editor/services/generate_branch_name');
......@@ -69,7 +70,7 @@ describe('submitContentChanges', () => {
});
it('commits the content changes to the branch when creating branch succeeds', () => {
return submitContentChanges({ username, projectId, sourcePath, content }).then(() => {
return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => {
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, {
branch,
commit_message: mergeRequestTitle,
......@@ -79,6 +80,35 @@ describe('submitContentChanges', () => {
file_path: sourcePath,
content,
},
{
action: 'create',
content: 'image1-content',
encoding: 'base64',
file_path: 'path/to/image1.png',
},
],
});
});
});
it('does not commit an image if it has been removed from the content', () => {
const contentWithoutImages = '## Content without images';
return submitContentChanges({
username,
projectId,
sourcePath,
content: contentWithoutImages,
images,
}).then(() => {
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, {
branch,
commit_message: mergeRequestTitle,
actions: [
{
action: 'update',
file_path: sourcePath,
content: contentWithoutImages,
},
],
});
});
......@@ -87,13 +117,13 @@ describe('submitContentChanges', () => {
it('notifies error when content could not be committed', () => {
Api.commitMultiple.mockRejectedValueOnce();
return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow(
SUBMIT_CHANGES_COMMIT_ERROR,
);
});
it('creates a merge request when commiting changes succeeds', () => {
return submitContentChanges({ username, projectId, sourcePath, content }).then(() => {
return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => {
expect(Api.createProjectMergeRequest).toHaveBeenCalledWith(
projectId,
convertObjectPropsToSnakeCase({
......@@ -108,7 +138,7 @@ describe('submitContentChanges', () => {
it('notifies error when merge request could not be created', () => {
Api.createProjectMergeRequest.mockRejectedValueOnce();
return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow(
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
);
});
......@@ -117,9 +147,11 @@ describe('submitContentChanges', () => {
let result;
beforeEach(() => {
return submitContentChanges({ username, projectId, sourcePath, content }).then(_result => {
result = _result;
});
return submitContentChanges({ username, projectId, sourcePath, content, images }).then(
_result => {
result = _result;
},
);
});
it('returns the branch name', () => {
......@@ -147,7 +179,7 @@ describe('submitContentChanges', () => {
describe('sends the correct tracking event', () => {
beforeEach(() => {
return submitContentChanges({ username, projectId, sourcePath, content });
return submitContentChanges({ username, projectId, sourcePath, content, images });
});
it('for committing changes', () => {
......
......@@ -6,6 +6,7 @@ import { IMAGE_TABS } from '~/vue_shared/components/rich_content_editor/constant
describe('Add Image Modal', () => {
let wrapper;
const propsData = { imageRoot: 'path/to/root/' };
const findModal = () => wrapper.find(GlModal);
const findTabs = () => wrapper.find(GlTabs);
......@@ -14,7 +15,10 @@ describe('Add Image Modal', () => {
const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' });
beforeEach(() => {
wrapper = shallowMount(AddImageModal, { provide: { glFeatures: { sseImageUploads: true } } });
wrapper = shallowMount(AddImageModal, {
provide: { glFeatures: { sseImageUploads: true } },
propsData,
});
});
describe('when content is loaded', () => {
......@@ -44,9 +48,10 @@ describe('Add Image Modal', () => {
it('validates the file', () => {
const preventDefault = jest.fn();
const description = 'some description';
const file = { name: 'some_file.png' };
wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() };
wrapper.setData({ description, tabIndex: IMAGE_TABS.UPLOAD_TAB });
wrapper.setData({ file, description, tabIndex: IMAGE_TABS.UPLOAD_TAB });
findModal().vm.$emit('ok', { preventDefault });
......
......@@ -28,12 +28,13 @@ describe('Rich Content Editor', () => {
let wrapper;
const content = '## Some Markdown';
const imageRoot = 'path/to/root/';
const findEditor = () => wrapper.find({ ref: 'editor' });
const findAddImageModal = () => wrapper.find(AddImageModal);
beforeEach(() => {
wrapper = shallowMount(RichContentEditor, {
propsData: { content },
propsData: { content, imageRoot },
});
});
......
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