Commit 999e21a7 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '218529-upload-image' into 'master'

Upload images from SSE to the project repo

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