Commit 2e8e9e39 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '15018-build-results-fe' into 'master'

Creates component for artifacts

Closes #15018

See merge request gitlab-org/gitlab!17934
parents fa98d6e5 6bfb066b
<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
......@@ -1964,6 +1964,9 @@ msgstr ""
msgid "Arrange charts"
msgstr ""
msgid "Artifact"
msgstr ""
msgid "Artifact ID"
msgstr ""
......@@ -2661,6 +2664,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 ""
......@@ -18067,6 +18076,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