Commit 2b0f1f08 authored by Jacques Erasmus's avatar Jacques Erasmus Committed by Enrique Alcántara

Add image upload tab to the Static Site Editor

Added a tab for image uploads
parent e22534e9
...@@ -43,3 +43,7 @@ export const EDITOR_TYPES = { ...@@ -43,3 +43,7 @@ export const EDITOR_TYPES = {
export const EDITOR_HEIGHT = '100%'; export const EDITOR_HEIGHT = '100%';
export const EDITOR_PREVIEW_STYLE = 'horizontal'; export const EDITOR_PREVIEW_STYLE = 'horizontal';
export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 };
export const MAX_FILE_SIZE = 2097152; // 2Mb
<script>
import { isSafeURL } from '~/lib/utils/url_utility';
import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IMAGE_TABS } from '../../constants';
import UploadImageTab from './upload_image_tab.vue';
export default {
components: {
UploadImageTab,
GlModal,
GlFormGroup,
GlFormInput,
GlTabs,
GlTab,
},
mixins: [glFeatureFlagMixin()],
data() {
return {
urlError: null,
imageUrl: null,
description: null,
tabIndex: IMAGE_TABS.UPLOAD_TAB,
uploadImageTab: null,
};
},
modalTitle: __('Image Details'),
okTitle: __('Insert'),
urlTabTitle: __('By URL'),
urlLabel: __('Image URL'),
descriptionLabel: __('Description'),
uploadTabTitle: __('Upload file'),
computed: {
altText() {
return this.description;
},
},
methods: {
show() {
this.urlError = null;
this.imageUrl = null;
this.description = null;
this.tabIndex = IMAGE_TABS.UPLOAD_TAB;
this.$refs.modal.show();
},
onOk(event) {
if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
this.submitFile(event);
return;
}
this.submitURL(event);
},
setFile(file) {
this.file = file;
},
submitFile(event) {
const { file, altText } = this;
const { uploadImageTab } = this.$refs;
uploadImageTab.validateFile();
if (uploadImageTab.fileError) {
event.preventDefault();
return;
}
this.$emit('addImage', { file, altText: altText || file.name });
},
submitURL(event) {
if (!this.validateUrl()) {
event.preventDefault();
return;
}
const { imageUrl, altText } = this;
this.$emit('addImage', { imageUrl, altText: altText || imageUrl });
},
validateUrl() {
if (!isSafeURL(this.imageUrl)) {
this.urlError = __('Please provide a valid URL');
this.$refs.urlInput.$el.focus();
return false;
}
return true;
},
},
};
</script>
<template>
<gl-modal
ref="modal"
modal-id="add-image-modal"
:title="$options.modalTitle"
:ok-title="$options.okTitle"
@ok="onOk"
>
<gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex">
<!-- Upload file Tab -->
<gl-tab :title="$options.uploadTabTitle">
<upload-image-tab ref="uploadImageTab" @input="setFile" />
</gl-tab>
<!-- By URL Tab -->
<gl-tab :title="$options.urlTabTitle">
<gl-form-group
class="gl-mt-5 gl-mb-3"
:label="$options.urlLabel"
label-for="url-input"
:state="!Boolean(urlError)"
:invalid-feedback="urlError"
>
<gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
</gl-form-group>
</gl-tab>
</gl-tabs>
<gl-form-group
v-else
class="gl-mt-5 gl-mb-3"
:label="$options.urlLabel"
label-for="url-input"
:state="!Boolean(urlError)"
:invalid-feedback="urlError"
>
<gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
</gl-form-group>
<!-- Description Input -->
<gl-form-group :label="$options.descriptionLabel" label-for="description-input">
<gl-form-input id="description-input" ref="descriptionInput" v-model="description" />
</gl-form-group>
</gl-modal>
</template>
<script>
import { __ } from '~/locale';
import { GlFormGroup } from '@gitlab/ui';
import { MAX_FILE_SIZE } from '../../constants';
export default {
components: {
GlFormGroup,
},
data() {
return {
file: null,
fileError: null,
};
},
fileLabel: __('Select file'),
methods: {
onInput(event) {
[this.file] = event.target.files;
this.validateFile();
if (!this.fileError) {
this.$emit('input', this.file);
}
},
validateFile() {
this.fileError = null;
if (!this.file) {
this.fileError = __('Please choose a file');
} else if (this.file.size > MAX_FILE_SIZE) {
this.fileError = __('Maximum file size is 2MB. Please select a smaller file.');
}
},
},
};
</script>
<template>
<gl-form-group
class="gl-mt-5 gl-mb-3"
:label="$options.fileLabel"
label-for="file-input"
:state="!Boolean(fileError)"
:invalid-feedback="fileError"
>
<input
id="file-input"
ref="fileInput"
class="gl-mt-3 gl-mb-2"
type="file"
accept="image/*"
@input="onInput"
/>
</gl-form-group>
</template>
<script>
import { isSafeURL } from '~/lib/utils/url_utility';
import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlModal,
GlFormGroup,
GlFormInput,
},
data() {
return {
error: null,
imageUrl: null,
altText: null,
modalTitle: __('Image Details'),
okTitle: __('Insert'),
urlLabel: __('Image URL'),
descriptionLabel: __('Description'),
};
},
methods: {
show() {
this.error = null;
this.imageUrl = null;
this.altText = null;
this.$refs.modal.show();
},
onOk(event) {
if (!this.isValid()) {
event.preventDefault();
return;
}
const { imageUrl, altText } = this;
this.$emit('addImage', { imageUrl, altText: altText || __('image') });
},
isValid() {
if (!isSafeURL(this.imageUrl)) {
this.error = __('Please provide a valid URL');
this.$refs.urlInput.$el.focus();
return false;
}
return true;
},
},
};
</script>
<template>
<gl-modal
ref="modal"
modal-id="add-image-modal"
:title="modalTitle"
:ok-title="okTitle"
@ok="onOk"
>
<gl-form-group
:label="urlLabel"
label-for="url-input"
:state="!Boolean(error)"
:invalid-feedback="error"
>
<gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
</gl-form-group>
<gl-form-group :label="descriptionLabel" label-for="description-input">
<gl-form-input id="description-input" ref="descriptionInput" v-model="altText" />
</gl-form-group>
</gl-modal>
</template>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import 'codemirror/lib/codemirror.css'; import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css'; import '@toast-ui/editor/dist/toastui-editor.css';
import AddImageModal from './modals/add_image_modal.vue'; import AddImageModal from './modals/add_image/add_image_modal.vue';
import { import {
EDITOR_OPTIONS, EDITOR_OPTIONS,
EDITOR_TYPES, EDITOR_TYPES,
...@@ -18,6 +18,8 @@ import { ...@@ -18,6 +18,8 @@ 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: () =>
...@@ -96,7 +98,16 @@ export default { ...@@ -96,7 +98,16 @@ export default {
onOpenAddImageModal() { onOpenAddImageModal() {
this.$refs.addImageModal.show(); this.$refs.addImageModal.show();
}, },
onAddImage(image) { onAddImage({ imageUrl, altText, file }) {
const image = { imageUrl, altText };
if (file) {
image.imageUrl = getUrl(file);
// TODO - persist images locally (local image repository)
// 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);
}, },
onChangeMode(newMode) { onChangeMode(newMode) {
......
// eslint-disable-next-line import/prefer-default-export
export const getUrl = file => URL.createObjectURL(file);
...@@ -9,6 +9,9 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController ...@@ -9,6 +9,9 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:show] prepend_before_action :authenticate_user!, only: [:show]
before_action :assign_ref_and_path, only: [:show] before_action :assign_ref_and_path, only: [:show]
before_action :authorize_edit_tree!, only: [:show] before_action :authorize_edit_tree!, only: [:show]
before_action do
push_frontend_feature_flag(:sse_image_uploads)
end
def show def show
@config = Gitlab::StaticSiteEditor::Config.new(@repository, @ref, @path, params[:return_url]) @config = Gitlab::StaticSiteEditor::Config.new(@repository, @ref, @path, params[:return_url])
......
...@@ -3820,6 +3820,9 @@ msgstr "" ...@@ -3820,6 +3820,9 @@ msgstr ""
msgid "By %{user_name}" msgid "By %{user_name}"
msgstr "" msgstr ""
msgid "By URL"
msgstr ""
msgid "By default GitLab sends emails in HTML and plain text formats so mail clients can choose what format to use. Disable this option if you only want to send emails in plain text format." msgid "By default GitLab sends emails in HTML and plain text formats so mail clients can choose what format to use. Disable this option if you only want to send emails in plain text format."
msgstr "" msgstr ""
...@@ -13856,6 +13859,9 @@ msgstr "" ...@@ -13856,6 +13859,9 @@ msgstr ""
msgid "Maximum field length" msgid "Maximum field length"
msgstr "" msgstr ""
msgid "Maximum file size is 2MB. Please select a smaller file."
msgstr ""
msgid "Maximum import size (MB)" msgid "Maximum import size (MB)"
msgstr "" msgstr ""
...@@ -16654,6 +16660,9 @@ msgstr "" ...@@ -16654,6 +16660,9 @@ msgstr ""
msgid "Please check your email (%{email}) to verify that you own this address and unlock the power of CI/CD. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}." msgid "Please check your email (%{email}) to verify that you own this address and unlock the power of CI/CD. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}."
msgstr "" msgstr ""
msgid "Please choose a file"
msgstr ""
msgid "Please choose a group URL with no special characters." msgid "Please choose a group URL with no special characters."
msgstr "" msgstr ""
...@@ -20301,6 +20310,9 @@ msgstr "" ...@@ -20301,6 +20310,9 @@ msgstr ""
msgid "Select due date" msgid "Select due date"
msgstr "" msgstr ""
msgid "Select file"
msgstr ""
msgid "Select group or project" msgid "Select group or project"
msgstr "" msgstr ""
...@@ -27193,9 +27205,6 @@ msgstr "" ...@@ -27193,9 +27205,6 @@ msgstr ""
msgid "https://your-bitbucket-server" msgid "https://your-bitbucket-server"
msgstr "" msgstr ""
msgid "image"
msgstr ""
msgid "image diff" msgid "image diff"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui'; import { GlModal, GlTabs } from '@gitlab/ui';
import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue'; import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue';
import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue';
import { IMAGE_TABS } from '~/vue_shared/components/rich_content_editor/constants';
describe('Add Image Modal', () => { describe('Add Image Modal', () => {
let wrapper; let wrapper;
const findModal = () => wrapper.find(GlModal); const findModal = () => wrapper.find(GlModal);
const findTabs = () => wrapper.find(GlTabs);
const findUploadImageTab = () => wrapper.find(UploadImageTab);
const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); const findUrlInput = () => wrapper.find({ ref: 'urlInput' });
const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' });
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(AddImageModal); wrapper = shallowMount(AddImageModal, { provide: { glFeatures: { sseImageUploads: true } } });
}); });
describe('when content is loaded', () => { describe('when content is loaded', () => {
...@@ -18,6 +22,14 @@ describe('Add Image Modal', () => { ...@@ -18,6 +22,14 @@ describe('Add Image Modal', () => {
expect(findModal().exists()).toBe(true); expect(findModal().exists()).toBe(true);
}); });
it('renders a Tabs component', () => {
expect(findTabs().exists()).toBe(true);
});
it('renders an upload image tab', () => {
expect(findUploadImageTab().exists()).toBe(true);
});
it('renders an input to add an image URL', () => { it('renders an input to add an image URL', () => {
expect(findUrlInput().exists()).toBe(true); expect(findUrlInput().exists()).toBe(true);
}); });
...@@ -28,14 +40,32 @@ describe('Add Image Modal', () => { ...@@ -28,14 +40,32 @@ describe('Add Image Modal', () => {
}); });
describe('add image', () => { describe('add image', () => {
describe('Upload', () => {
it('validates the file', () => {
const preventDefault = jest.fn();
const description = 'some description';
wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() };
wrapper.setData({ description, tabIndex: IMAGE_TABS.UPLOAD_TAB });
findModal().vm.$emit('ok', { preventDefault });
expect(wrapper.vm.$refs.uploadImageTab.validateFile).toHaveBeenCalled();
});
});
describe('URL', () => {
it('emits an addImage event when a valid URL is specified', () => { it('emits an addImage event when a valid URL is specified', () => {
const preventDefault = jest.fn(); const preventDefault = jest.fn();
const mockImage = { imageUrl: '/some/valid/url.png', altText: 'some description' }; const mockImage = { imageUrl: '/some/valid/url.png', description: 'some description' };
wrapper.setData({ ...mockImage }); wrapper.setData({ ...mockImage, tabIndex: IMAGE_TABS.URL_TAB });
findModal().vm.$emit('ok', { preventDefault }); findModal().vm.$emit('ok', { preventDefault });
expect(preventDefault).not.toHaveBeenCalled(); expect(preventDefault).not.toHaveBeenCalled();
expect(wrapper.emitted('addImage')).toEqual([[mockImage]]); expect(wrapper.emitted('addImage')).toEqual([
[{ imageUrl: mockImage.imageUrl, altText: mockImage.description }],
]);
});
}); });
}); });
}); });
import { shallowMount } from '@vue/test-utils';
import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue';
describe('Upload Image Tab', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(UploadImageTab);
});
afterEach(() => wrapper.destroy());
const triggerInputEvent = size => {
const file = { size, name: 'file-name.png' };
const mockEvent = new Event('input');
Object.defineProperty(mockEvent, 'target', { value: { files: [file] } });
wrapper.find({ ref: 'fileInput' }).element.dispatchEvent(mockEvent);
return file;
};
describe('onInput', () => {
it.each`
size | fileError
${2000000000} | ${'Maximum file size is 2MB. Please select a smaller file.'}
${200} | ${null}
`('validates the file correctly', ({ size, fileError }) => {
triggerInputEvent(size);
expect(wrapper.vm.fileError).toBe(fileError);
});
});
it('emits input event when file is valid', () => {
const file = triggerInputEvent(200);
expect(wrapper.emitted('input')).toEqual([[file]]);
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue'; import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue';
import { import {
EDITOR_OPTIONS, EDITOR_OPTIONS,
EDITOR_TYPES, EDITOR_TYPES,
...@@ -119,7 +119,7 @@ describe('Rich Content Editor', () => { ...@@ -119,7 +119,7 @@ describe('Rich Content Editor', () => {
}); });
it('calls the onAddImage method when the addImage event is emitted', () => { it('calls the onAddImage method when the addImage event is emitted', () => {
const mockImage = { imageUrl: 'some/url.png', description: 'some description' }; const mockImage = { imageUrl: 'some/url.png', altText: 'some description' };
const mockInstance = { exec: jest.fn() }; const mockInstance = { exec: jest.fn() };
wrapper.vm.$refs.editor = mockInstance; wrapper.vm.$refs.editor = mockInstance;
......
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