diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/metric_images')
7 files changed, 546 insertions, 0 deletions
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue new file mode 100644 index 00000000000..3e796a73f72 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue @@ -0,0 +1,119 @@ +<script> +import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal, GlTab } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { __, s__ } from '~/locale'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; +import MetricImagesTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; + +export default { + components: { + GlFormGroup, + GlFormInput, + GlLoadingIcon, + GlModal, + GlTab, + MetricImagesTable, + UploadDropzone, + }, + inject: ['canUpdate', 'projectId', 'iid'], + data() { + return { + currentFiles: [], + modalVisible: false, + modalUrl: '', + modalUrlText: '', + }; + }, + computed: { + ...mapState(['metricImages', 'isLoadingMetricImages', 'isUploadingImage']), + actionPrimaryProps() { + return { + text: this.$options.i18n.modalUpload, + attributes: { + loading: this.isUploadingImage, + disabled: this.isUploadingImage, + category: 'primary', + variant: 'confirm', + }, + }; + }, + }, + mounted() { + this.setInitialData({ modelIid: this.iid, projectId: this.projectId }); + this.fetchImages(); + }, + methods: { + ...mapActions(['fetchImages', 'uploadImage', 'setInitialData']), + clearInputs() { + this.modalVisible = false; + this.modalUrl = ''; + this.modalUrlText = ''; + this.currentFile = false; + }, + openMetricDialog(files) { + this.modalVisible = true; + this.currentFiles = files; + }, + async onUpload() { + try { + await this.uploadImage({ + files: this.currentFiles, + url: this.modalUrl, + urlText: this.modalUrlText, + }); + // Error case handled within action + } finally { + this.clearInputs(); + } + }, + }, + i18n: { + modalUpload: __('Upload'), + modalCancel: __('Cancel'), + modalTitle: s__('Incidents|Add image details'), + modalDescription: s__( + "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', + ), + }, +}; +</script> + +<template> + <gl-tab :title="s__('Incident|Metrics')" data-testid="metrics-tab"> + <div v-if="isLoadingMetricImages"> + <gl-loading-icon class="gl-p-5" size="sm" /> + </div> + <gl-modal + modal-id="upload-metric-modal" + size="sm" + :action-primary="actionPrimaryProps" + :action-cancel="{ text: $options.i18n.modalCancel }" + :title="$options.i18n.modalTitle" + :visible="modalVisible" + @hidden="clearInputs" + @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="__('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" /> + </gl-form-group> + </gl-modal> + <metric-images-table v-for="metric in metricImages" :key="metric.id" v-bind="metric" /> + <upload-dropzone + v-if="canUpdate" + :drop-description-message="$options.i18n.dropDescription" + @change="openMetricDialog" + /> + </gl-tab> +</template> diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue new file mode 100644 index 00000000000..8eb8e52728d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue @@ -0,0 +1,266 @@ +<script> +import { + GlButton, + GlFormGroup, + GlFormInput, + GlCard, + GlIcon, + GlLink, + GlModal, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import { __, s__ } from '~/locale'; + +export default { + i18n: { + modalDelete: __('Delete'), + 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: { + type: Number, + required: true, + }, + filePath: { + type: String, + required: true, + }, + filename: { + type: String, + required: true, + }, + url: { + type: String, + 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: { + deleteActionPrimaryProps() { + return { + text: this.$options.i18n.modalDelete, + attributes: { + loading: this.isDeleting, + disabled: this.isDeleting, + category: 'primary', + variant: 'danger', + }, + }; + }, + 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'; + }, + bodyClass() { + return [ + 'gl-border-1', + 'gl-border-t-solid', + 'gl-border-gray-100', + { 'gl-display-none': this.isCollapsed }, + ]; + }, + }, + methods: { + ...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; + await this.deleteImage(this.id); + } finally { + this.isDeleting = false; + 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> + +<template> + <gl-card + class="collapsible-card border gl-p-0 gl-mb-5" + header-class="gl-display-flex gl-align-items-center gl-border-b-0 gl-py-3" + :body-class="bodyClass" + > + <gl-modal + body-class="gl-pb-0! gl-min-h-6!" + modal-id="delete-metric-modal" + size="sm" + :visible="modalVisible" + :action-primary="deleteActionPrimaryProps" + :action-cancel="{ text: $options.i18n.modalCancel }" + @primary.prevent="onDelete" + @hidden="resetEditFields" + > + <template #modal-title> + <gl-sprintf :message="$options.i18n.modalTitle"> + <template #filename> + {{ filename }} + </template> + </gl-sprintf> + </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"> + <gl-button + class="collapsible-card-btn gl-display-flex gl-text-decoration-none gl-reset-color! gl-hover-text-blue-800! gl-shadow-none!" + :aria-label="filename" + variant="link" + category="tertiary" + data-testid="collapse-button" + @click="toggleCollapsed" + > + <gl-icon class="gl-mr-2" :name="arrowIconName" /> + </gl-button> + <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 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> + <div + v-show="!isCollapsed" + class="gl-display-flex gl-flex-direction-column" + data-testid="metric-image-body" + > + <img class="gl-max-w-full gl-align-self-center" :src="filePath" /> + </div> + </gl-card> +</template> diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js new file mode 100644 index 00000000000..832fb891838 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js @@ -0,0 +1,85 @@ +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import * as types from './mutation_types'; + +export const fetchImagesFactory = (service) => async ({ state, commit }) => { + commit(types.REQUEST_METRIC_IMAGES); + const { modelIid, projectId } = state; + + try { + const response = await service.getMetricImages({ id: projectId, modelIid }); + commit(types.RECEIVE_METRIC_IMAGES_SUCCESS, response); + } catch (error) { + commit(types.RECEIVE_METRIC_IMAGES_ERROR); + createFlash({ message: s__('MetricImages|There was an issue loading metric images.') }); + } +}; + +export const uploadImageFactory = (service) => async ( + { state, commit }, + { files, url, urlText }, +) => { + commit(types.REQUEST_METRIC_UPLOAD); + + const { modelIid, projectId } = state; + + try { + const response = await service.uploadMetricImage({ + file: files.item(0), + id: projectId, + modelIid, + url, + urlText, + }); + commit(types.RECEIVE_METRIC_UPLOAD_SUCCESS, response); + } catch (error) { + commit(types.RECEIVE_METRIC_UPLOAD_ERROR); + createFlash({ message: s__('MetricImages|There was an issue uploading your image.') }); + } +}; + +export const updateImageFactory = (service) => async ( + { state, commit }, + { imageId, url, urlText }, +) => { + commit(types.REQUEST_METRIC_UPLOAD); + + const { modelIid, projectId } = state; + + try { + const response = await service.updateMetricImage({ + modelIid, + id: projectId, + imageId, + url, + urlText, + }); + commit(types.RECEIVE_METRIC_UPDATE_SUCCESS, response); + } catch (error) { + commit(types.RECEIVE_METRIC_UPLOAD_ERROR); + createFlash({ message: s__('MetricImages|There was an issue updating your image.') }); + } +}; + +export const deleteImageFactory = (service) => async ({ state, commit }, imageId) => { + const { modelIid, projectId } = state; + + try { + await service.deleteMetricImage({ imageId, id: projectId, modelIid }); + commit(types.RECEIVE_METRIC_DELETE_SUCCESS, imageId); + } catch (error) { + createFlash({ message: s__('MetricImages|There was an issue deleting the image.') }); + } +}; + +export const setInitialData = ({ commit }, data) => { + commit(types.SET_INITIAL_DATA, data); +}; + +export default (service) => ({ + fetchImages: fetchImagesFactory(service), + uploadImage: uploadImageFactory(service), + updateImage: updateImageFactory(service), + deleteImage: deleteImageFactory(service), + setInitialData, +}); diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/index.js b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js new file mode 100644 index 00000000000..f13dde9a2bc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import actionsFactory from './actions'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export default (initialState, service) => + new Vuex.Store({ + actions: actionsFactory(service), + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js new file mode 100644 index 00000000000..8f1b31217a2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js @@ -0,0 +1,13 @@ +export const REQUEST_METRIC_IMAGES = 'REQUEST_METRIC_IMAGES'; +export const RECEIVE_METRIC_IMAGES_SUCCESS = 'RECEIVE_METRIC_IMAGES_SUCCESS'; +export const RECEIVE_METRIC_IMAGES_ERROR = 'RECEIVE_METRIC_IMAGES_ERROR'; + +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/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js b/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js new file mode 100644 index 00000000000..b42234b2829 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js @@ -0,0 +1,39 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_METRIC_IMAGES](state) { + state.isLoadingMetricImages = true; + }, + [types.RECEIVE_METRIC_IMAGES_SUCCESS](state, images) { + state.metricImages = images || []; + state.isLoadingMetricImages = false; + }, + [types.RECEIVE_METRIC_IMAGES_ERROR](state) { + state.isLoadingMetricImages = false; + }, + [types.REQUEST_METRIC_UPLOAD](state) { + state.isUploadingImage = true; + }, + [types.RECEIVE_METRIC_UPLOAD_SUCCESS](state, image) { + state.metricImages.push(image); + state.isUploadingImage = false; + }, + [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); + }, + [types.SET_INITIAL_DATA](state, { modelIid, projectId }) { + state.modelIid = modelIid; + state.projectId = projectId; + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/state.js b/app/assets/javascripts/vue_shared/components/metric_images/store/state.js new file mode 100644 index 00000000000..b734e5c87a6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/state.js @@ -0,0 +1,10 @@ +export default ({ modelIid, projectId } = {}) => ({ + // Initial state + modelIid, + projectId, + + // View state + metricImages: [], + isLoadingMetricImages: false, + isUploadingImage: false, +}); |