Commit 6bfb066b authored by Filipa Lacerda's avatar Filipa Lacerda

Creates artifacts app

The artifacts endpoint needs to be polled while
we don't have the content.

This commit creates an vuex app to handle the polling
parent 82bf296b
<script>
import { GlLink } from '@gitlab/ui';
export default {
components: {
GlLink,
},
props: {
artifacts: {
type: Array,
required: true,
},
},
};
</script>
<template>
<table class="table m-0">
<thead class="thead-white text-nowrap">
<tr class="d-none d-sm-table-row">
<th class="w-0"></th>
<th>{{ __('Artifact') }}</th>
<th class="w-50"></th>
<th>{{ __('Job') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in artifacts" :key="item.text">
<td class="w-0"></td>
<td>
<gl-link :href="item.url" target="_blank">{{ item.text }}</gl-link>
</td>
<td class="w-0"></td>
<td>
<gl-link :href="item.job_path">{{ item.job_name }}</gl-link>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import ArtifactsList from './artifacts_list.vue';
import MrCollapsibleExtension from './mr_collapsible_extension.vue';
import createStore from '../stores/artifacts_list';
export default {
store: createStore(),
components: {
ArtifactsList,
MrCollapsibleExtension,
},
props: {
endpoint: {
type: String,
required: true,
},
},
computed: {
...mapState(['artifacts', 'isLoading', 'hasError']),
...mapGetters(['title']),
},
created() {
this.setEndpoint(this.endpoint);
this.fetchArtifacts();
},
methods: {
...mapActions(['setEndpoint', 'fetchArtifacts']),
},
};
</script>
<template>
<mr-collapsible-extension :title="title" :is-loading="isLoading" :has-error="hasError">
<artifacts-list :artifacts="artifacts" />
</mr-collapsible-extension>
</template>
<script>
import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlButton,
GlLink,
GlLoadingIcon,
Icon,
},
props: {
title: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
hasError: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isCollapsed: true,
};
},
computed: {
arrowIconName() {
return this.isCollapsed ? 'angle-right' : 'angle-down';
},
ariaLabel() {
return this.isCollapsed ? __('Expand') : __('Collapse');
},
isButtonDisabled() {
return this.isLoading || this.hasError;
},
},
methods: {
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
},
},
};
</script>
<template>
<div>
<div class="mr-widget-extension d-flex align-items-center pl-3">
<gl-button
class="btn-blank btn s32 square append-right-default"
:aria-label="ariaLabel"
:disabled="isButtonDisabled"
@click="toggleCollapsed"
>
<gl-loading-icon v-if="isLoading" />
<icon v-else :name="arrowIconName" class="js-icon" />
</gl-button>
<gl-button
variant="link"
class="js-title"
:disabled="isButtonDisabled"
:class="{ 'border-0': isButtonDisabled }"
@click="toggleCollapsed"
>
<template v-if="isCollapsed">{{ title }}</template>
<template v-else>{{ __('Collapse') }}</template>
</gl-button>
</div>
<div v-if="!isCollapsed" class="border-top js-slot-container">
<slot></slot>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import ArtifactsApp from './artifacts_list_app.vue';
import Deployment from './deployment.vue';
import MrWidgetContainer from './mr_widget_container.vue';
import MrWidgetPipeline from './mr_widget_pipeline.vue';
......@@ -15,6 +16,7 @@ import MrWidgetPipeline from './mr_widget_pipeline.vue';
export default {
name: 'MrWidgetPipelineContainer',
components: {
ArtifactsApp,
Deployment,
MrWidgetContainer,
MrWidgetPipeline,
......@@ -79,6 +81,9 @@ export default {
:troubleshooting-docs-path="mr.troubleshootingDocsPath"
/>
<template v-slot:footer>
<div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts">
<artifacts-app :endpoint="mr.exposedArtifactsPath" />
</div>
<div v-if="deployments.length" class="mr-widget-extension">
<deployment
v-for="deployment in deployments"
......
import Visibility from 'visibilityjs';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import httpStatusCodes from '~/lib/utils/http_status';
import * as types from './mutation_types';
export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
export const requestArtifacts = ({ commit }) => commit(types.REQUEST_ARTIFACTS);
let eTagPoll;
export const clearEtagPoll = () => {
eTagPoll = null;
};
export const stopPolling = () => {
if (eTagPoll) eTagPoll.stop();
};
export const restartPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
export const fetchArtifacts = ({ state, dispatch }) => {
dispatch('requestArtifacts');
eTagPoll = new Poll({
resource: {
getArtifacts(endpoint) {
return axios.get(endpoint);
},
},
data: state.endpoint,
method: 'getArtifacts',
successCallback: ({ data, status }) => {
dispatch('receiveArtifactsSuccess', {
data,
status,
});
},
errorCallback: () => dispatch('receiveArtifactsError'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
} else {
axios
.get(state.endpoint)
.then(({ data, status }) => dispatch('receiveArtifactsSuccess', { data, status }))
.catch(() => dispatch('receiveArtifactsError'));
}
Visibility.change(() => {
if (!Visibility.hidden()) {
dispatch('restartPolling');
} else {
dispatch('stopPolling');
}
});
};
export const receiveArtifactsSuccess = ({ commit }, response) => {
// With 204 we keep polling and don't update the state
if (response.status === httpStatusCodes.OK) {
commit(types.RECEIVE_ARTIFACTS_SUCCESS, response.data);
}
};
export const receiveArtifactsError = ({ commit }) => commit(types.RECEIVE_ARTIFACTS_ERROR);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import { s__, n__ } from '~/locale';
export const title = state => {
if (state.isLoading) {
return s__('BuildArtifacts|Loading artifacts');
}
if (state.hasError) {
return s__('BuildArtifacts|An error occurred while fetching the artifacts');
}
return n__('View exposed artifact', 'View %d exposed artifacts', state.artifacts.length);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import * as getters from './getters';
import state from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
mutations,
getters,
state: state(),
});
export const SET_ENDPOINT = 'SET_ENDPOINT';
export const REQUEST_ARTIFACTS = 'REQUEST_ARTIFACTS';
export const RECEIVE_ARTIFACTS_SUCCESS = 'RECEIVE_ARTIFACTS_SUCCESS';
export const RECEIVE_ARTIFACTS_ERROR = 'RECEIVE_ARTIFACTS_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_ENDPOINT](state, endpoint) {
state.endpoint = endpoint;
},
[types.REQUEST_ARTIFACTS](state) {
state.isLoading = true;
},
[types.RECEIVE_ARTIFACTS_SUCCESS](state, response) {
state.hasError = false;
state.isLoading = false;
state.artifacts = response;
},
[types.RECEIVE_ARTIFACTS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
state.artifacts = [];
},
};
export default () => ({
endpoint: null,
isLoading: false,
hasError: false,
artifacts: [],
});
......@@ -100,6 +100,7 @@ export default class MergeRequestStore {
this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.testResultsPath = data.test_reports_path;
this.exposedArtifactsPath = data.exposed_artifacts_path;
this.cancelAutoMergePath = data.cancel_auto_merge_path;
this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
......
---
title: Creates Vue and Vuex app to render exposed artifacts
merge_request: 17934
author:
type: added
......@@ -1892,6 +1892,9 @@ msgstr ""
msgid "Arrange charts"
msgstr ""
msgid "Artifact"
msgstr ""
msgid "Artifact ID"
msgstr ""
......@@ -2589,6 +2592,12 @@ msgstr ""
msgid "Browse files"
msgstr ""
msgid "BuildArtifacts|An error occurred while fetching the artifacts"
msgstr ""
msgid "BuildArtifacts|Loading artifacts"
msgstr ""
msgid "Built-in"
msgstr ""
......@@ -17938,6 +17947,11 @@ msgstr ""
msgid "View epics list"
msgstr ""
msgid "View exposed artifact"
msgid_plural "View %d exposed artifacts"
msgstr[0] ""
msgstr[1] ""
msgid "View file @ "
msgstr ""
......
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { GlLoadingIcon } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import ArtifactsListApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue';
import createStore from '~/vue_merge_request_widget/stores/artifacts_list';
import { artifactsList } from './mock_data';
describe('Merge Requests Artifacts list app', () => {
let wrapper;
let mock;
const store = createStore();
const localVue = createLocalVue();
localVue.use(Vuex);
const actionSpies = {
fetchArtifacts: jest.fn(),
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
const createComponent = () => {
wrapper = mount(localVue.extend(ArtifactsListApp), {
propsData: {
endpoint: TEST_HOST,
},
store,
methods: {
...actionSpies,
},
localVue,
sync: false,
});
};
const findButtons = () => wrapper.findAll('button');
const findTitle = () => wrapper.find('.js-title');
const findTableRows = () => wrapper.findAll('tbody tr');
describe('while loading', () => {
beforeEach(() => {
createComponent();
store.dispatch('requestArtifacts');
return wrapper.vm.$nextTick();
});
it('renders a loading icon', () => {
const loadingIcon = wrapper.find(GlLoadingIcon);
expect(loadingIcon.exists()).toBe(true);
});
it('renders loading text', () => {
expect(findTitle().text()).toBe('Loading artifacts');
});
it('renders disabled buttons', () => {
const buttons = findButtons();
expect(buttons.at(0).attributes('disabled')).toBe('disabled');
expect(buttons.at(1).attributes('disabled')).toBe('disabled');
});
});
describe('with results', () => {
beforeEach(() => {
createComponent();
mock.onGet(wrapper.vm.$store.state.endpoint).reply(200, artifactsList, {});
store.dispatch('receiveArtifactsSuccess', {
data: artifactsList,
status: 200,
});
return wrapper.vm.$nextTick();
});
it('renders a title with the number of artifacts', () => {
expect(findTitle().text()).toBe('View 2 exposed artifacts');
});
it('renders both buttons enabled', () => {
const buttons = findButtons();
expect(buttons.at(0).attributes('disabled')).toBe(undefined);
expect(buttons.at(1).attributes('disabled')).toBe(undefined);
});
describe('on click', () => {
it('renders the list of artifacts', () => {
findTitle().trigger('click');
wrapper.vm.$nextTick(() => {
expect(findTableRows().length).toEqual(2);
});
});
});
});
describe('with error', () => {
beforeEach(() => {
createComponent();
mock.onGet(wrapper.vm.$store.state.endpoint).reply(500, {}, {});
store.dispatch('receiveArtifactsError');
return wrapper.vm.$nextTick();
});
it('renders the error state', () => {
expect(findTitle().text()).toBe('An error occurred while fetching the artifacts');
});
it('renders disabled buttons', () => {
const buttons = findButtons();
expect(buttons.at(0).attributes('disabled')).toBe('disabled');
expect(buttons.at(1).attributes('disabled')).toBe('disabled');
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import ArtifactsList from '~/vue_merge_request_widget/components/artifacts_list.vue';
import { artifactsList } from './mock_data';
describe('Artifacts List', () => {
let wrapper;
const localVue = createLocalVue();
const data = {
artifacts: artifactsList,
};
const mountComponent = props => {
wrapper = shallowMount(localVue.extend(ArtifactsList), {
propsData: {
...props,
},
sync: false,
localVue,
});
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
mountComponent(data);
});
it('renders list of artifacts', () => {
expect(wrapper.findAll('tbody tr').length).toEqual(data.artifacts.length);
});
it('renders link for the artifact', () => {
expect(wrapper.find(GlLink).attributes('href')).toEqual(data.artifacts[0].url);
});
it('renders artifact name', () => {
expect(wrapper.find(GlLink).text()).toEqual(data.artifacts[0].text);
});
it('renders job url', () => {
expect(
wrapper
.findAll(GlLink)
.at(1)
.attributes('href'),
).toEqual(data.artifacts[0].job_path);
});
it('renders job name', () => {
expect(
wrapper
.findAll(GlLink)
.at(1)
.text(),
).toEqual(data.artifacts[0].job_name);
});
});
// eslint-disable-next-line import/prefer-default-export
export const artifactsList = [
{
text: 'result.txt',
url: 'bar',
job_name: 'generate-artifact',
job_path: 'bar',
},
{
text: 'foo.txt',
url: 'foo',
job_name: 'foo-artifact',
job_path: 'foo',
},
];
import { mount } from '@vue/test-utils';
import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue';
import { GlLoadingIcon } from '@gitlab/ui';
describe('Merge Request Collapsible Extension', () => {
let wrapper;
const data = {
title: 'View artifacts',
};
const mountComponent = props => {
wrapper = mount(MrCollapsibleSection, {
propsData: {
...props,
},
slots: {
default: '<div class="js-slot">Foo</div>',
},
});
};
const findTitle = () => wrapper.find('.js-title');
afterEach(() => {
wrapper.destroy();
});
describe('while collapsed', () => {
beforeEach(() => {
mountComponent(data);
});
it('renders provided title', () => {
expect(findTitle().text()).toBe(data.title);
});
it('renders angle-right icon', () => {
expect(wrapper.vm.arrowIconName).toBe('angle-right');
});
describe('onClick', () => {
beforeEach(() => {
wrapper.find('button').trigger('click');
});
it('rendes the provided slot', () => {
expect(wrapper.find('.js-slot').isVisible()).toBe(true);
});
it('renders `Collapse` as the title', () => {
expect(findTitle().text()).toBe('Collapse');
});
it('renders angle-down icon', () => {
expect(wrapper.vm.arrowIconName).toBe('angle-down');
});
});
});
describe('while loading', () => {
beforeEach(() => {
mountComponent(Object.assign({}, data, { isLoading: true }));
});
it('renders the buttons disabled', () => {
expect(
wrapper
.findAll('button')
.at(0)
.attributes('disabled'),
).toEqual('disabled');
expect(
wrapper
.findAll('button')
.at(1)
.attributes('disabled'),
).toEqual('disabled');
});
it('renders loading spinner', () => {
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
});
});
describe('with error', () => {
beforeEach(() => {
mountComponent(Object.assign({}, data, { hasError: true }));
});
it('renders the buttons disabled', () => {
expect(
wrapper
.findAll('button')
.at(0)
.attributes('disabled'),
).toEqual('disabled');
expect(
wrapper
.findAll('button')
.at(1)
.attributes('disabled'),
).toEqual('disabled');
});
});
});
import { title } from '~/vue_merge_request_widget/stores/artifacts_list/getters';
import state from '~/vue_merge_request_widget/stores/artifacts_list/state';
import { artifactsList } from '../../components/mock_data';
describe('Artifacts Store Getters', () => {
let localState;
beforeEach(() => {
localState = state();
});
describe('title', () => {
describe('when is loading', () => {
it('returns loading message', () => {
localState.isLoading = true;
expect(title(localState)).toBe('Loading artifacts');
});
});
describe('when has error', () => {
it('returns error message', () => {
localState.hasError = true;
expect(title(localState)).toBe('An error occurred while fetching the artifacts');
});
});
describe('when it has artifacts', () => {
it('returns artifacts message', () => {
localState.artifacts = artifactsList;
expect(title(localState)).toBe('View 2 exposed artifacts');
});
});
});
});
import state from '~/vue_merge_request_widget/stores/artifacts_list/state';
import mutations from '~/vue_merge_request_widget/stores/artifacts_list/mutations';
import * as types from '~/vue_merge_request_widget/stores/artifacts_list/mutation_types';
describe('Artifacts Store Mutations', () => {
let stateCopy;
beforeEach(() => {
stateCopy = state();
});
describe('SET_ENDPOINT', () => {
it('should set endpoint', () => {
mutations[types.SET_ENDPOINT](stateCopy, 'endpoint.json');
expect(stateCopy.endpoint).toEqual('endpoint.json');
});
});
describe('REQUEST_ARTIFACTS', () => {
it('should set isLoading to true', () => {
mutations[types.REQUEST_ARTIFACTS](stateCopy);
expect(stateCopy.isLoading).toEqual(true);
});
});
describe('REECEIVE_ARTIFACTS_SUCCESS', () => {
const artifacts = [
{
text: 'result.txt',
url: 'asda',
job_name: 'generate-artifact',
job_path: 'asda',
},
{
text: 'file.txt',
url: 'asda',
job_name: 'generate-artifact',
job_path: 'asda',
},
];
beforeEach(() => {
mutations[types.RECEIVE_ARTIFACTS_SUCCESS](stateCopy, artifacts);
});
it('should set isLoading to false', () => {
expect(stateCopy.isLoading).toEqual(false);
});
it('should set hasError to false', () => {
expect(stateCopy.hasError).toEqual(false);
});
it('should set list of artifacts', () => {
expect(stateCopy.artifacts).toEqual(artifacts);
});
});
describe('RECEIVE_ARTIFACTS_ERROR', () => {
beforeEach(() => {
mutations[types.RECEIVE_ARTIFACTS_ERROR](stateCopy);
});
it('should set isLoading to false', () => {
expect(stateCopy.isLoading).toEqual(false);
});
it('should set hasError to true', () => {
expect(stateCopy.hasError).toEqual(true);
});
it('should set list of artifacts as empty array', () => {
expect(stateCopy.artifacts).toEqual([]);
});
});
});
import { mount, createLocalVue } from '@vue/test-utils';
import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue';
import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue';
import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue';
import { mockStore } from '../mock_data';
describe('MrWidgetPipelineContainer', () => {
......@@ -87,4 +88,10 @@ describe('MrWidgetPipelineContainer', () => {
expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps);
});
});
describe('with artifacts path', () => {
it('renders the artifacts app', () => {
expect(wrapper.find(ArtifactsApp).isVisible()).toBe(true);
});
});
});
......@@ -289,4 +289,5 @@ export const mockStore = {
troubleshootingDocsPath: 'troubleshooting-docs-path',
ciStatus: 'ci-status',
hasCI: true,
exposedArtifactsPath: 'exposed_artifacts.json',
};
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import {
setEndpoint,
requestArtifacts,
clearEtagPoll,
stopPolling,
fetchArtifacts,
receiveArtifactsSuccess,
receiveArtifactsError,
} from '~/vue_merge_request_widget/stores/artifacts_list/actions';
import state from '~/vue_merge_request_widget/stores/artifacts_list/state';
import * as types from '~/vue_merge_request_widget/stores/artifacts_list/mutation_types';
import testAction from 'spec/helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
describe('Artifacts App Store Actions', () => {
let mockedState;
beforeEach(() => {
mockedState = state();
});
describe('setEndpoint', () => {
it('should commit SET_ENDPOINT mutation', done => {
testAction(
setEndpoint,
'endpoint.json',
mockedState,
[{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }],
[],
done,
);
});
});
describe('requestArtifacts', () => {
it('should commit REQUEST_ARTIFACTS mutation', done => {
testAction(
requestArtifacts,
null,
mockedState,
[{ type: types.REQUEST_ARTIFACTS }],
[],
done,
);
});
});
describe('fetchArtifacts', () => {
let mock;
beforeEach(() => {
mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
stopPolling();
clearEtagPoll();
});
describe('success', () => {
it('dispatches requestArtifacts and receiveArtifactsSuccess ', done => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [
{
text: 'result.txt',
url: 'asda',
job_name: 'generate-artifact',
job_path: 'asda',
},
]);
testAction(
fetchArtifacts,
null,
mockedState,
[],
[
{
type: 'requestArtifacts',
},
{
payload: {
data: [
{
text: 'result.txt',
url: 'asda',
job_name: 'generate-artifact',
job_path: 'asda',
},
],
status: 200,
},
type: 'receiveArtifactsSuccess',
},
],
done,
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
});
it('dispatches requestArtifacts and receiveArtifactsError ', done => {
testAction(
fetchArtifacts,
null,
mockedState,
[],
[
{
type: 'requestArtifacts',
},
{
type: 'receiveArtifactsError',
},
],
done,
);
});
});
});
describe('receiveArtifactsSuccess', () => {
it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', done => {
testAction(
receiveArtifactsSuccess,
{ data: { summary: {} }, status: 200 },
mockedState,
[{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }],
[],
done,
);
});
it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', done => {
testAction(
receiveArtifactsSuccess,
{ data: { summary: {} }, status: 204 },
mockedState,
[],
[],
done,
);
});
});
describe('receiveArtifactsError', () => {
it('should commit RECEIVE_ARTIFACTS_ERROR mutation', done => {
testAction(
receiveArtifactsError,
null,
mockedState,
[{ type: types.RECEIVE_ARTIFACTS_ERROR }],
[],
done,
);
});
});
});
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