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';
import { s__ } from '~/locale';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import Tracking from '~/tracking';
import getAlert from './graphql/queries/get_alert.graphql';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
......@@ -17,8 +16,9 @@ export default {
GlTab,
GlTabs,
HighlightBar,
MetricsTab: () => import('ee_component/issue_show/components/incidents/metrics_tab.vue'),
},
inject: ['fullPath', 'iid'],
inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'],
apollo: {
alert: {
query: getAlert,
......@@ -67,7 +67,13 @@ export default {
<highlight-bar :alert="alert" />
<description-component v-bind="$attrs" />
</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" />
</gl-tab>
</gl-tabs>
......
......@@ -12,7 +12,17 @@ export default function initIssuableApp(issuableData = {}) {
defaultClient: createDefaultClient(),
});
const { iid, projectNamespace, projectPath, slaFeatureAvailable } = issuableData;
const {
canUpdate,
iid,
projectNamespace,
projectPath,
projectId,
slaFeatureAvailable,
uploadMetricsFeatureAvailable,
} = issuableData;
const fullPath = `${projectNamespace}/${projectPath}`;
return new Vue({
el: document.getElementById('js-issuable-app'),
......@@ -21,9 +31,12 @@ export default function initIssuableApp(issuableData = {}) {
issuableApp,
},
provide: {
fullPath: `${projectNamespace}/${projectPath}`,
canUpdate,
fullPath,
iid,
projectId,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
},
render(createElement) {
return createElement('issuable-app', {
......
export const ContentTypeMultipartFormData = {
'Content-Type': 'multipart/form-data',
};
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
export default {
...Api,
......@@ -44,6 +45,7 @@ export default {
descendantGroupsPath: '/api/:version/groups/:group_id/descendant_groups',
projectDeploymentFrequencyAnalyticsPath:
'/api/:version/projects/:id/analytics/deployment_frequency',
issueMetricImagesPath: '/api/:version/projects/:id/issues/:issue_iid/metric_images',
userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
......@@ -341,4 +343,28 @@ export default {
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
super.merge(
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
......
......@@ -16,10 +16,12 @@ class IssuableMetricImage < ApplicationRecord
validate :validate_file_is_image
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
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
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
iids: [params[:issue_iid]]
).execute.first
present issue.metric_images, with: Entities::IssuableMetricImage
present issue.metric_images.order_created_at_asc, with: Entities::IssuableMetricImage
else
render_api_error!('Issue not found', 404)
end
......
......@@ -4,6 +4,7 @@ import Api from 'ee/api';
import * as analyticsMockData from 'ee_jest/analytics/cycle_analytics/mock_data';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
describe('Api', () => {
const dummyApiVersion = 'v3000';
......@@ -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
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
let(:user) { create(:user) }
let(:issue) { build(:issue, author: user) }
......
......@@ -24,4 +24,49 @@ RSpec.describe IssuableMetricImage do
it { is_expected.to allow_value('https://www.gitlab.com').for(:url) }
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
......@@ -14952,9 +14952,30 @@ msgstr ""
msgid "Incidents"
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"
msgstr ""
msgid "Incident|Metrics"
msgstr ""
msgid "Incident|Summary"
msgstr ""
......@@ -30386,6 +30407,9 @@ msgstr ""
msgid "Upgrade your plan to improve Merge Requests."
msgstr ""
msgid "Upload"
msgstr ""
msgid "Upload CSV file"
msgstr ""
......
......@@ -47,6 +47,7 @@ describe('Issuable output', () => {
provide: {
fullPath: 'gitlab-org/incidents',
iid: '19',
uploadMetricsFeatureAvailable: false,
},
stubs: {
HighlightBar: true,
......
import { shallowMount } from '@vue/test-utils';
import merge from 'lodash/merge';
import { GlTab } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import INVALID_URL from '~/lib/utils/invalid_url';
import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
import { descriptionProps } from '../../mock_data';
......@@ -18,17 +20,22 @@ const mockAlert = {
describe('Incident Tabs component', () => {
let wrapper;
const mountComponent = (data = {}) => {
wrapper = shallowMount(IncidentTabs, {
const mountComponent = (data = {}, options = {}) => {
wrapper = shallowMount(
IncidentTabs,
merge(
{
propsData: {
...descriptionProps,
},
stubs: {
DescriptionComponent: true,
MetricsTab: true,
},
provide: {
fullPath: '',
iid: '',
uploadMetricsFeatureAvailable: true,
},
data() {
return { alert: mockAlert, ...data };
......@@ -42,12 +49,16 @@ describe('Incident Tabs component', () => {
},
},
},
});
},
options,
),
);
};
const findTabs = () => wrapper.findAll(GlTab);
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 findDescriptionComponent = () => wrapper.find(DescriptionComponent);
const findHighlightBarComponent = () => wrapper.find(HighlightBar);
......@@ -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', () => {
beforeEach(() => {
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