diff --git a/ee/app/assets/javascripts/api.js b/ee/app/assets/javascripts/api.js index 44b4a88a98c39ddff4542794548c7226dd07b2f8..9d015c86990eaf0c70f743a2bde7ba3137622c3e 100644 --- a/ee/app/assets/javascripts/api.js +++ b/ee/app/assets/javascripts/api.js @@ -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)) diff --git a/ee/app/assets/javascripts/issues/show/components/incidents/metrics_image.vue b/ee/app/assets/javascripts/issues/show/components/incidents/metrics_image.vue index 8e9f0ee2063365535ee959f3e58f11d49fcc845a..8eb8e52728dc624e6c3de28258679470ce9c685f 100644 --- a/ee/app/assets/javascripts/issues/show/components/incidents/metrics_image.vue +++ b/ee/app/assets/javascripts/issues/show/components/incidents/metrics_image.vue @@ -1,5 +1,15 @@ <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> diff --git a/ee/app/assets/javascripts/issues/show/components/incidents/metrics_tab.vue b/ee/app/assets/javascripts/issues/show/components/incidents/metrics_tab.vue index b419cb4009066102f6d8e24f9f60351c1b8e365a..a2d72534ded0f70d74e51f612d4f42512611664a 100644 --- a/ee/app/assets/javascripts/issues/show/components/incidents/metrics_tab.vue +++ b/ee/app/assets/javascripts/issues/show/components/incidents/metrics_tab.vue @@ -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')" > diff --git a/ee/app/assets/javascripts/issues/show/components/incidents/service.js b/ee/app/assets/javascripts/issues/show/components/incidents/service.js index 138f304fb256bb7fc8b800f2a56cff3db605b234..c80049c58bdbbb34d34ebc7f6885febcab2079d5 100644 --- a/ee/app/assets/javascripts/issues/show/components/incidents/service.js +++ b/ee/app/assets/javascripts/issues/show/components/incidents/service.js @@ -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); diff --git a/ee/app/assets/javascripts/issues/show/components/incidents/store/actions.js b/ee/app/assets/javascripts/issues/show/components/incidents/store/actions.js index c8677e713a08312db2ea094dce1c476a7be97308..33a9beed1715d0f244ffff1c67cebd423bbfe55e 100644 --- a/ee/app/assets/javascripts/issues/show/components/incidents/store/actions.js +++ b/ee/app/assets/javascripts/issues/show/components/incidents/store/actions.js @@ -1,6 +1,11 @@ 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; diff --git a/ee/app/assets/javascripts/issues/show/components/incidents/store/mutation_types.js b/ee/app/assets/javascripts/issues/show/components/incidents/store/mutation_types.js index 43cdbdbdf9bc93a82cae76c5fa36f938131b846f..8f1b31217a23d9c8997c5c4cd4224597e15668bc 100644 --- a/ee/app/assets/javascripts/issues/show/components/incidents/store/mutation_types.js +++ b/ee/app/assets/javascripts/issues/show/components/incidents/store/mutation_types.js @@ -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'; diff --git a/ee/app/assets/javascripts/issues/show/components/incidents/store/mutations.js b/ee/app/assets/javascripts/issues/show/components/incidents/store/mutations.js index 60845ffff646f44d8eddc757943ad6a2798b4ced..56ff1b6d95d035145a6cfbea70d62d0f3c9be1dd 100644 --- a/ee/app/assets/javascripts/issues/show/components/incidents/store/mutations.js +++ b/ee/app/assets/javascripts/issues/show/components/incidents/store/mutations.js @@ -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); diff --git a/ee/spec/frontend/api_spec.js b/ee/spec/frontend/api_spec.js index a94844b361089a6d213d008216389886856bd29d..979b0dceaf95e6686dbc345ae780784b39f293d9 100644 --- a/ee/spec/frontend/api_spec.js +++ b/ee/spec/frontend/api_spec.js @@ -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({ diff --git a/ee/spec/frontend/issues/show/components/incidents/__snapshots__/metrics_image_spec.js.snap b/ee/spec/frontend/issues/show/components/incidents/__snapshots__/metrics_image_spec.js.snap index 43d3e7509d55984264f00ba6d9c36eb73ba31ad8..5dd12d9edf5eee7cb850b897a83a2c1f59fdcb5c 100644 --- a/ee/spec/frontend/issues/show/components/incidents/__snapshots__/metrics_image_spec.js.snap +++ b/ee/spec/frontend/issues/show/components/incidents/__snapshots__/metrics_image_spec.js.snap @@ -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" diff --git a/ee/spec/frontend/issues/show/components/incidents/metrics_image_spec.js b/ee/spec/frontend/issues/show/components/incidents/metrics_image_spec.js index a91918755ba3789491c481b35193da712dc18ef2..c9997cfef46001f55fe97bb76e2c6f51b4b54dad 100644 --- a/ee/spec/frontend/issues/show/components/incidents/metrics_image_spec.js +++ b/ee/spec/frontend/issues/show/components/incidents/metrics_image_spec.js @@ -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(''); + }); + }); + }); }); diff --git a/ee/spec/frontend/issues/show/components/incidents/metrics_tab_spec.js b/ee/spec/frontend/issues/show/components/incidents/metrics_tab_spec.js index b52a051fe2ba72bf5c5959be4282706d19536d97..51969ce54f0af6123e7136a1566451d6384a1b6b 100644 --- a/ee/spec/frontend/issues/show/components/incidents/metrics_tab_spec.js +++ b/ee/spec/frontend/issues/show/components/incidents/metrics_tab_spec.js @@ -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 () => { diff --git a/ee/spec/frontend/issues/show/components/incidents/service_spec.js b/ee/spec/frontend/issues/show/components/incidents/service_spec.js index 3b1bff10c4e9995eb9c2755ad115d604b091e63c..508c8ef8eb267ac2f0d21edf86bff97aa9de80d8 100644 --- a/ee/spec/frontend/issues/show/components/incidents/service_spec.js +++ b/ee/spec/frontend/issues/show/components/incidents/service_spec.js @@ -1,10 +1,15 @@ 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]); + }); }); diff --git a/ee/spec/frontend/issues/show/components/incidents/store/actions_spec.js b/ee/spec/frontend/issues/show/components/incidents/store/actions_spec.js index 8d28d13a2015adf0d61a91581281921cee131d18..0a7931e70144987e935c7ebd5654792441deaac3 100644 --- a/ee/spec/frontend/issues/show/components/incidents/store/actions_spec.js +++ b/ee/spec/frontend/issues/show/components/incidents/store/actions_spec.js @@ -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; diff --git a/ee/spec/frontend/issues/show/components/incidents/store/mutation_spec.js b/ee/spec/frontend/issues/show/components/incidents/store/mutation_spec.js index 5ffc8e464a3ea3228fc74670cb6f5cce87e3dfb8..02203f2477246d0bf45e52165a0a539c2138459a 100644 --- a/ee/spec/frontend/issues/show/components/incidents/store/mutation_spec.js +++ b/ee/spec/frontend/issues/show/components/incidents/store/mutation_spec.js @@ -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]]; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5bff1e0406422d1c5bcea288cec1757dc6165202..91845fc31c70f88411b03315d78bf685f3e237ad 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -18954,7 +18954,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" @@ -18969,10 +18972,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" @@ -18981,9 +18984,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 "" @@ -21483,6 +21495,9 @@ msgstr "" msgid "Link" msgstr "" +msgid "Link (optional)" +msgstr "" + msgid "Link Prometheus monitoring to GitLab." msgstr "" @@ -35619,6 +35634,9 @@ msgstr "" msgid "Tests" msgstr "" +msgid "Text (optional)" +msgstr "" + msgid "Text added to the body of all email messages. %{character_limit} character limit" msgstr ""