Commit 708f2a9e authored by Tristan Read's avatar Tristan Read Committed by Peter Leitzen

Add tab for uploading metric images

Adds a new metrics tab in incidents
Uses a new shared component for drag and drop
parent 3e96111d
...@@ -6,7 +6,6 @@ import createFlash from '~/flash'; ...@@ -6,7 +6,6 @@ import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import getAlert from './graphql/queries/get_alert.graphql'; import getAlert from './graphql/queries/get_alert.graphql';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
...@@ -17,8 +16,9 @@ export default { ...@@ -17,8 +16,9 @@ export default {
GlTab, GlTab,
GlTabs, GlTabs,
HighlightBar, HighlightBar,
MetricsTab: () => import('ee_component/issue_show/components/incidents/metrics_tab.vue'),
}, },
inject: ['fullPath', 'iid'], inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'],
apollo: { apollo: {
alert: { alert: {
query: getAlert, query: getAlert,
...@@ -67,7 +67,13 @@ export default { ...@@ -67,7 +67,13 @@ export default {
<highlight-bar :alert="alert" /> <highlight-bar :alert="alert" />
<description-component v-bind="$attrs" /> <description-component v-bind="$attrs" />
</gl-tab> </gl-tab>
<gl-tab v-if="alert" class="alert-management-details" :title="s__('Incident|Alert details')"> <metrics-tab v-if="uploadMetricsFeatureAvailable" data-testid="metrics-tab" />
<gl-tab
v-if="alert"
class="alert-management-details"
:title="s__('Incident|Alert details')"
data-testid="alert-details-tab"
>
<alert-details-table :alert="alert" :loading="loading" /> <alert-details-table :alert="alert" :loading="loading" />
</gl-tab> </gl-tab>
</gl-tabs> </gl-tabs>
......
...@@ -12,7 +12,17 @@ export default function initIssuableApp(issuableData = {}) { ...@@ -12,7 +12,17 @@ export default function initIssuableApp(issuableData = {}) {
defaultClient: createDefaultClient(), defaultClient: createDefaultClient(),
}); });
const { iid, projectNamespace, projectPath, slaFeatureAvailable } = issuableData; const {
canUpdate,
iid,
projectNamespace,
projectPath,
projectId,
slaFeatureAvailable,
uploadMetricsFeatureAvailable,
} = issuableData;
const fullPath = `${projectNamespace}/${projectPath}`;
return new Vue({ return new Vue({
el: document.getElementById('js-issuable-app'), el: document.getElementById('js-issuable-app'),
...@@ -21,9 +31,12 @@ export default function initIssuableApp(issuableData = {}) { ...@@ -21,9 +31,12 @@ export default function initIssuableApp(issuableData = {}) {
issuableApp, issuableApp,
}, },
provide: { provide: {
fullPath: `${projectNamespace}/${projectPath}`, canUpdate,
fullPath,
iid, iid,
projectId,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable), slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
}, },
render(createElement) { render(createElement) {
return createElement('issuable-app', { return createElement('issuable-app', {
......
export const ContentTypeMultipartFormData = {
'Content-Type': 'multipart/form-data',
};
import Api from '~/api'; import Api from '~/api';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
export default { export default {
...Api, ...Api,
...@@ -44,6 +45,7 @@ export default { ...@@ -44,6 +45,7 @@ export default {
descendantGroupsPath: '/api/:version/groups/:group_id/descendant_groups', descendantGroupsPath: '/api/:version/groups/:group_id/descendant_groups',
projectDeploymentFrequencyAnalyticsPath: projectDeploymentFrequencyAnalyticsPath:
'/api/:version/projects/:id/analytics/deployment_frequency', '/api/:version/projects/:id/analytics/deployment_frequency',
issueMetricImagesPath: '/api/:version/projects/:id/issues/:issue_iid/metric_images',
userSubscription(namespaceId) { userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId)); const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
...@@ -341,4 +343,28 @@ export default { ...@@ -341,4 +343,28 @@ export default {
return axios.get(url, { params }); return axios.get(url, { params });
}, },
fetchIssueMetricImages({ issueIid, id }) {
const metricImagesUrl = Api.buildUrl(this.issueMetricImagesPath)
.replace(':id', encodeURIComponent(id))
.replace(':issue_iid', encodeURIComponent(issueIid));
return axios.get(metricImagesUrl);
},
uploadIssueMetricImage({ issueIid, id, file, url = null }) {
const options = { headers: { ...ContentTypeMultipartFormData } };
const metricImagesUrl = Api.buildUrl(this.issueMetricImagesPath)
.replace(':id', encodeURIComponent(id))
.replace(':issue_iid', encodeURIComponent(issueIid));
// Construct multipart form data
const formData = new FormData();
formData.append('file', file);
if (url) {
formData.append('url', url);
}
return axios.post(metricImagesUrl, formData, options);
},
}; };
<script>
import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui';
export default {
components: {
GlButton,
GlCard,
GlIcon,
GlLink,
},
props: {
id: {
type: Number,
required: true,
},
filePath: {
type: String,
required: true,
},
filename: {
type: String,
required: true,
},
url: {
type: String,
required: false,
default: null,
},
},
data() {
return {
isCollapsed: false,
};
},
computed: {
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: {
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
},
},
};
</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"
>
<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-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">
{{ filename }}
</gl-link>
<span v-else>{{ filename }}</span>
</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>
<script>
import { mapState, mapActions } from 'vuex';
import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal, GlTab } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import MetricsImage from './metrics_image.vue';
import createStore from './store';
export default {
components: {
GlFormGroup,
GlFormInput,
GlLoadingIcon,
GlModal,
GlTab,
MetricsImage,
UploadDropzone,
},
data() {
return {
currentFiles: [],
modalVisible: false,
modalUrl: '',
};
},
inject: ['canUpdate', 'projectId', 'iid'],
store: createStore(),
computed: {
...mapState(['metricImages', 'isLoadingMetricImages', 'isUploadingImage']),
actionPrimaryProps() {
return {
text: this.$options.i18n.modalUpload,
attributes: {
loading: this.isUploadingImage,
disabled: this.isUploadingImage,
category: 'primary',
variant: 'success',
},
};
},
},
mounted() {
this.setInitialData({ issueIid: this.iid, projectId: this.projectId });
this.fetchMetricImages();
},
methods: {
...mapActions(['fetchMetricImages', 'uploadImage', 'setInitialData']),
clearInputs() {
this.modalVisible = false;
this.modalUrl = '';
this.currentFile = false;
},
openMetricDialog(files) {
this.modalVisible = true;
this.currentFiles = files;
},
async onUpload() {
try {
await this.uploadImage({ files: this.currentFiles, url: this.modalUrl });
// Error case handled within action
} finally {
this.clearInputs();
}
},
},
i18n: {
modalUpload: __('Upload'),
modalCancel: __('Cancel'),
modalTitle: s__('Incidents|Add a URL'),
modalDescription: s__(
'Incidents|You can optionally add a URL to link users to the original graph.',
),
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" />
</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"
@canceled="clearInputs"
@primary.prevent="onUpload"
>
<p>{{ $options.i18n.modalDescription }}</p>
<gl-form-group :label="__('URL')" label-for="upload-url-input">
<gl-form-input id="upload-url-input" v-model="modalUrl" />
<template name="description">
<p class="gl-text-gray-500 gl-mt-3 gl-mb-0">
{{ s__('Incidents|Must start with http or https') }}
</p>
</template>
</gl-form-group>
</gl-modal>
<metrics-image 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>
import Api from 'ee/api';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export const getMetricImages = async (payload) => {
const response = await Api.fetchIssueMetricImages(payload);
return convertObjectPropsToCamelCase(response.data, { deep: true });
};
export const uploadMetricImage = async (payload) => {
const response = await Api.uploadIssueMetricImage(payload);
return convertObjectPropsToCamelCase(response.data);
};
import { s__ } from '~/locale';
import createFlash from '~/flash';
import * as types from './mutation_types';
import { getMetricImages, uploadMetricImage } from '../service';
export const fetchMetricImages = async ({ state, commit }) => {
commit(types.REQUEST_METRIC_IMAGES);
const { issueIid, projectId } = state;
try {
const response = await getMetricImages({ id: projectId, issueIid });
commit(types.RECEIVE_METRIC_IMAGES_SUCCESS, response);
} catch (error) {
commit(types.RECEIVE_METRIC_IMAGES_ERROR);
createFlash({ message: s__('Incidents|There was an issue loading metric images.') });
}
};
export const uploadImage = async ({ state, commit }, { files, url }) => {
commit(types.REQUEST_METRIC_UPLOAD);
const { issueIid, projectId } = state;
try {
const response = await uploadMetricImage({ file: files.item(0), id: projectId, issueIid, url });
commit(types.RECEIVE_METRIC_UPLOAD_SUCCESS, response);
} catch (error) {
commit(types.RECEIVE_METRIC_UPLOAD_ERROR);
createFlash({ message: s__('Incidents|There was an issue uploading your image.') });
}
};
export const setInitialData = ({ commit }, data) => {
commit(types.SET_INITIAL_DATA, data);
};
import Vue from 'vue';
import Vuex from 'vuex';
import createState from './state';
import * as actions from './actions';
import mutations from './mutations';
Vue.use(Vuex);
export default (initialState) =>
new Vuex.Store({
actions,
mutations,
state: createState(initialState),
});
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 SET_INITIAL_DATA = 'SET_INITIAL_DATA';
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.SET_INITIAL_DATA](state, { issueIid, projectId }) {
state.issueIid = issueIid;
state.projectId = projectId;
},
};
export default ({ issueIid, projectId } = {}) => ({
// Initial state
issueIid,
projectId,
// View state
metricImages: [],
isLoadingMetricImages: false,
isUploadingImage: false,
});
...@@ -35,7 +35,9 @@ module EE ...@@ -35,7 +35,9 @@ module EE
super.merge( super.merge(
publishedIncidentUrl: ::Gitlab::StatusPage::Storage.details_url(issuable), publishedIncidentUrl: ::Gitlab::StatusPage::Storage.details_url(issuable),
slaFeatureAvailable: issuable.sla_available?.to_s slaFeatureAvailable: issuable.sla_available?.to_s,
uploadMetricsFeatureAvailable: issuable.metric_images_available?.to_s,
projectId: issuable.project_id
) )
end end
......
...@@ -16,10 +16,12 @@ class IssuableMetricImage < ApplicationRecord ...@@ -16,10 +16,12 @@ class IssuableMetricImage < ApplicationRecord
validate :validate_file_is_image validate :validate_file_is_image
validates :url, length: { maximum: 255 }, public_url: { allow_blank: true } validates :url, length: { maximum: 255 }, public_url: { allow_blank: true }
scope :order_created_at_asc, -> { order(created_at: :asc) }
MAX_FILE_SIZE = 1.megabyte.freeze MAX_FILE_SIZE = 1.megabyte.freeze
def self.available_for?(project) def self.available_for?(project)
project&.feature_available?(:incident_metric_upload) Feature.enabled?(:incident_metric_upload_ui, project) && project&.feature_available?(:incident_metric_upload)
end end
def filename def filename
......
---
name: incident_metric_upload_ui
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46731
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/293934
milestone: '13.8'
type: development
group: group::monitor
default_enabled: false
...@@ -65,7 +65,7 @@ module EE ...@@ -65,7 +65,7 @@ module EE
iids: [params[:issue_iid]] iids: [params[:issue_iid]]
).execute.first ).execute.first
present issue.metric_images, with: Entities::IssuableMetricImage present issue.metric_images.order_created_at_asc, with: Entities::IssuableMetricImage
else else
render_api_error!('Issue not found', 404) render_api_error!('Issue not found', 404)
end end
......
...@@ -4,6 +4,7 @@ import Api from 'ee/api'; ...@@ -4,6 +4,7 @@ import Api from 'ee/api';
import * as analyticsMockData from 'ee_jest/analytics/cycle_analytics/mock_data'; import * as analyticsMockData from 'ee_jest/analytics/cycle_analytics/mock_data';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
describe('Api', () => { describe('Api', () => {
const dummyApiVersion = 'v3000'; const dummyApiVersion = 'v3000';
...@@ -835,4 +836,41 @@ describe('Api', () => { ...@@ -835,4 +836,41 @@ describe('Api', () => {
}); });
}); });
}); });
describe('Issue metric images', () => {
const projectId = 1;
const issueIid = '2';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/issues/${issueIid}/metric_images`;
describe('fetchIssueMetricImages', () => {
it('fetches a list of images', async () => {
jest.spyOn(axios, 'get');
mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []);
await Api.fetchIssueMetricImages({ issueIid, id: projectId }).then(({ data }) => {
expect(data).toEqual([]);
expect(axios.get).toHaveBeenCalled();
});
});
});
describe('uploadIssueMetricImage', () => {
const file = 'mock file';
const url = 'mock url';
it('uploads an image', async () => {
jest.spyOn(axios, 'post');
mock.onPost(expectedUrl).replyOnce(httpStatus.OK, {});
await Api.uploadIssueMetricImage({ issueIid, id: projectId, file, url }).then(
({ data }) => {
expect(data).toEqual({});
expect(axios.post.mock.calls[0][2]).toEqual({
headers: { ...ContentTypeMultipartFormData },
});
},
);
});
});
});
}); });
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Metrics upload item render the metrics image component 1`] = `
<gl-card-stub
bodyclass="gl-border-1,gl-border-t-solid,gl-border-gray-100,[object Object]"
class="collapsible-card border gl-p-0 gl-mb-5"
footerclass=""
headerclass="gl-display-flex gl-align-items-center gl-border-b-0 gl-py-3"
>
<div
class="gl-display-flex gl-flex-direction-column"
data-testid="metric-image-body"
>
<img
class="gl-max-w-full gl-align-self-center"
src="test_file_path"
/>
</div>
</gl-card-stub>
`;
import { shallowMount, mount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import MetricsImage from 'ee/issue_show/components/incidents/metrics_image.vue';
const defaultProps = {
id: 1,
filePath: 'test_file_path',
filename: 'test_file_name',
};
describe('Metrics upload item', () => {
let wrapper;
const mountComponent = (propsData = {}, mountMethod = mount) => {
wrapper = mountMethod(MetricsImage, {
propsData: {
...defaultProps,
...propsData,
},
});
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findImageLink = () => wrapper.find(GlLink);
const findCollapseButton = () => wrapper.find('[data-testid="collapse-button"]');
const findMetricImageBody = () => wrapper.find('[data-testid="metric-image-body"]');
it('render the metrics image component', () => {
mountComponent({}, shallowMount);
expect(wrapper.element).toMatchSnapshot();
});
it('shows a link with the correct url', () => {
const testUrl = 'test_url';
mountComponent({ url: testUrl });
expect(findImageLink().attributes('href')).toBe(testUrl);
expect(findImageLink().text()).toBe(defaultProps.filename);
});
describe('expand and collapse', () => {
beforeEach(() => {
mountComponent();
});
it('the card is expanded by default', () => {
expect(findMetricImageBody().isVisible()).toBe(true);
});
it('the card is collapsed when clicked', async () => {
findCollapseButton().trigger('click');
await waitForPromises();
expect(findMetricImageBody().isVisible()).toBe(false);
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import merge from 'lodash/merge';
import { GlFormInput, GlModal } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import MetricsTab from 'ee/issue_show/components/incidents/metrics_tab.vue';
import MetricsImage from 'ee/issue_show/components/incidents/metrics_image.vue';
import createStore from 'ee/issue_show/components/incidents/store';
import { getMetricImages } from 'ee/issue_show/components/incidents/service';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import { fileList, initialData } from './mock_data';
jest.mock('ee/issue_show/components/incidents/service', () => ({
getMetricImages: jest.fn(),
}));
const mockEvent = { preventDefault: jest.fn() };
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Metrics tab', () => {
let wrapper;
let store;
const mountComponent = (options = {}) => {
store = createStore();
wrapper = shallowMount(
MetricsTab,
merge(
{
localVue,
store,
provide: {
canUpdate: true,
iid: initialData.issueIid,
projectId: initialData.projectId,
},
},
options,
),
);
};
beforeEach(() => {
mountComponent();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findUploadDropzone = () => wrapper.find(UploadDropzone);
const findImages = () => wrapper.findAll(MetricsImage);
const findModal = () => wrapper.find(GlModal);
const submitModal = () => findModal().vm.$emit('primary', mockEvent);
const cancelModal = () => findModal().vm.$emit('canceled');
describe('empty state', () => {
beforeEach(() => {
mountComponent();
});
it('renders the upload component', () => {
expect(findUploadDropzone().exists()).toBe(true);
});
});
describe('permissions', () => {
beforeEach(() => {
mountComponent({ provide: { canUpdate: false } });
});
it('hides the upload component when disallowed', () => {
expect(findUploadDropzone().exists()).toBe(false);
});
});
describe('onLoad action', () => {
it('should load images', async () => {
getMetricImages.mockImplementation(() => Promise.resolve(fileList));
mountComponent();
await waitForPromises();
expect(findImages().length).toBe(1);
});
});
describe('add metric dialog', () => {
const testUrl = 'test url';
it('should open the add metric dialog when clicked', async () => {
mountComponent();
findUploadDropzone().vm.$emit('change');
await waitForPromises();
expect(findModal().attributes('visible')).toBe('true');
});
it('should close when cancelled', async () => {
mountComponent({
data() {
return { modalVisible: true };
},
});
cancelModal();
await waitForPromises();
expect(findModal().attributes('visible')).toBeFalsy();
});
it('should add files and url when selected', async () => {
mountComponent({
data() {
return { modalVisible: true, modalUrl: testUrl, currentFiles: fileList };
},
});
const dispatchSpy = jest.spyOn(store, 'dispatch');
submitModal();
await waitForPromises();
expect(dispatchSpy).toHaveBeenCalledWith('uploadImage', { files: fileList, url: testUrl });
});
describe('url field', () => {
beforeEach(() => {
mountComponent({
data() {
return { modalVisible: true, modalUrl: testUrl };
},
});
});
it('should display the url field', () => {
expect(wrapper.find(GlFormInput).attributes('value')).toBe(testUrl);
});
it('should clear url when cancelled', async () => {
cancelModal();
await waitForPromises();
expect(wrapper.find(GlFormInput).attributes('value')).toBe('');
});
it('should clear url when submitted', async () => {
submitModal();
await waitForPromises();
expect(wrapper.find(GlFormInput).attributes('value')).toBe('');
});
});
});
});
export const fileList = [{ filePath: 'test', filename: 'hello', id: 5, url: null }];
export const fileListRaw = [{ file_path: 'test', filename: 'hello', id: 5, url: null }];
export const initialData = { issueIid: '123', projectId: 456 };
import { getMetricImages, uploadMetricImage } from 'ee/issue_show/components/incidents/service';
import Api from 'ee/api';
import { fileList, fileListRaw } from './mock_data';
jest.mock('ee/api', () => ({
fetchIssueMetricImages: jest.fn(),
uploadIssueMetricImage: jest.fn(),
}));
describe('Incidents service', () => {
it('fetches metric images', async () => {
Api.fetchIssueMetricImages.mockResolvedValue({ data: fileListRaw });
const result = await getMetricImages();
expect(Api.fetchIssueMetricImages).toHaveBeenCalled();
expect(result).toEqual(fileList);
});
it('uploads a metric image', async () => {
Api.uploadIssueMetricImage.mockResolvedValue({ data: fileListRaw[0] });
const result = await uploadMetricImage();
expect(Api.uploadIssueMetricImage).toHaveBeenCalled();
expect(result).toEqual(fileList[0]);
});
});
import { createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import testAction from 'helpers/vuex_action_helper';
import createStore from 'ee/issue_show/components/incidents/store';
import * as actions from 'ee/issue_show/components/incidents/store/actions';
import * as types from 'ee/issue_show/components/incidents/store/mutation_types';
import { getMetricImages, uploadMetricImage } from 'ee/issue_show/components/incidents/service';
import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { fileList, initialData } from '../mock_data';
jest.mock('~/flash');
jest.mock('ee/issue_show/components/incidents/service', () => ({
getMetricImages: jest.fn(),
uploadMetricImage: jest.fn(),
}));
const defaultState = {
issueIid: 1,
projectId: '2',
};
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Metrics tab store actions', () => {
let store;
let state;
beforeEach(() => {
store = createStore(defaultState);
state = store.state;
});
afterEach(() => {
createFlash.mockClear();
});
describe('fetching metric images', () => {
it('should call success action when fetching metric images', () => {
getMetricImages.mockImplementation(() => Promise.resolve(fileList));
testAction(actions.fetchMetricImages, null, state, [
{ type: types.REQUEST_METRIC_IMAGES },
{
type: types.RECEIVE_METRIC_IMAGES_SUCCESS,
payload: convertObjectPropsToCamelCase(fileList, { deep: true }),
},
]);
});
it('should call error action when fetching metric images with an error', async () => {
getMetricImages.mockImplementation(() => Promise.reject());
await testAction(
actions.fetchMetricImages,
null,
state,
[{ type: types.REQUEST_METRIC_IMAGES }, { type: types.RECEIVE_METRIC_IMAGES_ERROR }],
[],
);
expect(createFlash).toHaveBeenCalled();
});
});
describe('uploading metric images', () => {
const payload = {
// mock the FileList api
files: {
item() {
return fileList[0];
},
},
url: 'test_url',
};
it('should call success action when uploading an image', () => {
uploadMetricImage.mockImplementation(() => Promise.resolve(fileList[0]));
testAction(actions.uploadImage, payload, state, [
{ type: types.REQUEST_METRIC_UPLOAD },
{
type: types.RECEIVE_METRIC_UPLOAD_SUCCESS,
payload: fileList[0],
},
]);
});
it('should call error action when failing to upload an image', async () => {
uploadMetricImage.mockImplementation(() => Promise.reject());
await testAction(
actions.uploadImage,
payload,
state,
[{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }],
[],
);
expect(createFlash).toHaveBeenCalled();
});
});
describe('initial data', () => {
it('should set the initial data correctly', () => {
testAction(actions.setInitialData, initialData, state, [
{ type: types.SET_INITIAL_DATA, payload: initialData },
]);
});
});
});
import * as types from 'ee/issue_show/components/incidents/store/mutation_types';
import mutations from 'ee/issue_show/components/incidents/store/mutations';
import { cloneDeep } from 'lodash';
import { initialData } from '../mock_data';
const defaultState = {
metricImages: [],
isLoadingMetricImages: false,
isUploadingImage: false,
};
const testImages = [
{ filename: 'test.filename', id: 0, filePath: 'test/file/path', url: null },
{ filename: 'second.filename', id: 1, filePath: 'second/file/path', url: 'test/url' },
];
describe('Metric images mutations', () => {
let state;
const createState = (customState = {}) => {
state = {
...cloneDeep(defaultState),
...customState,
};
};
beforeEach(() => {
createState();
});
describe('REQUEST_METRIC_IMAGES', () => {
beforeEach(() => {
mutations[types.REQUEST_METRIC_IMAGES](state);
});
it('should set the loading state', () => {
expect(state.isLoadingMetricImages).toBe(true);
});
});
describe('RECEIVE_METRIC_IMAGES_SUCCESS', () => {
beforeEach(() => {
mutations[types.RECEIVE_METRIC_IMAGES_SUCCESS](state, testImages);
});
it('should unset the loading state', () => {
expect(state.isLoadingMetricImages).toBe(false);
});
it('should set the metric images', () => {
expect(state.metricImages).toEqual(testImages);
});
});
describe('RECEIVE_METRIC_IMAGES_ERROR', () => {
beforeEach(() => {
mutations[types.RECEIVE_METRIC_IMAGES_ERROR](state);
});
it('should unset the loading state', () => {
expect(state.isLoadingMetricImages).toBe(false);
});
});
describe('REQUEST_METRIC_UPLOAD', () => {
beforeEach(() => {
mutations[types.REQUEST_METRIC_UPLOAD](state);
});
it('should set the loading state', () => {
expect(state.isUploadingImage).toBe(true);
});
});
describe('RECEIVE_METRIC_UPLOAD_SUCCESS', () => {
const initialImage = testImages[0];
const newImage = testImages[1];
beforeEach(() => {
createState({ metricImages: [initialImage] });
mutations[types.RECEIVE_METRIC_UPLOAD_SUCCESS](state, newImage);
});
it('should unset the loading state', () => {
expect(state.isUploadingImage).toBe(false);
});
it('should add the new metric image after the existing one', () => {
expect(state.metricImages).toMatchObject([initialImage, newImage]);
});
});
describe('RECEIVE_METRIC_UPLOAD_ERROR', () => {
beforeEach(() => {
mutations[types.RECEIVE_METRIC_UPLOAD_ERROR](state);
});
it('should unset the loading state', () => {
expect(state.isUploadingImage).toBe(false);
});
});
describe('SET_INITIAL_DATA', () => {
beforeEach(() => {
mutations[types.SET_INITIAL_DATA](state, initialData);
});
it('should unset the loading state', () => {
expect(state.issueIid).toBe(initialData.issueIid);
expect(state.projectId).toBe(initialData.projectId);
});
});
});
...@@ -70,6 +70,33 @@ RSpec.describe IssuablesHelper do ...@@ -70,6 +70,33 @@ RSpec.describe IssuablesHelper do
end end
end end
context 'for an incident' do
context 'default state' do
let_it_be(:issue) { create(:issue, author: user, description: 'issue text', issue_type: :incident) }
it 'returns the correct data' do
@project = issue.project
expect(helper.issuable_initial_data(issue)).to include(uploadMetricsFeatureAvailable: "false")
end
end
context 'when incident metric upload is available' do
before do
stub_licensed_features(incident_metric_upload: true)
stub_feature_flags(incident_metric_upload_ui: issue.project)
end
let_it_be(:issue) { create(:issue, author: user, description: 'issue text', issue_type: :incident) }
it 'correctly returns uploadMetricsFeatureAvailable as true' do
@project = issue.project
expect(helper.issuable_initial_data(issue)).to include(uploadMetricsFeatureAvailable: "true")
end
end
end
describe '#gitlab_team_member_badge' do describe '#gitlab_team_member_badge' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:issue) { build(:issue, author: user) } let(:issue) { build(:issue, author: user) }
......
...@@ -24,4 +24,49 @@ RSpec.describe IssuableMetricImage do ...@@ -24,4 +24,49 @@ RSpec.describe IssuableMetricImage do
it { is_expected.to allow_value('https://www.gitlab.com').for(:url) } it { is_expected.to allow_value('https://www.gitlab.com').for(:url) }
end end
end end
describe 'scopes' do
let_it_be(:issue) { create(:issue) }
let_it_be(:image_1) { create(:issuable_metric_image, issue: issue) }
let_it_be(:image_2) { create(:issuable_metric_image, issue: issue) }
describe '.order_created_at_asc' do
subject { described_class.order_created_at_asc }
it 'orders in ascending order' do
expect(subject).to eq([image_1, image_2])
end
end
end
describe '.available_for?' do
subject { IssuableMetricImage.available_for?(issue.project) }
before do
stub_licensed_features(incident_metric_upload: true)
stub_feature_flags(incident_metric_upload_ui: true)
end
let_it_be_with_refind(:issue) { create(:issue) }
context 'license and feature flag enabled' do
it { is_expected.to eq(true) }
end
context 'feature flag disabled' do
before do
stub_feature_flags(incident_metric_upload_ui: false)
end
it { is_expected.to eq(false) }
end
context 'license disabled' do
before do
stub_licensed_features(incident_metric_upload: false)
end
it { is_expected.to eq(false) }
end
end
end end
...@@ -14952,9 +14952,30 @@ msgstr "" ...@@ -14952,9 +14952,30 @@ msgstr ""
msgid "Incidents" msgid "Incidents"
msgstr "" msgstr ""
msgid "Incidents|Add a URL"
msgstr ""
msgid "Incidents|Drop or %{linkStart}upload%{linkEnd} a metric screenshot to attach it to the incident"
msgstr ""
msgid "Incidents|Must start with http or https"
msgstr ""
msgid "Incidents|There was an issue loading metric images."
msgstr ""
msgid "Incidents|There was an issue uploading your image."
msgstr ""
msgid "Incidents|You can optionally add a URL to link users to the original graph."
msgstr ""
msgid "Incident|Alert details" msgid "Incident|Alert details"
msgstr "" msgstr ""
msgid "Incident|Metrics"
msgstr ""
msgid "Incident|Summary" msgid "Incident|Summary"
msgstr "" msgstr ""
...@@ -30386,6 +30407,9 @@ msgstr "" ...@@ -30386,6 +30407,9 @@ msgstr ""
msgid "Upgrade your plan to improve Merge Requests." msgid "Upgrade your plan to improve Merge Requests."
msgstr "" msgstr ""
msgid "Upload"
msgstr ""
msgid "Upload CSV file" msgid "Upload CSV file"
msgstr "" msgstr ""
......
...@@ -47,6 +47,7 @@ describe('Issuable output', () => { ...@@ -47,6 +47,7 @@ describe('Issuable output', () => {
provide: { provide: {
fullPath: 'gitlab-org/incidents', fullPath: 'gitlab-org/incidents',
iid: '19', iid: '19',
uploadMetricsFeatureAvailable: false,
}, },
stubs: { stubs: {
HighlightBar: true, HighlightBar: true,
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import merge from 'lodash/merge';
import { GlTab } from '@gitlab/ui'; import { GlTab } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import INVALID_URL from '~/lib/utils/invalid_url'; import INVALID_URL from '~/lib/utils/invalid_url';
import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
import { descriptionProps } from '../../mock_data'; import { descriptionProps } from '../../mock_data';
...@@ -18,17 +20,22 @@ const mockAlert = { ...@@ -18,17 +20,22 @@ const mockAlert = {
describe('Incident Tabs component', () => { describe('Incident Tabs component', () => {
let wrapper; let wrapper;
const mountComponent = (data = {}) => { const mountComponent = (data = {}, options = {}) => {
wrapper = shallowMount(IncidentTabs, { wrapper = shallowMount(
IncidentTabs,
merge(
{
propsData: { propsData: {
...descriptionProps, ...descriptionProps,
}, },
stubs: { stubs: {
DescriptionComponent: true, DescriptionComponent: true,
MetricsTab: true,
}, },
provide: { provide: {
fullPath: '', fullPath: '',
iid: '', iid: '',
uploadMetricsFeatureAvailable: true,
}, },
data() { data() {
return { alert: mockAlert, ...data }; return { alert: mockAlert, ...data };
...@@ -42,12 +49,16 @@ describe('Incident Tabs component', () => { ...@@ -42,12 +49,16 @@ describe('Incident Tabs component', () => {
}, },
}, },
}, },
}); },
options,
),
);
}; };
const findTabs = () => wrapper.findAll(GlTab); const findTabs = () => wrapper.findAll(GlTab);
const findSummaryTab = () => findTabs().at(0); const findSummaryTab = () => findTabs().at(0);
const findAlertDetailsTab = () => findTabs().at(1); const findMetricsTab = () => wrapper.find('[data-testid="metrics-tab"]');
const findAlertDetailsTab = () => wrapper.find('[data-testid="alert-details-tab"]');
const findAlertDetailsComponent = () => wrapper.find(AlertDetailsTable); const findAlertDetailsComponent = () => wrapper.find(AlertDetailsTable);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent); const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
const findHighlightBarComponent = () => wrapper.find(HighlightBar); const findHighlightBarComponent = () => wrapper.find(HighlightBar);
...@@ -100,6 +111,24 @@ describe('Incident Tabs component', () => { ...@@ -100,6 +111,24 @@ describe('Incident Tabs component', () => {
}); });
}); });
describe('upload metrics feature available', () => {
it('shows the metric tab when metrics are available', async () => {
mountComponent({}, { provide: { uploadMetricsFeatureAvailable: true } });
await waitForPromises();
expect(findMetricsTab().exists()).toBe(true);
});
it('hides the tab when metrics are not available', async () => {
mountComponent({}, { provide: { uploadMetricsFeatureAvailable: false } });
await waitForPromises();
expect(findMetricsTab().exists()).toBe(false);
});
});
describe('Snowplow tracking', () => { describe('Snowplow tracking', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(Tracking, 'event'); jest.spyOn(Tracking, 'event');
......
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