summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/components/metric_images
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/metric_images')
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue119
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue266
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/actions.js85
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/index.js14
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js39
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/state.js10
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,
+});