Commit f88e9e0b authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '346171-frontend-for-metric-image-url-text' into 'master'

Allow urlText to be saved on metric images

See merge request gitlab-org/gitlab!79811
parents c832a374 2c50c5d8
......@@ -328,7 +328,7 @@ export default {
return axios.get(metricImagesUrl);
},
uploadIssueMetricImage({ issueIid, id, file, url = null }) {
uploadIssueMetricImage({ issueIid, id, file, url = null, urlText = null }) {
const options = { headers: { ...ContentTypeMultipartFormData } };
const metricImagesUrl = Api.buildUrl(this.issueMetricImagesPath)
.replace(':id', encodeURIComponent(id))
......@@ -340,10 +340,31 @@ export default {
if (url) {
formData.append('url', url);
}
if (urlText) {
formData.append('url_text', urlText);
}
return axios.post(metricImagesUrl, formData, options);
},
updateIssueMetricImage({ issueIid, id, imageId, url = null, urlText = null }) {
const metricImagesUrl = Api.buildUrl(this.issueMetricSingleImagePath)
.replace(':id', encodeURIComponent(id))
.replace(':issue_iid', encodeURIComponent(issueIid))
.replace(':image_id', encodeURIComponent(imageId));
// Construct multipart form data
const formData = new FormData();
if (url != null) {
formData.append('url', url);
}
if (urlText != null) {
formData.append('url_text', urlText);
}
return axios.put(metricImagesUrl, formData);
},
deleteMetricImage({ issueIid, id, imageId }) {
const individualMetricImageUrl = Api.buildUrl(this.issueMetricSingleImagePath)
.replace(':id', encodeURIComponent(id))
......
<script>
import { GlButton, GlCard, GlIcon, GlLink, GlModal, GlSprintf } from '@gitlab/ui';
import {
GlButton,
GlFormGroup,
GlFormInput,
GlCard,
GlIcon,
GlLink,
GlModal,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __, s__ } from '~/locale';
......@@ -9,15 +19,24 @@ export default {
modalDescription: s__('Incident|Are you sure you wish to delete this image?'),
modalCancel: __('Cancel'),
modalTitle: s__('Incident|Deleting %{filename}'),
editModalUpdate: __('Update'),
editModalTitle: s__('Incident|Editing %{filename}'),
editIconTitle: s__('Incident|Edit image text or link'),
deleteIconTitle: s__('Incident|Delete image'),
},
components: {
GlButton,
GlFormGroup,
GlFormInput,
GlCard,
GlIcon,
GlLink,
GlModal,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['canUpdate'],
props: {
id: {
......@@ -37,16 +56,25 @@ export default {
required: false,
default: null,
},
urlText: {
type: String,
required: false,
default: null,
},
},
data() {
return {
isCollapsed: false,
isDeleting: false,
isUpdating: false,
modalVisible: false,
editModalVisible: false,
modalUrl: this.url,
modalUrlText: this.urlText,
};
},
computed: {
actionPrimaryProps() {
deleteActionPrimaryProps() {
return {
text: this.$options.i18n.modalDelete,
attributes: {
......@@ -57,6 +85,17 @@ export default {
},
};
},
updateActionPrimaryProps() {
return {
text: this.$options.i18n.editModalUpdate,
attributes: {
loading: this.isUpdating,
disabled: this.isUpdating,
category: 'primary',
variant: 'confirm',
},
};
},
arrowIconName() {
return this.isCollapsed ? 'chevron-right' : 'chevron-down';
},
......@@ -70,10 +109,16 @@ export default {
},
},
methods: {
...mapActions(['deleteImage']),
...mapActions(['deleteImage', 'updateImage']),
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
},
resetEditFields() {
this.modalUrl = this.url;
this.modalUrlText = this.urlText;
this.editModalVisible = false;
this.modalVisible = false;
},
async onDelete() {
try {
this.isDeleting = true;
......@@ -83,6 +128,21 @@ export default {
this.modalVisible = false;
}
},
async onUpdate() {
try {
this.isUpdating = true;
await this.updateImage({
imageId: this.id,
url: this.modalUrl,
urlText: this.modalUrlText,
});
} finally {
this.isUpdating = false;
this.modalUrl = '';
this.modalUrlText = '';
this.editModalVisible = false;
}
},
},
};
</script>
......@@ -98,10 +158,10 @@ export default {
modal-id="delete-metric-modal"
size="sm"
:visible="modalVisible"
:action-primary="actionPrimaryProps"
:action-primary="deleteActionPrimaryProps"
:action-cancel="{ text: $options.i18n.modalCancel }"
@primary.prevent="onDelete"
@hidden="modalVisible = false"
@hidden="resetEditFields"
>
<template #modal-title>
<gl-sprintf :message="$options.i18n.modalTitle">
......@@ -112,6 +172,46 @@ export default {
</template>
<p>{{ $options.i18n.modalDescription }}</p>
</gl-modal>
<gl-modal
modal-id="edit-metric-modal"
size="sm"
:action-primary="updateActionPrimaryProps"
:action-cancel="{ text: $options.i18n.modalCancel }"
:visible="editModalVisible"
data-testid="metric-image-edit-modal"
@hidden="resetEditFields"
@primary.prevent="onUpdate"
>
<template #modal-title>
<gl-sprintf :message="$options.i18n.editModalTitle">
<template #filename>
{{ filename }}
</template>
</gl-sprintf>
</template>
<gl-form-group :label="__('Text (optional)')" label-for="upload-text-input">
<gl-form-input
id="upload-text-input"
v-model="modalUrlText"
data-testid="metric-image-text-field"
/>
</gl-form-group>
<gl-form-group
:label="__('Link (optional)')"
label-for="upload-url-input"
:description="s__('Incidents|Must start with http or https')"
>
<gl-form-input
id="upload-url-input"
v-model="modalUrl"
data-testid="metric-image-url-field"
/>
</gl-form-group>
</gl-modal>
<template #header>
<div class="gl-w-full gl-display-flex gl-flex-direction-row gl-justify-content-space-between">
<div class="gl-display-flex gl-flex-direction-row gl-align-items-center gl-w-full">
......@@ -125,18 +225,33 @@ export default {
>
<gl-icon class="gl-mr-2" :name="arrowIconName" />
</gl-button>
<gl-link v-if="url" :href="url">
{{ filename }}
<gl-link v-if="url" :href="url" target="_blank" data-testid="metric-image-label-span">
{{ urlText == null || urlText == '' ? filename : urlText }}
<gl-icon name="external-link" class="gl-vertical-align-middle" />
</gl-link>
<span v-else>{{ filename }}</span>
<gl-button
v-if="canUpdate"
class="gl-ml-auto"
icon="remove"
:aria-label="__('Delete')"
data-testid="delete-button"
@click="modalVisible = true"
/>
<span v-else data-testid="metric-image-label-span">{{
urlText == null || urlText == '' ? filename : urlText
}}</span>
<div class="gl-ml-auto btn-group">
<gl-button
v-if="canUpdate"
v-gl-tooltip.bottom
icon="pencil"
:aria-label="__('Edit')"
:title="$options.i18n.editIconTitle"
data-testid="edit-button"
@click="editModalVisible = true"
/>
<gl-button
v-if="canUpdate"
v-gl-tooltip.bottom
icon="remove"
:aria-label="__('Delete')"
:title="$options.i18n.deleteIconTitle"
data-testid="delete-button"
@click="modalVisible = true"
/>
</div>
</div>
</div>
</template>
......
......@@ -22,6 +22,7 @@ export default {
currentFiles: [],
modalVisible: false,
modalUrl: '',
modalUrlText: '',
};
},
store: createStore(),
......@@ -34,7 +35,7 @@ export default {
loading: this.isUploadingImage,
disabled: this.isUploadingImage,
category: 'primary',
variant: 'success',
variant: 'confirm',
},
};
},
......@@ -48,6 +49,7 @@ export default {
clearInputs() {
this.modalVisible = false;
this.modalUrl = '';
this.modalUrlText = '';
this.currentFile = false;
},
openMetricDialog(files) {
......@@ -56,7 +58,11 @@ export default {
},
async onUpload() {
try {
await this.uploadImage({ files: this.currentFiles, url: this.modalUrl });
await this.uploadImage({
files: this.currentFiles,
url: this.modalUrl,
urlText: this.modalUrlText,
});
// Error case handled within action
} finally {
this.clearInputs();
......@@ -66,9 +72,9 @@ export default {
i18n: {
modalUpload: __('Upload'),
modalCancel: __('Cancel'),
modalTitle: s__('Incidents|Add a URL'),
modalTitle: s__('Incidents|Add image details'),
modalDescription: s__(
'Incidents|You can optionally add a URL to link users to the original graph.',
"Incidents|Add text or a link to display with your image. If you don't add either, the file name displays instead.",
),
dropDescription: s__(
'Incidents|Drop or %{linkStart}upload%{linkEnd} a metric screenshot to attach it to the incident',
......@@ -93,8 +99,12 @@ export default {
@primary.prevent="onUpload"
>
<p>{{ $options.i18n.modalDescription }}</p>
<gl-form-group :label="__('Text (optional)')" label-for="upload-text-input">
<gl-form-input id="upload-text-input" v-model="modalUrlText" />
</gl-form-group>
<gl-form-group
:label="__('URL')"
:label="__('Link (optional)')"
label-for="upload-url-input"
:description="s__('Incidents|Must start with http or https')"
>
......
......@@ -11,6 +11,11 @@ export const uploadMetricImage = async (payload) => {
return convertObjectPropsToCamelCase(response.data);
};
export const updateMetricImage = async (payload) => {
const response = await Api.updateIssueMetricImage(payload);
return convertObjectPropsToCamelCase(response.data);
};
export const deleteMetricImage = async (payload) => {
const response = await Api.deleteMetricImage(payload);
return convertObjectPropsToCamelCase(response.data);
......
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { deleteMetricImage, getMetricImages, uploadMetricImage } from '../service';
import {
deleteMetricImage,
getMetricImages,
uploadMetricImage,
updateMetricImage,
} from '../service';
import * as types from './mutation_types';
export const fetchMetricImages = async ({ state, commit }) => {
......@@ -17,13 +22,19 @@ export const fetchMetricImages = async ({ state, commit }) => {
}
};
export const uploadImage = async ({ state, commit }, { files, url }) => {
export const uploadImage = async ({ state, commit }, { files, url, urlText }) => {
commit(types.REQUEST_METRIC_UPLOAD);
const { issueIid, projectId } = state;
try {
const response = await uploadMetricImage({ file: files.item(0), id: projectId, issueIid, url });
const response = await uploadMetricImage({
file: files.item(0),
id: projectId,
issueIid,
url,
urlText,
});
commit(types.RECEIVE_METRIC_UPLOAD_SUCCESS, response);
} catch (error) {
commit(types.RECEIVE_METRIC_UPLOAD_ERROR);
......@@ -31,6 +42,26 @@ export const uploadImage = async ({ state, commit }, { files, url }) => {
}
};
export const updateImage = async ({ state, commit }, { imageId, url, urlText }) => {
commit(types.REQUEST_METRIC_UPLOAD);
const { issueIid, projectId } = state;
try {
const response = await updateMetricImage({
issueIid,
id: projectId,
imageId,
url,
urlText,
});
commit(types.RECEIVE_METRIC_UPDATE_SUCCESS, response);
} catch (error) {
commit(types.RECEIVE_METRIC_UPLOAD_ERROR);
createFlash({ message: s__('Incidents|There was an issue updating your image.') });
}
};
export const deleteImage = async ({ state, commit }, imageId) => {
const { issueIid, projectId } = state;
......
......@@ -6,6 +6,8 @@ export const REQUEST_METRIC_UPLOAD = 'REQUEST_METRIC_UPLOAD';
export const RECEIVE_METRIC_UPLOAD_SUCCESS = 'RECEIVE_METRIC_UPLOAD_SUCCESS';
export const RECEIVE_METRIC_UPLOAD_ERROR = 'RECEIVE_METRIC_UPLOAD_ERROR';
export const RECEIVE_METRIC_UPDATE_SUCCESS = 'RECEIVE_METRIC_UPDATE_SUCCESS';
export const RECEIVE_METRIC_DELETE_SUCCESS = 'RECEIVE_METRIC_DELETE_SUCCESS';
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
......@@ -21,6 +21,13 @@ export default {
[types.RECEIVE_METRIC_UPLOAD_ERROR](state) {
state.isUploadingImage = false;
},
[types.RECEIVE_METRIC_UPDATE_SUCCESS](state, image) {
state.isUploadingImage = false;
const metricIndex = state.metricImages.findIndex((img) => img.id === image.id);
if (metricIndex >= 0) {
state.metricImages.splice(metricIndex, 1, image);
}
},
[types.RECEIVE_METRIC_DELETE_SUCCESS](state, imageId) {
const metricIndex = state.metricImages.findIndex((image) => image.id === imageId);
state.metricImages.splice(metricIndex, 1);
......
......@@ -732,12 +732,13 @@ describe('Api', () => {
describe('uploadIssueMetricImage', () => {
const file = 'mock file';
const url = 'mock url';
const urlText = 'mock urlText';
it('uploads an image', async () => {
jest.spyOn(axios, 'post');
mock.onPost(expectedUrl).replyOnce(httpStatus.OK, {});
await Api.uploadIssueMetricImage({ issueIid, id: projectId, file, url }).then(
await Api.uploadIssueMetricImage({ issueIid, id: projectId, file, url, urlText }).then(
({ data }) => {
expect(data).toEqual({});
expect(axios.post.mock.calls[0][2]).toEqual({
......
......@@ -22,6 +22,43 @@ exports[`Metrics upload item render the metrics image component 1`] = `
Are you sure you wish to delete this image?
</p>
</gl-modal-stub>
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
data-testid="metric-image-edit-modal"
dismisslabel="Close"
modalclass=""
modalid="edit-metric-modal"
size="sm"
titletag="h4"
>
<gl-form-group-stub
label="Text (optional)"
label-for="upload-text-input"
labeldescription=""
optionaltext="(optional)"
>
<gl-form-input-stub
data-testid="metric-image-text-field"
id="upload-text-input"
/>
</gl-form-group-stub>
<gl-form-group-stub
description="Must start with http or https"
label="Link (optional)"
label-for="upload-url-input"
labeldescription=""
optionaltext="(optional)"
>
<gl-form-input-stub
data-testid="metric-image-url-field"
id="upload-url-input"
/>
</gl-form-group-stub>
</gl-modal-stub>
<div
class="gl-display-flex gl-flex-direction-column"
......
......@@ -47,14 +47,22 @@ describe('Metrics upload item', () => {
});
const findImageLink = () => wrapper.findComponent(GlLink);
const findLabelTextSpan = () => wrapper.find('[data-testid="metric-image-label-span"]');
const findCollapseButton = () => wrapper.find('[data-testid="collapse-button"]');
const findMetricImageBody = () => wrapper.find('[data-testid="metric-image-body"]');
const findModal = () => wrapper.findComponent(GlModal);
const findEditModal = () => wrapper.find('[data-testid="metric-image-edit-modal"]');
const findDeleteButton = () => wrapper.find('[data-testid="delete-button"]');
const findEditButton = () => wrapper.find('[data-testid="edit-button"]');
const findImageTextInput = () => wrapper.find('[data-testid="metric-image-text-field"]');
const findImageUrlInput = () => wrapper.find('[data-testid="metric-image-url-field"]');
const closeModal = () => findModal().vm.$emit('hidden');
const submitModal = () => findModal().vm.$emit('primary', mockEvent);
const deleteImage = () => findDeleteButton().vm.$emit('click');
const closeEditModal = () => findEditModal().vm.$emit('hidden');
const submitEditModal = () => findEditModal().vm.$emit('primary', mockEvent);
const editImage = () => findEditButton().vm.$emit('click');
it('render the metrics image component', () => {
mountComponent({}, shallowMount);
......@@ -70,6 +78,22 @@ describe('Metrics upload item', () => {
expect(findImageLink().text()).toBe(defaultProps.filename);
});
it('shows a link with the url text, if url text is present', () => {
const testUrl = 'test_url';
const testUrlText = 'test_url_text';
mountComponent({ propsData: { url: testUrl, urlText: testUrlText } });
expect(findImageLink().attributes('href')).toBe(testUrl);
expect(findImageLink().text()).toBe(testUrlText);
});
it('shows the url text with no url, if no url is present', () => {
const testUrlText = 'test_url_text';
mountComponent({ propsData: { urlText: testUrlText } });
expect(findLabelTextSpan().text()).toBe(testUrlText);
});
describe('expand and collapse', () => {
beforeEach(() => {
mountComponent();
......@@ -89,7 +113,7 @@ describe('Metrics upload item', () => {
});
describe('delete functionality', () => {
it('should open the modal when clicked', async () => {
it('should open the delete modal when clicked', async () => {
mountComponent({ stubs: { GlModal: true } });
deleteImage();
......@@ -138,4 +162,69 @@ describe('Metrics upload item', () => {
});
});
});
describe('edit functionality', () => {
it('should open the delete modal when clicked', async () => {
mountComponent({ stubs: { GlModal: true } });
editImage();
await waitForPromises();
expect(findEditModal().attributes('visible')).toBe('true');
});
describe('when the modal is open', () => {
beforeEach(() => {
mountComponent({
data() {
return { editModalVisible: true };
},
propsData: { urlText: 'test' },
stubs: { GlModal: true },
});
});
it('should close the modal when cancelled', async () => {
closeEditModal();
await waitForPromises();
expect(findEditModal().attributes('visible')).toBeFalsy();
});
it('should delete the image when selected', async () => {
const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(jest.fn());
submitEditModal();
await waitForPromises();
expect(dispatchSpy).toHaveBeenCalledWith('updateImage', {
imageId: defaultProps.id,
url: null,
urlText: 'test',
});
});
it('should clear edits when the modal is closed', async () => {
await findImageTextInput().setValue('test value');
await findImageUrlInput().setValue('http://www.gitlab.com');
expect(findImageTextInput().element.value).toBe('test value');
expect(findImageUrlInput().element.value).toBe('http://www.gitlab.com');
closeEditModal();
await waitForPromises();
editImage();
await waitForPromises();
expect(findImageTextInput().element.value).toBe('test');
expect(findImageUrlInput().element.value).toBe('');
});
});
});
});
......@@ -131,7 +131,11 @@ describe('Metrics tab', () => {
await waitForPromises();
expect(dispatchSpy).toHaveBeenCalledWith('uploadImage', { files: fileList, url: testUrl });
expect(dispatchSpy).toHaveBeenCalledWith('uploadImage', {
files: fileList,
url: testUrl,
urlText: '',
});
});
describe('url field', () => {
......@@ -144,7 +148,11 @@ describe('Metrics tab', () => {
});
it('should display the url field', () => {
expect(wrapper.findComponent(GlFormInput).attributes('value')).toBe(testUrl);
expect(wrapper.find('#upload-url-input').attributes('value')).toBe(testUrl);
});
it('should display the url text field', () => {
expect(wrapper.find('#upload-text-input').attributes('value')).toBe('');
});
it('should clear url when cancelled', async () => {
......
import Api from 'ee/api';
import { getMetricImages, uploadMetricImage } from 'ee/issues/show/components/incidents/service';
import {
getMetricImages,
uploadMetricImage,
updateMetricImage,
} from 'ee/issues/show/components/incidents/service';
import { fileList, fileListRaw } from './mock_data';
jest.mock('ee/api', () => ({
fetchIssueMetricImages: jest.fn(),
uploadIssueMetricImage: jest.fn(),
updateIssueMetricImage: jest.fn(),
}));
describe('Incidents service', () => {
......@@ -23,4 +28,12 @@ describe('Incidents service', () => {
expect(Api.uploadIssueMetricImage).toHaveBeenCalled();
expect(result).toEqual(fileList[0]);
});
it('updates a metric image', async () => {
Api.updateIssueMetricImage.mockResolvedValue({ data: fileListRaw[0] });
const result = await updateMetricImage();
expect(Api.updateIssueMetricImage).toHaveBeenCalled();
expect(result).toEqual(fileList[0]);
});
});
......@@ -3,6 +3,7 @@ import Vuex from 'vuex';
import {
getMetricImages,
uploadMetricImage,
updateMetricImage,
deleteMetricImage,
} from 'ee/issues/show/components/incidents/service';
import createStore from 'ee/issues/show/components/incidents/store';
......@@ -17,6 +18,7 @@ jest.mock('~/flash');
jest.mock('ee/issues/show/components/incidents/service', () => ({
getMetricImages: jest.fn(),
uploadMetricImage: jest.fn(),
updateMetricImage: jest.fn(),
deleteMetricImage: jest.fn(),
}));
......@@ -104,6 +106,37 @@ describe('Metrics tab store actions', () => {
});
});
describe('updating metric images', () => {
const payload = {
url: 'test_url',
urlText: 'url text',
};
it('should call success action when updating an image', () => {
updateMetricImage.mockImplementation(() => Promise.resolve());
testAction(actions.updateImage, payload, state, [
{ type: types.REQUEST_METRIC_UPLOAD },
{
type: types.RECEIVE_METRIC_UPDATE_SUCCESS,
},
]);
});
it('should call error action when failing to update an image', async () => {
updateMetricImage.mockImplementation(() => Promise.reject());
await testAction(
actions.updateImage,
payload,
state,
[{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }],
[],
);
expect(createFlash).toHaveBeenCalled();
});
});
describe('deleting a metric image', () => {
const payload = fileList[0].id;
......
......@@ -101,6 +101,25 @@ describe('Metric images mutations', () => {
});
});
describe('RECEIVE_METRIC_UPDATE_SUCCESS', () => {
const initialImage = testImages[0];
const newImage = testImages[0];
newImage.url = 'https://www.gitlab.com';
beforeEach(() => {
createState({ metricImages: [initialImage] });
mutations[types.RECEIVE_METRIC_UPDATE_SUCCESS](state, newImage);
});
it('should unset the loading state', () => {
expect(state.isUploadingImage).toBe(false);
});
it('should replace the existing image with the new one', () => {
expect(state.metricImages).toMatchObject([newImage]);
});
});
describe('RECEIVE_METRIC_DELETE_SUCCESS', () => {
const deletedImageId = testImages[1].id;
const expectedResult = [testImages[0], testImages[2]];
......
......@@ -19184,7 +19184,10 @@ msgstr ""
msgid "Incidents"
msgstr ""
msgid "Incidents|Add a URL"
msgid "Incidents|Add image details"
msgstr ""
msgid "Incidents|Add text or a link to display with your image. If you don't add either, the file name displays instead."
msgstr ""
msgid "Incidents|Drop or %{linkStart}upload%{linkEnd} a metric screenshot to attach it to the incident"
......@@ -19199,10 +19202,10 @@ msgstr ""
msgid "Incidents|There was an issue loading metric images."
msgstr ""
msgid "Incidents|There was an issue uploading your image."
msgid "Incidents|There was an issue updating your image."
msgstr ""
msgid "Incidents|You can optionally add a URL to link users to the original graph."
msgid "Incidents|There was an issue uploading your image."
msgstr ""
msgid "Incident|Alert details"
......@@ -19211,9 +19214,18 @@ msgstr ""
msgid "Incident|Are you sure you wish to delete this image?"
msgstr ""
msgid "Incident|Delete image"
msgstr ""
msgid "Incident|Deleting %{filename}"
msgstr ""
msgid "Incident|Edit image text or link"
msgstr ""
msgid "Incident|Editing %{filename}"
msgstr ""
msgid "Incident|Metrics"
msgstr ""
......@@ -21740,6 +21752,9 @@ msgstr ""
msgid "Link"
msgstr ""
msgid "Link (optional)"
msgstr ""
msgid "Link Prometheus monitoring to GitLab."
msgstr ""
......@@ -35999,6 +36014,9 @@ msgstr ""
msgid "Tests"
msgstr ""
msgid "Text (optional)"
msgstr ""
msgid "Text added to the body of all email messages. %{character_limit} character limit"
msgstr ""
......
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