Merge branch 'ide-jobs-list-components' into 'master'

Show CI jobs in IDE

Closes #44604

See merge request gitlab-org/gitlab-ce!19106
This commit is contained in:
Filipa Lacerda 2018-06-01 08:45:35 +00:00
commit 5b9e4d986a
44 changed files with 1577 additions and 553 deletions

View File

@ -24,8 +24,6 @@ const Api = {
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
pipelinesPath: '/api/:version/projects/:id/pipelines',
pipelineJobsPath: '/api/:version/projects/:id/pipelines/:pipeline_id/jobs',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@ -238,20 +236,6 @@ const Api = {
});
},
pipelines(projectPath, params = {}) {
const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(projectPath));
return axios.get(url, { params });
},
pipelineJobs(projectPath, pipelineId, params = {}) {
const url = Api.buildUrl(this.pipelineJobsPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':pipeline_id', pipelineId);
return axios.get(url, { params });
},
buildUrl(url) {
let urlRoot = '';
if (gon.relative_url_root != null) {

View File

@ -6,6 +6,7 @@ import RepoTabs from './repo_tabs.vue';
import IdeStatusBar from './ide_status_bar.vue';
import RepoEditor from './repo_editor.vue';
import FindFile from './file_finder/index.vue';
import RightPane from './panes/right.vue';
const originalStopCallback = Mousetrap.stopCallback;
@ -16,6 +17,7 @@ export default {
IdeStatusBar,
RepoEditor,
FindFile,
RightPane,
},
computed: {
...mapState([
@ -25,6 +27,7 @@ export default {
'currentMergeRequestId',
'fileFindVisible',
'emptyStateSvgPath',
'currentProjectId',
]),
...mapGetters(['activeFile', 'hasChanges']),
},
@ -122,6 +125,9 @@ export default {
</div>
</template>
</div>
<right-pane
v-if="currentProjectId"
/>
</div>
<ide-status-bar :file="activeFile"/>
</article>

View File

@ -31,6 +31,7 @@ export default {
computed: {
...mapState(['currentBranchId', 'currentProjectId']),
...mapGetters(['currentProject', 'lastCommit']),
...mapState('pipelines', ['latestPipeline']),
},
watch: {
lastCommit() {
@ -51,14 +52,14 @@ export default {
}
},
methods: {
...mapActions(['pipelinePoll', 'stopPipelinePolling']),
...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']),
startTimer() {
this.intervalId = setInterval(() => {
this.commitAgeUpdate();
}, 1000);
},
initPipelinePolling() {
this.pipelinePoll();
this.fetchLatestPipeline();
this.isPollingInitialized = true;
},
commitAgeUpdate() {
@ -81,18 +82,18 @@ export default {
>
<span
class="ide-status-pipeline"
v-if="lastCommit.pipeline && lastCommit.pipeline.details"
v-if="latestPipeline && latestPipeline.details"
>
<ci-icon
:status="lastCommit.pipeline.details.status"
:status="latestPipeline.details.status"
v-tooltip
:title="lastCommit.pipeline.details.status.text"
:title="latestPipeline.details.status.text"
/>
Pipeline
<a
class="monospace"
:href="lastCommit.pipeline.details.status.details_path">#{{ lastCommit.pipeline.id }}</a>
{{ lastCommit.pipeline.details.status.text }}
:href="latestPipeline.details.status.details_path">#{{ latestPipeline.id }}</a>
{{ latestPipeline.details.status.text }}
for
</span>

View File

@ -0,0 +1,46 @@
<script>
import Icon from '../../../vue_shared/components/icon.vue';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
export default {
components: {
Icon,
CiIcon,
},
props: {
job: {
type: Object,
required: true,
},
},
computed: {
jobId() {
return `#${this.job.id}`;
},
},
};
</script>
<template>
<div class="ide-job-item">
<ci-icon
:status="job.status"
:borderless="true"
:size="24"
/>
<span class="prepend-left-8">
{{ job.name }}
<a
:href="job.path"
target="_blank"
class="ide-external-link"
>
{{ jobId }}
<icon
name="external-link"
:size="12"
/>
</a>
</span>
</div>
</template>

View File

@ -0,0 +1,44 @@
<script>
import { mapActions } from 'vuex';
import LoadingIcon from '../../../vue_shared/components/loading_icon.vue';
import Stage from './stage.vue';
export default {
components: {
LoadingIcon,
Stage,
},
props: {
stages: {
type: Array,
required: true,
},
loading: {
type: Boolean,
required: true,
},
},
methods: {
...mapActions('pipelines', ['fetchJobs', 'toggleStageCollapsed']),
},
};
</script>
<template>
<div>
<loading-icon
v-if="loading && !stages.length"
class="prepend-top-default"
size="2"
/>
<template v-else>
<stage
v-for="stage in stages"
:key="stage.id"
:stage="stage"
@fetch="fetchJobs"
@toggleCollapsed="toggleStageCollapsed"
/>
</template>
</div>
</template>

View File

@ -0,0 +1,108 @@
<script>
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
import LoadingIcon from '../../../vue_shared/components/loading_icon.vue';
import Item from './item.vue';
export default {
directives: {
tooltip,
},
components: {
Icon,
CiIcon,
LoadingIcon,
Item,
},
props: {
stage: {
type: Object,
required: true,
},
},
data() {
return {
showTooltip: false,
};
},
computed: {
collapseIcon() {
return this.stage.isCollapsed ? 'angle-left' : 'angle-down';
},
showLoadingIcon() {
return this.stage.isLoading && !this.stage.jobs.length;
},
jobsCount() {
return this.stage.jobs.length;
},
},
mounted() {
const { stageTitle } = this.$refs;
this.showTooltip = stageTitle.scrollWidth > stageTitle.offsetWidth;
this.$emit('fetch', this.stage);
},
methods: {
toggleCollapsed() {
this.$emit('toggleCollapsed', this.stage.id);
},
},
};
</script>
<template>
<div
class="ide-stage card prepend-top-default"
>
<div
class="card-header"
:class="{
'border-bottom-0': stage.isCollapsed
}"
@click="toggleCollapsed"
>
<ci-icon
:status="stage.status"
:size="24"
/>
<strong
v-tooltip="showTooltip"
:title="showTooltip ? stage.name : null"
data-container="body"
class="prepend-left-8 ide-stage-title"
ref="stageTitle"
>
{{ stage.name }}
</strong>
<div
v-if="!stage.isLoading || stage.jobs.length"
class="append-right-8 prepend-left-4"
>
<span class="badge badge-pill">
{{ jobsCount }}
</span>
</div>
<icon
:name="collapseIcon"
css-classes="ide-stage-collapse-icon"
/>
</div>
<div
class="card-body"
v-show="!stage.isCollapsed"
>
<loading-icon
v-if="showLoadingIcon"
/>
<template v-else>
<item
v-for="job in stage.jobs"
:key="job.id"
:job="job"
/>
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,65 @@
<script>
import { mapActions, mapState } from 'vuex';
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants';
import PipelinesList from '../pipelines/list.vue';
export default {
directives: {
tooltip,
},
components: {
Icon,
PipelinesList,
},
computed: {
...mapState(['rightPane']),
},
methods: {
...mapActions(['setRightPane']),
clickTab(e, view) {
e.target.blur();
this.setRightPane(view);
},
},
rightSidebarViews,
};
</script>
<template>
<div
class="multi-file-commit-panel ide-right-sidebar"
>
<div
class="multi-file-commit-panel-inner"
v-if="rightPane"
>
<component :is="rightPane" />
</div>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
<li>
<button
v-tooltip
data-container="body"
data-placement="left"
:title="__('Pipelines')"
class="ide-sidebar-link is-right"
:class="{
active: rightPane === $options.rightSidebarViews.pipelines
}"
type="button"
@click="clickTab($event, $options.rightSidebarViews.pipelines)"
>
<icon
:size="16"
name="pipeline"
/>
</button>
</li>
</ul>
</nav>
</div>
</template>

View File

@ -0,0 +1,146 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import _ from 'underscore';
import { sprintf, __ } from '../../../locale';
import LoadingIcon from '../../../vue_shared/components/loading_icon.vue';
import Icon from '../../../vue_shared/components/icon.vue';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
import Tabs from '../../../vue_shared/components/tabs/tabs';
import Tab from '../../../vue_shared/components/tabs/tab.vue';
import EmptyState from '../../../pipelines/components/empty_state.vue';
import JobsList from '../jobs/list.vue';
export default {
components: {
LoadingIcon,
Icon,
CiIcon,
Tabs,
Tab,
JobsList,
EmptyState,
},
computed: {
...mapState(['pipelinesEmptyStateSvgPath', 'links']),
...mapGetters(['currentProject']),
...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']),
...mapState('pipelines', ['isLoadingPipeline', 'latestPipeline', 'stages', 'isLoadingJobs']),
ciLintText() {
return sprintf(
__('You can also test your .gitlab-ci.yml in the %{linkStart}Lint%{linkEnd}'),
{
linkStart: `<a href="${_.escape(this.currentProject.web_url)}/-/ci/lint">`,
linkEnd: '</a>',
},
false,
);
},
showLoadingIcon() {
return this.isLoadingPipeline && this.latestPipeline === null;
},
},
created() {
this.fetchLatestPipeline();
},
methods: {
...mapActions('pipelines', ['fetchLatestPipeline']),
},
};
</script>
<template>
<div class="ide-pipeline">
<loading-icon
v-if="showLoadingIcon"
class="prepend-top-default"
size="2"
/>
<template v-else-if="latestPipeline !== null">
<header
v-if="latestPipeline"
class="ide-tree-header ide-pipeline-header"
>
<ci-icon
:status="latestPipeline.details.status"
:size="24"
/>
<span class="prepend-left-8">
<strong>
{{ __('Pipeline') }}
</strong>
<a
:href="latestPipeline.path"
target="_blank"
class="ide-external-link"
>
#{{ latestPipeline.id }}
<icon
name="external-link"
:size="12"
/>
</a>
</span>
</header>
<empty-state
v-if="latestPipeline === false"
:help-page-path="links.ciHelpPagePath"
:empty-state-svg-path="pipelinesEmptyStateSvgPath"
:can-set-ci="true"
/>
<div
v-else-if="latestPipeline.yamlError"
class="bs-callout bs-callout-danger"
>
<p class="append-bottom-0">
{{ __('Found errors in your .gitlab-ci.yml:') }}
</p>
<p class="append-bottom-0">
{{ latestPipeline.yamlError }}
</p>
<p
class="append-bottom-0"
v-html="ciLintText"
></p>
</div>
<tabs
v-else
class="ide-pipeline-list"
>
<tab
:active="!pipelineFailed"
>
<template slot="title">
{{ __('Jobs') }}
<span
v-if="jobsCount"
class="badge badge-pill"
>
{{ jobsCount }}
</span>
</template>
<jobs-list
:loading="isLoadingJobs"
:stages="stages"
/>
</tab>
<tab
:active="pipelineFailed"
>
<template slot="title">
{{ __('Failed Jobs') }}
<span
v-if="failedJobsCount"
class="badge badge-pill"
>
{{ failedJobsCount }}
</span>
</template>
<jobs-list
:loading="isLoadingJobs"
:stages="failedStages"
/>
</tab>
</tabs>
</template>
</div>
</template>

View File

@ -20,3 +20,7 @@ export const viewerTypes = {
edit: 'editor',
diff: 'diff',
};
export const rightSidebarViews = {
pipelines: 'pipelines-list',
};

View File

@ -63,7 +63,7 @@ router.beforeEach((to, from, next) => {
.then(() => {
const fullProjectId = `${to.params.namespace}/${to.params.project}`;
const baseSplit = to.params[0].split('/-/');
const baseSplit = (to.params[0] && to.params[0].split('/-/')) || [''];
const branchId = baseSplit[0].slice(-1) === '/' ? baseSplit[0].slice(0, -1) : baseSplit[0];
if (branchId) {

View File

@ -1,4 +1,5 @@
import Vue from 'vue';
import { mapActions } from 'vuex';
import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue';
import store from './stores';
@ -17,11 +18,18 @@ export function initIde(el) {
ide,
},
created() {
this.$store.dispatch('setEmptyStateSvgs', {
this.setEmptyStateSvgs({
emptyStateSvgPath: el.dataset.emptyStateSvgPath,
noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
});
this.setLinks({
ciHelpPagePath: el.dataset.ciHelpPagePath,
});
},
methods: {
...mapActions(['setEmptyStateSvgs', 'setLinks']),
},
render(createElement) {
return createElement('ide');

View File

@ -169,6 +169,12 @@ export const burstUnusedSeal = ({ state, commit }) => {
}
};
export const setRightPane = ({ commit }, view) => {
commit(types.SET_RIGHT_PANE, view);
};
export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links);
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';

View File

@ -1,11 +1,7 @@
import Visibility from 'visibilityjs';
import flash from '~/flash';
import { __ } from '~/locale';
import service from '../../services';
import * as types from '../mutation_types';
import Poll from '../../../lib/utils/poll';
let eTagPoll;
export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) =>
new Promise((resolve, reject) => {
@ -85,61 +81,3 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {})
.catch(() => {
flash(__('Error loading last commit.'), 'alert', document, null, false, true);
});
export const pollSuccessCallBack = ({ commit, state }, { data }) => {
if (data.pipelines && data.pipelines.length) {
const lastCommitHash =
state.projects[state.currentProjectId].branches[state.currentBranchId].commit.id;
const lastCommitPipeline = data.pipelines.find(
pipeline => pipeline.commit.id === lastCommitHash,
);
commit(types.SET_LAST_COMMIT_PIPELINE, {
projectId: state.currentProjectId,
branchId: state.currentBranchId,
pipeline: lastCommitPipeline || {},
});
}
return data;
};
export const pipelinePoll = ({ getters, dispatch }) => {
eTagPoll = new Poll({
resource: service,
method: 'lastCommitPipelines',
data: {
getters,
},
successCallback: ({ data }) => dispatch('pollSuccessCallBack', { data }),
errorCallback: () => {
flash(
__('Something went wrong while fetching the latest pipeline status.'),
'alert',
document,
null,
false,
true,
);
},
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
eTagPoll.restart();
} else {
eTagPoll.stop();
}
});
};
export const stopPipelinePolling = () => {
eTagPoll.stop();
};
export const restartPipelinePolling = () => {
eTagPoll.restart();
};

View File

@ -9,13 +9,16 @@ import pipelines from './modules/pipelines';
Vue.use(Vuex);
export default new Vuex.Store({
state: state(),
actions,
mutations,
getters,
modules: {
commit: commitModule,
pipelines,
},
});
export const createStore = () =>
new Vuex.Store({
state: state(),
actions,
mutations,
getters,
modules: {
commit: commitModule,
pipelines,
},
});
export default createStore();

View File

@ -1,49 +1,80 @@
import Visibility from 'visibilityjs';
import axios from 'axios';
import { __ } from '../../../../locale';
import Api from '../../../../api';
import flash from '../../../../flash';
import Poll from '../../../../lib/utils/poll';
import service from '../../../services';
import * as types from './mutation_types';
let eTagPoll;
export const clearEtagPoll = () => {
eTagPoll = null;
};
export const stopPipelinePolling = () => eTagPoll && eTagPoll.stop();
export const restartPipelinePolling = () => eTagPoll && eTagPoll.restart();
export const requestLatestPipeline = ({ commit }) => commit(types.REQUEST_LATEST_PIPELINE);
export const receiveLatestPipelineError = ({ commit }) => {
export const receiveLatestPipelineError = ({ commit, dispatch }) => {
flash(__('There was an error loading latest pipeline'));
commit(types.RECEIVE_LASTEST_PIPELINE_ERROR);
dispatch('stopPipelinePolling');
};
export const receiveLatestPipelineSuccess = ({ commit }, pipeline) =>
commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, pipeline);
export const receiveLatestPipelineSuccess = ({ rootGetters, commit }, { pipelines }) => {
let lastCommitPipeline = false;
if (pipelines && pipelines.length) {
const lastCommitHash = rootGetters.lastCommit && rootGetters.lastCommit.id;
lastCommitPipeline = pipelines.find(pipeline => pipeline.commit.id === lastCommitHash);
}
commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, lastCommitPipeline);
};
export const fetchLatestPipeline = ({ dispatch, rootGetters }) => {
if (eTagPoll) return;
export const fetchLatestPipeline = ({ dispatch, rootState }, sha) => {
dispatch('requestLatestPipeline');
return Api.pipelines(rootState.currentProjectId, { sha, per_page: '1' })
.then(({ data }) => {
dispatch('receiveLatestPipelineSuccess', data.pop());
})
.catch(() => dispatch('receiveLatestPipelineError'));
eTagPoll = new Poll({
resource: service,
method: 'lastCommitPipelines',
data: { getters: rootGetters },
successCallback: ({ data }) => dispatch('receiveLatestPipelineSuccess', data),
errorCallback: () => dispatch('receiveLatestPipelineError'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
eTagPoll.restart();
} else {
eTagPoll.stop();
}
});
};
export const requestJobs = ({ commit }) => commit(types.REQUEST_JOBS);
export const receiveJobsError = ({ commit }) => {
export const requestJobs = ({ commit }, id) => commit(types.REQUEST_JOBS, id);
export const receiveJobsError = ({ commit }, id) => {
flash(__('There was an error loading jobs'));
commit(types.RECEIVE_JOBS_ERROR);
commit(types.RECEIVE_JOBS_ERROR, id);
};
export const receiveJobsSuccess = ({ commit }, data) => commit(types.RECEIVE_JOBS_SUCCESS, data);
export const receiveJobsSuccess = ({ commit }, { id, data }) =>
commit(types.RECEIVE_JOBS_SUCCESS, { id, data });
export const fetchJobs = ({ dispatch, state, rootState }, page = '1') => {
dispatch('requestJobs');
export const fetchJobs = ({ dispatch }, stage) => {
dispatch('requestJobs', stage.id);
Api.pipelineJobs(rootState.currentProjectId, state.latestPipeline.id, {
page,
})
.then(({ data, headers }) => {
const nextPage = headers && headers['x-next-page'];
dispatch('receiveJobsSuccess', data);
if (nextPage) {
dispatch('fetchJobs', nextPage);
}
})
.catch(() => dispatch('receiveJobsError'));
axios
.get(stage.dropdownPath)
.then(({ data }) => dispatch('receiveJobsSuccess', { id: stage.id, data }))
.catch(() => dispatch('receiveJobsError', stage.id));
};
export const toggleStageCollapsed = ({ commit }, stageId) =>
commit(types.TOGGLE_STAGE_COLLAPSE, stageId);
export default () => {};

View File

@ -0,0 +1,4 @@
// eslint-disable-next-line import/prefer-default-export
export const states = {
failed: 'failed',
};

View File

@ -1,7 +1,22 @@
import { states } from './constants';
export const hasLatestPipeline = state => !state.isLoadingPipeline && !!state.latestPipeline;
export const failedJobs = state =>
export const pipelineFailed = state =>
state.latestPipeline && state.latestPipeline.details.status.text === states.failed;
export const failedStages = state =>
state.stages.filter(stage => stage.status.text.toLowerCase() === states.failed).map(stage => ({
...stage,
jobs: stage.jobs.filter(job => job.status.text.toLowerCase() === states.failed),
}));
export const failedJobsCount = state =>
state.stages.reduce(
(acc, stage) => acc.concat(stage.jobs.filter(job => job.status === 'failed')),
[],
(acc, stage) => acc + stage.jobs.filter(j => j.status.text === states.failed).length,
0,
);
export const jobsCount = state => state.stages.reduce((acc, stage) => acc + stage.jobs.length, 0);
export default () => {};

View File

@ -5,3 +5,5 @@ export const RECEIVE_LASTEST_PIPELINE_SUCCESS = 'RECEIVE_LASTEST_PIPELINE_SUCCES
export const REQUEST_JOBS = 'REQUEST_JOBS';
export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR';
export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
export const TOGGLE_STAGE_COLLAPSE = 'TOGGLE_STAGE_COLLAPSE';

View File

@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import * as types from './mutation_types';
import { normalizeJob } from './utils';
export default {
[types.REQUEST_LATEST_PIPELINE](state) {
@ -14,40 +15,52 @@ export default {
if (pipeline) {
state.latestPipeline = {
id: pipeline.id,
status: pipeline.status,
path: pipeline.path,
commit: pipeline.commit,
details: {
status: pipeline.details.status,
},
yamlError: pipeline.yaml_errors,
};
state.stages = pipeline.details.stages.map((stage, i) => {
const foundStage = state.stages.find(s => s.id === i);
return {
id: i,
dropdownPath: stage.dropdown_path,
name: stage.name,
status: stage.status,
isCollapsed: foundStage ? foundStage.isCollapsed : false,
isLoading: foundStage ? foundStage.isLoading : false,
jobs: foundStage ? foundStage.jobs : [],
};
});
} else {
state.latestPipeline = false;
}
},
[types.REQUEST_JOBS](state) {
state.isLoadingJobs = true;
[types.REQUEST_JOBS](state, id) {
state.stages = state.stages.map(stage => ({
...stage,
isLoading: stage.id === id ? true : stage.isLoading,
}));
},
[types.RECEIVE_JOBS_ERROR](state) {
state.isLoadingJobs = false;
[types.RECEIVE_JOBS_ERROR](state, id) {
state.stages = state.stages.map(stage => ({
...stage,
isLoading: stage.id === id ? false : stage.isLoading,
}));
},
[types.RECEIVE_JOBS_SUCCESS](state, jobs) {
state.isLoadingJobs = false;
state.stages = jobs.reduce((acc, job) => {
let stage = acc.find(s => s.title === job.stage);
if (!stage) {
stage = {
title: job.stage,
jobs: [],
};
acc.push(stage);
}
stage.jobs = stage.jobs.concat({
id: job.id,
name: job.name,
status: job.status,
stage: job.stage,
duration: job.duration,
});
return acc;
}, state.stages);
[types.RECEIVE_JOBS_SUCCESS](state, { id, data }) {
state.stages = state.stages.map(stage => ({
...stage,
isLoading: stage.id === id ? false : stage.isLoading,
jobs: stage.id === id ? data.latest_statuses.map(normalizeJob) : stage.jobs,
}));
},
[types.TOGGLE_STAGE_COLLAPSE](state, id) {
state.stages = state.stages.map(stage => ({
...stage,
isCollapsed: stage.id === id ? !stage.isCollapsed : stage.isCollapsed,
}));
},
};

View File

@ -1,5 +1,5 @@
export default () => ({
isLoadingPipeline: false,
isLoadingPipeline: true,
isLoadingJobs: false,
latestPipeline: null,
stages: [],

View File

@ -0,0 +1,7 @@
// eslint-disable-next-line import/prefer-default-export
export const normalizeJob = job => ({
id: job.id,
name: job.name,
status: job.status,
path: job.build_path,
});

View File

@ -6,6 +6,7 @@ export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS';
export const SET_LINKS = 'SET_LINKS';
// Project Mutation Types
export const SET_PROJECT = 'SET_PROJECT';
@ -23,7 +24,6 @@ export const SET_BRANCH = 'SET_BRANCH';
export const SET_BRANCH_COMMIT = 'SET_BRANCH_COMMIT';
export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE';
export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN';
export const SET_LAST_COMMIT_PIPELINE = 'SET_LAST_COMMIT_PIPELINE';
// Tree mutation types
export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
@ -66,3 +66,5 @@ export const UPDATE_ACTIVITY_BAR_VIEW = 'UPDATE_ACTIVITY_BAR_VIEW';
export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL';
export const SET_RIGHT_PANE = 'SET_RIGHT_PANE';

View File

@ -114,12 +114,13 @@ export default {
},
[types.SET_EMPTY_STATE_SVGS](
state,
{ emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath },
{ emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath },
) {
Object.assign(state, {
emptyStateSvgPath,
noChangesStateSvgPath,
committedStateSvgPath,
pipelinesEmptyStateSvgPath,
});
},
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
@ -148,6 +149,14 @@ export default {
unusedSeal: false,
});
},
[types.SET_RIGHT_PANE](state, view) {
Object.assign(state, {
rightPane: state.rightPane === view ? null : view,
});
},
[types.SET_LINKS](state, links) {
Object.assign(state, { links });
},
...projectMutations,
...mergeRequestMutation,
...fileMutations,

View File

@ -14,10 +14,6 @@ export default {
treeId: `${projectPath}/${branchName}`,
active: true,
workingReference: '',
commit: {
...branch.commit,
pipeline: {},
},
},
},
});
@ -32,9 +28,4 @@ export default {
commit,
});
},
[types.SET_LAST_COMMIT_PIPELINE](state, { projectId, branchId, pipeline }) {
Object.assign(state.projects[projectId].branches[branchId].commit, {
pipeline,
});
},
};

View File

@ -23,4 +23,6 @@ export default () => ({
currentActivityView: activityBarViews.edit,
unusedSeal: true,
fileFindVisible: false,
rightPane: null,
links: {},
});

View File

@ -22,6 +22,8 @@ import Icon from '../../vue_shared/components/icon.vue';
* - Jobs show view header
* - Jobs show view sidebar
*/
const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
export default {
components: {
Icon,
@ -31,17 +33,36 @@ export default {
type: Object,
required: true,
},
size: {
type: Number,
required: false,
default: 16,
validator(value) {
return validSizes.includes(value);
},
},
borderless: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
cssClass() {
const status = this.status.group;
return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
},
icon() {
return this.borderless ? `${this.status.icon}_borderless` : this.status.icon;
},
},
};
</script>
<template>
<span :class="cssClass">
<icon :name="status.icon" />
<icon
:name="icon"
:size="size"
/>
</span>
</template>

View File

@ -0,0 +1,42 @@
<script>
export default {
props: {
title: {
type: String,
required: false,
default: '',
},
active: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
// props can't be updated, so we map it to data where we can
localActive: this.active,
};
},
watch: {
active() {
this.localActive = this.active;
},
},
created() {
this.isTab = true;
},
};
</script>
<template>
<div
class="tab-pane"
:class="{
active: localActive
}"
role="tabpanel"
>
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,64 @@
export default {
data() {
return {
currentIndex: 0,
tabs: [],
};
},
mounted() {
this.updateTabs();
},
methods: {
updateTabs() {
this.tabs = this.$children.filter(child => child.isTab);
this.currentIndex = this.tabs.findIndex(tab => tab.localActive);
},
setTab(index) {
this.tabs[this.currentIndex].localActive = false;
this.tabs[index].localActive = true;
this.currentIndex = index;
},
},
render(h) {
const navItems = this.tabs.map((tab, i) =>
h(
'li',
{
key: i,
},
[
h(
'a',
{
class: tab.localActive ? 'active' : null,
attrs: {
href: '#',
},
on: {
click: () => this.setTab(i),
},
},
tab.$slots.title || tab.title,
),
],
),
);
const nav = h(
'ul',
{
class: 'nav-links tab-links',
},
[navItems],
);
const content = h(
'div',
{
class: ['tab-content'],
},
[this.$slots.default],
);
return h('div', {}, [[nav], content]);
},
};

View File

@ -192,6 +192,10 @@
&.active {
color: $color-700;
box-shadow: inset 3px 0 $color-700;
&.is-right {
box-shadow: inset -3px 0 $color-700;
}
}
}
}

View File

@ -909,6 +909,16 @@
width: 1px;
background: $white-light;
}
&.is-right {
padding-right: $gl-padding;
padding-left: $gl-padding + 1px;
&::after {
right: auto;
left: -1px;
}
}
}
}
@ -1121,3 +1131,112 @@
white-space: nowrap;
}
}
.ide-external-link {
svg {
display: none;
}
&:hover,
&:focus {
svg {
display: inline-block;
}
}
}
.ide-right-sidebar {
width: auto;
min-width: 60px;
.ide-activity-bar {
border-left: 1px solid $white-dark;
}
.multi-file-commit-panel-inner {
width: 350px;
padding: $grid-size $gl-padding;
background-color: $white-light;
border-left: 1px solid $white-dark;
}
}
.ide-pipeline {
display: flex;
flex-direction: column;
height: 100%;
.empty-state {
margin-top: auto;
margin-bottom: auto;
p {
margin: $grid-size 0;
text-align: center;
line-height: 24px;
}
.btn,
h4 {
margin: 0;
}
}
}
.ide-pipeline-list {
flex: 1;
overflow: auto;
}
.ide-pipeline-header {
min-height: 50px;
padding-left: $gl-padding;
padding-right: $gl-padding;
.ci-status-icon {
display: flex;
}
}
.ide-job-item {
display: flex;
padding: 16px;
&:not(:last-child) {
border-bottom: 1px solid $border-color;
}
.ci-status-icon {
display: flex;
justify-content: center;
height: 20px;
margin-top: -2px;
overflow: hidden;
}
}
.ide-stage {
.card-header {
display: flex;
cursor: pointer;
.ci-status-icon {
display: flex;
align-items: center;
}
}
.card-body {
padding: 0;
}
}
.ide-stage-collapse-icon {
margin: auto 0 auto auto;
}
.ide-stage-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -3,7 +3,9 @@
#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
"no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
"committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg') } }
"committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
"pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'),
"ci-help-page-path" => help_page_path('ci/quick_start/README'), } }
.text-center
= icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...')

View File

@ -0,0 +1,29 @@
import Vue from 'vue';
import JobItem from '~/ide/components/jobs/item.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
import { jobs } from '../../mock_data';
describe('IDE jobs item', () => {
const Component = Vue.extend(JobItem);
const job = jobs[0];
let vm;
beforeEach(() => {
vm = mountComponent(Component, {
job,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders job details', () => {
expect(vm.$el.textContent).toContain(job.name);
expect(vm.$el.textContent).toContain(`#${job.id}`);
});
it('renders CI icon', () => {
expect(vm.$el.querySelector('.ic-status_passed_borderless')).not.toBe(null);
});
});

View File

@ -0,0 +1,67 @@
import Vue from 'vue';
import StageList from '~/ide/components/jobs/list.vue';
import { createStore } from '~/ide/stores';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { stages, jobs } from '../../mock_data';
describe('IDE stages list', () => {
const Component = Vue.extend(StageList);
let vm;
beforeEach(() => {
const store = createStore();
vm = createComponentWithStore(Component, store, {
stages: stages.map((mappedState, i) => ({
...mappedState,
id: i,
dropdownPath: mappedState.dropdown_path,
jobs: [...jobs],
isLoading: false,
isCollapsed: false,
})),
loading: false,
});
spyOn(vm, 'fetchJobs');
spyOn(vm, 'toggleStageCollapsed');
vm.$mount();
});
afterEach(() => {
vm.$destroy();
});
it('renders list of stages', () => {
expect(vm.$el.querySelectorAll('.card').length).toBe(2);
});
it('renders loading icon when no stages & is loading', done => {
vm.stages = [];
vm.loading = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
done();
});
});
it('calls toggleStageCollapsed when clicking stage header', done => {
vm.$el.querySelector('.card-header').click();
vm.$nextTick(() => {
expect(vm.toggleStageCollapsed).toHaveBeenCalledWith(0);
done();
});
});
it('calls fetchJobs when stage is mounted', () => {
expect(vm.fetchJobs.calls.count()).toBe(stages.length);
expect(vm.fetchJobs.calls.argsFor(0)).toEqual([vm.stages[0]]);
expect(vm.fetchJobs.calls.argsFor(1)).toEqual([vm.stages[1]]);
});
});

View File

@ -0,0 +1,95 @@
import Vue from 'vue';
import Stage from '~/ide/components/jobs/stage.vue';
import { stages, jobs } from '../../mock_data';
describe('IDE pipeline stage', () => {
const Component = Vue.extend(Stage);
let vm;
let stage;
beforeEach(() => {
stage = {
...stages[0],
id: 0,
dropdownPath: stages[0].dropdown_path,
jobs: [...jobs],
isLoading: false,
isCollapsed: false,
};
vm = new Component({
propsData: { stage },
});
spyOn(vm, '$emit');
vm.$mount();
});
afterEach(() => {
vm.$destroy();
});
it('emits fetch event when mounted', () => {
expect(vm.$emit).toHaveBeenCalledWith('fetch', vm.stage);
});
it('renders stages details', () => {
expect(vm.$el.textContent).toContain(vm.stage.name);
});
it('renders CI icon', () => {
expect(vm.$el.querySelector('.ic-status_failed')).not.toBe(null);
});
describe('collapsed', () => {
it('emits event when clicking header', done => {
vm.$el.querySelector('.card-header').click();
vm.$nextTick(() => {
expect(vm.$emit).toHaveBeenCalledWith('toggleCollapsed', vm.stage.id);
done();
});
});
it('toggles collapse status when collapsed', done => {
vm.stage.isCollapsed = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.card-body').style.display).toBe('none');
done();
});
});
it('sets border bottom class when collapsed', done => {
vm.stage.isCollapsed = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.card-header').classList).toContain('border-bottom-0');
done();
});
});
});
it('renders jobs count', () => {
expect(vm.$el.querySelector('.badge').textContent).toContain('4');
});
it('renders loading icon when no jobs and isLoading is true', done => {
vm.stage.isLoading = true;
vm.stage.jobs = [];
vm.$nextTick(() => {
expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
done();
});
});
it('renders list of jobs', () => {
expect(vm.$el.querySelectorAll('.ide-job-item').length).toBe(4);
});
});

View File

@ -0,0 +1,117 @@
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { createStore } from '~/ide/stores';
import List from '~/ide/components/pipelines/list.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { pipelines, projectData, stages, jobs } from '../../mock_data';
describe('IDE pipelines list', () => {
const Component = Vue.extend(List);
let vm;
let mock;
beforeEach(done => {
const store = createStore();
mock = new MockAdapter(axios);
store.state.currentProjectId = 'abc/def';
store.state.currentBranchId = 'master';
store.state.projects['abc/def'] = {
...projectData,
path_with_namespace: 'abc/def',
branches: {
master: { commit: { id: '123' } },
},
};
store.state.links = { ciHelpPagePath: gl.TEST_HOST };
store.state.pipelinesEmptyStateSvgPath = gl.TEST_HOST;
store.state.pipelines.stages = stages.map((mappedState, i) => ({
...mappedState,
id: i,
dropdownPath: mappedState.dropdown_path,
jobs: [...jobs],
isLoading: false,
isCollapsed: false,
}));
mock
.onGet('/abc/def/commit/123/pipelines')
.replyOnce(200, { pipelines: [...pipelines] }, { 'poll-interval': '-1' });
vm = createComponentWithStore(Component, store).$mount();
setTimeout(done);
});
afterEach(() => {
vm.$store.dispatch('pipelines/stopPipelinePolling');
vm.$store.dispatch('pipelines/clearEtagPoll');
vm.$destroy();
mock.restore();
});
it('renders pipeline data', () => {
expect(vm.$el.textContent).toContain('#1');
});
it('renders CI icon', () => {
expect(vm.$el.querySelector('.ci-status-icon-failed')).not.toBe(null);
});
it('renders list of jobs', () => {
expect(vm.$el.querySelectorAll('.tab-pane:first-child .ide-job-item').length).toBe(
jobs.length * stages.length,
);
});
it('renders list of failed jobs on failed jobs tab', done => {
vm.$el.querySelectorAll('.tab-links a')[1].click();
vm.$nextTick(() => {
expect(vm.$el.querySelectorAll('.tab-pane.active .ide-job-item').length).toBe(2);
done();
});
});
describe('YAML error', () => {
it('renders YAML error', done => {
vm.$store.state.pipelines.latestPipeline.yamlError = 'test yaml error';
vm.$nextTick(() => {
expect(vm.$el.textContent).toContain('Found errors in your .gitlab-ci.yml:');
expect(vm.$el.textContent).toContain('test yaml error');
done();
});
});
});
describe('empty state', () => {
it('renders pipelines empty state', done => {
vm.$store.state.pipelines.latestPipeline = false;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.empty-state')).not.toBe(null);
done();
});
});
});
describe('loading state', () => {
it('renders loading state when there is no latest pipeline', done => {
vm.$store.state.pipelines.latestPipeline = null;
vm.$store.state.pipelines.isLoadingPipeline = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
done();
});
});
});
});

View File

@ -1,11 +1,13 @@
import { decorateData } from '~/ide/stores/utils';
import state from '~/ide/stores/state';
import commitState from '~/ide/stores/modules/commit/state';
import pipelinesState from '~/ide/stores/modules/pipelines/state';
export const resetStore = store => {
const newState = {
...state(),
commit: commitState(),
pipelines: pipelinesState(),
};
store.replaceState(newState);
};

View File

@ -19,13 +19,48 @@ export const pipelines = [
id: 1,
ref: 'master',
sha: '123',
status: 'failed',
details: {
status: {
icon: 'status_failed',
group: 'failed',
text: 'Failed',
},
},
commit: { id: '123' },
},
{
id: 2,
ref: 'master',
sha: '213',
status: 'success',
details: {
status: {
icon: 'status_failed',
group: 'failed',
text: 'Failed',
},
},
commit: { id: '213' },
},
];
export const stages = [
{
dropdown_path: `${gl.TEST_HOST}/testing`,
name: 'build',
status: {
icon: 'status_failed',
group: 'failed',
text: 'failed',
},
},
{
dropdown_path: 'testing',
name: 'test',
status: {
icon: 'status_failed',
group: 'failed',
text: 'failed',
},
},
];
@ -33,28 +68,44 @@ export const jobs = [
{
id: 1,
name: 'test',
status: 'failed',
path: 'testing',
status: {
icon: 'status_passed',
text: 'passed',
},
stage: 'test',
duration: 1,
},
{
id: 2,
name: 'test 2',
status: 'failed',
path: 'testing2',
status: {
icon: 'status_passed',
text: 'passed',
},
stage: 'test',
duration: 1,
},
{
id: 3,
name: 'test 3',
status: 'failed',
path: 'testing3',
status: {
icon: 'status_passed',
text: 'passed',
},
stage: 'test',
duration: 1,
},
{
id: 4,
name: 'test 3',
status: 'failed',
name: 'test 4',
path: 'testing4',
status: {
icon: 'status_failed',
text: 'failed',
},
stage: 'build',
duration: 1,
},
@ -68,14 +119,16 @@ export const fullPipelinesResponse = {
pipelines: [
{
id: '51',
path: 'test',
commit: {
id: 'xxxxxxxxxxxxxxxxxxxx',
id: '123',
},
details: {
status: {
icon: 'status_failed',
text: 'failed',
},
stages: [...stages],
},
},
{
@ -88,6 +141,7 @@ export const fullPipelinesResponse = {
icon: 'status_passed',
text: 'passed',
},
stages: [...stages],
},
},
],

View File

@ -1,31 +1,10 @@
import Visibility from 'visibilityjs';
import MockAdapter from 'axios-mock-adapter';
import { refreshLastCommitData, pollSuccessCallBack } from '~/ide/stores/actions';
import { refreshLastCommitData } from '~/ide/stores/actions';
import store from '~/ide/stores';
import service from '~/ide/services';
import axios from '~/lib/utils/axios_utils';
import { fullPipelinesResponse } from '../../mock_data';
import { resetStore } from '../../helpers';
import testAction from '../../../helpers/vuex_action_helper';
describe('IDE store project actions', () => {
const setProjectState = () => {
store.state.currentProjectId = 'abc/def';
store.state.currentBranchId = 'master';
store.state.projects['abc/def'] = {
id: 4,
path_with_namespace: 'abc/def',
branches: {
master: {
commit: {
id: 'abc123def456ghi789jkl',
title: 'example',
},
},
},
};
};
beforeEach(() => {
store.state.projects['abc/def'] = {};
});
@ -101,92 +80,4 @@ describe('IDE store project actions', () => {
);
});
});
describe('pipelinePoll', () => {
let mock;
beforeEach(() => {
setProjectState();
jasmine.clock().install();
mock = new MockAdapter(axios);
mock
.onGet('/abc/def/commit/abc123def456ghi789jkl/pipelines')
.reply(200, { data: { foo: 'bar' } }, { 'poll-interval': '10000' });
});
afterEach(() => {
jasmine.clock().uninstall();
mock.restore();
store.dispatch('stopPipelinePolling');
});
it('calls service periodically', done => {
spyOn(axios, 'get').and.callThrough();
spyOn(Visibility, 'hidden').and.returnValue(false);
store
.dispatch('pipelinePoll')
.then(() => {
jasmine.clock().tick(1000);
expect(axios.get).toHaveBeenCalled();
expect(axios.get.calls.count()).toBe(1);
})
.then(() => new Promise(resolve => requestAnimationFrame(resolve)))
.then(() => {
jasmine.clock().tick(10000);
expect(axios.get.calls.count()).toBe(2);
})
.then(() => new Promise(resolve => requestAnimationFrame(resolve)))
.then(() => {
jasmine.clock().tick(10000);
expect(axios.get.calls.count()).toBe(3);
})
.then(() => new Promise(resolve => requestAnimationFrame(resolve)))
.then(() => {
jasmine.clock().tick(10000);
expect(axios.get.calls.count()).toBe(4);
})
.then(done)
.catch(done.fail);
});
});
describe('pollSuccessCallBack', () => {
beforeEach(() => {
setProjectState();
});
it('commits correct pipeline', done => {
testAction(
pollSuccessCallBack,
fullPipelinesResponse,
store.state,
[
{
type: 'SET_LAST_COMMIT_PIPELINE',
payload: {
projectId: 'abc/def',
branchId: 'master',
pipeline: {
id: '50',
commit: {
id: 'abc123def456ghi789jkl',
},
details: {
status: {
icon: 'status_passed',
text: 'passed',
},
},
},
},
},
], // mutations
[], // action
done,
);
});
});
});

View File

@ -1,3 +1,4 @@
import Visibility from 'visibilityjs';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import actions, {
@ -5,10 +6,13 @@ import actions, {
receiveLatestPipelineError,
receiveLatestPipelineSuccess,
fetchLatestPipeline,
stopPipelinePolling,
clearEtagPoll,
requestJobs,
receiveJobsError,
receiveJobsSuccess,
fetchJobs,
toggleStageCollapsed,
} from '~/ide/stores/modules/pipelines/actions';
import state from '~/ide/stores/modules/pipelines/state';
import * as types from '~/ide/stores/modules/pipelines/mutation_types';
@ -51,7 +55,7 @@ describe('IDE pipelines actions', () => {
null,
mockedState,
[{ type: types.RECEIVE_LASTEST_PIPELINE_ERROR }],
[],
[{ type: 'stopPipelinePolling' }],
done,
);
});
@ -59,91 +63,128 @@ describe('IDE pipelines actions', () => {
it('creates flash message', () => {
const flashSpy = spyOnDependency(actions, 'flash');
receiveLatestPipelineError({ commit() {} });
receiveLatestPipelineError({ commit() {}, dispatch() {} });
expect(flashSpy).toHaveBeenCalled();
});
});
describe('receiveLatestPipelineSuccess', () => {
it('commits pipeline', done => {
testAction(
receiveLatestPipelineSuccess,
const rootGetters = {
lastCommit: { id: '123' },
};
let commit;
beforeEach(() => {
commit = jasmine.createSpy('commit');
});
it('commits pipeline', () => {
receiveLatestPipelineSuccess({ rootGetters, commit }, { pipelines });
expect(commit.calls.argsFor(0)).toEqual([
types.RECEIVE_LASTEST_PIPELINE_SUCCESS,
pipelines[0],
mockedState,
[{ type: types.RECEIVE_LASTEST_PIPELINE_SUCCESS, payload: pipelines[0] }],
[],
done,
);
]);
});
it('commits false when there are no pipelines', () => {
receiveLatestPipelineSuccess({ rootGetters, commit }, { pipelines: [] });
expect(commit.calls.argsFor(0)).toEqual([types.RECEIVE_LASTEST_PIPELINE_SUCCESS, false]);
});
});
describe('fetchLatestPipeline', () => {
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
stopPipelinePolling();
clearEtagPoll();
});
describe('success', () => {
beforeEach(() => {
mock.onGet(/\/api\/v4\/projects\/(.*)\/pipelines(.*)/).replyOnce(200, pipelines);
mock
.onGet('/abc/def/commit/abc123def456ghi789jkl/pipelines')
.reply(200, { data: { foo: 'bar' } }, { 'poll-interval': '10000' });
});
it('dispatches request', done => {
testAction(
fetchLatestPipeline,
'123',
mockedState,
[],
[{ type: 'requestLatestPipeline' }, { type: 'receiveLatestPipelineSuccess' }],
done,
);
});
spyOn(axios, 'get').and.callThrough();
spyOn(Visibility, 'hidden').and.returnValue(false);
it('dispatches success with latest pipeline', done => {
testAction(
fetchLatestPipeline,
'123',
mockedState,
[],
[
{ type: 'requestLatestPipeline' },
{ type: 'receiveLatestPipelineSuccess', payload: pipelines[0] },
],
done,
);
});
const dispatch = jasmine.createSpy('dispatch');
const rootGetters = {
lastCommit: { id: 'abc123def456ghi789jkl' },
currentProject: { path_with_namespace: 'abc/def' },
};
it('calls axios with correct params', () => {
const apiSpy = spyOn(axios, 'get').and.callThrough();
fetchLatestPipeline({ dispatch, rootGetters });
fetchLatestPipeline({ dispatch() {}, rootState: state }, '123');
expect(dispatch.calls.argsFor(0)).toEqual(['requestLatestPipeline']);
expect(apiSpy).toHaveBeenCalledWith(jasmine.anything(), {
params: {
sha: '123',
per_page: '1',
},
});
jasmine.clock().tick(1000);
new Promise(resolve => requestAnimationFrame(resolve))
.then(() => {
expect(axios.get).toHaveBeenCalled();
expect(axios.get.calls.count()).toBe(1);
expect(dispatch.calls.argsFor(1)).toEqual([
'receiveLatestPipelineSuccess',
jasmine.anything(),
]);
jasmine.clock().tick(10000);
})
.then(() => new Promise(resolve => requestAnimationFrame(resolve)))
.then(() => {
expect(axios.get).toHaveBeenCalled();
expect(axios.get.calls.count()).toBe(2);
expect(dispatch.calls.argsFor(2)).toEqual([
'receiveLatestPipelineSuccess',
jasmine.anything(),
]);
})
.then(done)
.catch(done.fail);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(/\/api\/v4\/projects\/(.*)\/pipelines(.*)/).replyOnce(500);
mock.onGet('/abc/def/commit/abc123def456ghi789jkl/pipelines').reply(500);
});
it('dispatches error', done => {
testAction(
fetchLatestPipeline,
'123',
mockedState,
[],
[{ type: 'requestLatestPipeline' }, { type: 'receiveLatestPipelineError' }],
done,
);
const dispatch = jasmine.createSpy('dispatch');
const rootGetters = {
lastCommit: { id: 'abc123def456ghi789jkl' },
currentProject: { path_with_namespace: 'abc/def' },
};
fetchLatestPipeline({ dispatch, rootGetters });
jasmine.clock().tick(1500);
new Promise(resolve => requestAnimationFrame(resolve))
.then(() => {
expect(dispatch.calls.argsFor(1)).toEqual(['receiveLatestPipelineError']);
})
.then(done)
.catch(done.fail);
});
});
});
describe('requestJobs', () => {
it('commits request', done => {
testAction(requestJobs, null, mockedState, [{ type: types.REQUEST_JOBS }], [], done);
testAction(requestJobs, 1, mockedState, [{ type: types.REQUEST_JOBS, payload: 1 }], [], done);
});
});
@ -151,9 +192,9 @@ describe('IDE pipelines actions', () => {
it('commits error', done => {
testAction(
receiveJobsError,
null,
1,
mockedState,
[{ type: types.RECEIVE_JOBS_ERROR }],
[{ type: types.RECEIVE_JOBS_ERROR, payload: 1 }],
[],
done,
);
@ -162,19 +203,19 @@ describe('IDE pipelines actions', () => {
it('creates flash message', () => {
const flashSpy = spyOnDependency(actions, 'flash');
receiveJobsError({ commit() {} });
receiveJobsError({ commit() {} }, 1);
expect(flashSpy).toHaveBeenCalled();
});
});
describe('receiveJobsSuccess', () => {
it('commits jobs', done => {
it('commits data', done => {
testAction(
receiveJobsSuccess,
jobs,
{ id: 1, data: jobs },
mockedState,
[{ type: types.RECEIVE_JOBS_SUCCESS, payload: jobs }],
[{ type: types.RECEIVE_JOBS_SUCCESS, payload: { id: 1, data: jobs } }],
[],
done,
);
@ -182,108 +223,62 @@ describe('IDE pipelines actions', () => {
});
describe('fetchJobs', () => {
let page = '';
beforeEach(() => {
mockedState.latestPipeline = pipelines[0];
});
const stage = {
id: 1,
dropdownPath: `${gl.TEST_HOST}/jobs`,
};
describe('success', () => {
beforeEach(() => {
mock.onGet(/\/api\/v4\/projects\/(.*)\/pipelines\/(.*)\/jobs/).replyOnce(() => [
200,
jobs,
{
'x-next-page': page,
},
]);
mock.onGet(stage.dropdownPath).replyOnce(200, jobs);
});
it('dispatches request', done => {
testAction(
fetchJobs,
null,
mockedState,
[],
[{ type: 'requestJobs' }, { type: 'receiveJobsSuccess' }],
done,
);
});
it('dispatches success with latest pipeline', done => {
testAction(
fetchJobs,
null,
mockedState,
[],
[{ type: 'requestJobs' }, { type: 'receiveJobsSuccess', payload: jobs }],
done,
);
});
it('dispatches twice for both pages', done => {
page = '2';
testAction(
fetchJobs,
null,
stage,
mockedState,
[],
[
{ type: 'requestJobs' },
{ type: 'receiveJobsSuccess', payload: jobs },
{ type: 'fetchJobs', payload: '2' },
{ type: 'requestJobs' },
{ type: 'receiveJobsSuccess', payload: jobs },
{ type: 'requestJobs', payload: stage.id },
{ type: 'receiveJobsSuccess', payload: { id: stage.id, data: jobs } },
],
done,
);
});
it('calls axios with correct URL', () => {
const apiSpy = spyOn(axios, 'get').and.callThrough();
fetchJobs({ dispatch() {}, state: mockedState, rootState: mockedState });
expect(apiSpy).toHaveBeenCalledWith('/api/v4/projects/test%2Fproject/pipelines/1/jobs', {
params: { page: '1' },
});
});
it('calls axios with page next page', () => {
const apiSpy = spyOn(axios, 'get').and.callThrough();
fetchJobs({ dispatch() {}, state: mockedState, rootState: mockedState });
expect(apiSpy).toHaveBeenCalledWith('/api/v4/projects/test%2Fproject/pipelines/1/jobs', {
params: { page: '1' },
});
page = '2';
fetchJobs({ dispatch() {}, state: mockedState, rootState: mockedState }, page);
expect(apiSpy).toHaveBeenCalledWith('/api/v4/projects/test%2Fproject/pipelines/1/jobs', {
params: { page: '2' },
});
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(/\/api\/v4\/projects\/(.*)\/pipelines(.*)/).replyOnce(500);
mock.onGet(stage.dropdownPath).replyOnce(500);
});
it('dispatches error', done => {
testAction(
fetchJobs,
null,
stage,
mockedState,
[],
[{ type: 'requestJobs' }, { type: 'receiveJobsError' }],
[
{ type: 'requestJobs', payload: stage.id },
{ type: 'receiveJobsError', payload: stage.id },
],
done,
);
});
});
});
describe('toggleStageCollapsed', () => {
it('commits collapse', done => {
testAction(
toggleStageCollapsed,
1,
mockedState,
[{ type: types.TOGGLE_STAGE_COLLAPSE, payload: 1 }],
[],
done,
);
});
});
});

View File

@ -37,35 +37,4 @@ describe('IDE pipeline getters', () => {
expect(getters.hasLatestPipeline(mockedState)).toBe(true);
});
});
describe('failedJobs', () => {
it('returns array of failed jobs', () => {
mockedState.stages = [
{
title: 'test',
jobs: [{ id: 1, status: 'failed' }, { id: 2, status: 'success' }],
},
{
title: 'build',
jobs: [{ id: 3, status: 'failed' }, { id: 4, status: 'failed' }],
},
];
expect(getters.failedJobs(mockedState).length).toBe(3);
expect(getters.failedJobs(mockedState)).toEqual([
{
id: 1,
status: jasmine.anything(),
},
{
id: 3,
status: jasmine.anything(),
},
{
id: 4,
status: jasmine.anything(),
},
]);
});
});
});

View File

@ -1,7 +1,7 @@
import mutations from '~/ide/stores/modules/pipelines/mutations';
import state from '~/ide/stores/modules/pipelines/state';
import * as types from '~/ide/stores/modules/pipelines/mutation_types';
import { pipelines, jobs } from '../../../mock_data';
import { fullPipelinesResponse, stages, jobs } from '../../../mock_data';
describe('IDE pipelines mutations', () => {
let mockedState;
@ -28,93 +28,147 @@ describe('IDE pipelines mutations', () => {
describe(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, () => {
it('sets loading to false on success', () => {
mutations[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](mockedState, pipelines[0]);
mutations[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](
mockedState,
fullPipelinesResponse.data.pipelines[0],
);
expect(mockedState.isLoadingPipeline).toBe(false);
});
it('sets latestPipeline', () => {
mutations[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](mockedState, pipelines[0]);
mutations[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](
mockedState,
fullPipelinesResponse.data.pipelines[0],
);
expect(mockedState.latestPipeline).toEqual({
id: pipelines[0].id,
status: pipelines[0].status,
id: '51',
path: 'test',
commit: { id: '123' },
details: { status: jasmine.any(Object) },
yamlError: undefined,
});
});
it('does not set latest pipeline if pipeline is null', () => {
mutations[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](mockedState, null);
expect(mockedState.latestPipeline).toEqual(null);
});
});
describe(types.REQUEST_JOBS, () => {
it('sets jobs loading to true', () => {
mutations[types.REQUEST_JOBS](mockedState);
expect(mockedState.isLoadingJobs).toBe(true);
});
});
describe(types.RECEIVE_JOBS_ERROR, () => {
it('sets jobs loading to false', () => {
mutations[types.RECEIVE_JOBS_ERROR](mockedState);
expect(mockedState.isLoadingJobs).toBe(false);
});
});
describe(types.RECEIVE_JOBS_SUCCESS, () => {
it('sets jobs loading to false on success', () => {
mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, jobs);
expect(mockedState.isLoadingJobs).toBe(false);
expect(mockedState.latestPipeline).toEqual(false);
});
it('sets stages', () => {
mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, jobs);
mutations[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](
mockedState,
fullPipelinesResponse.data.pipelines[0],
);
expect(mockedState.stages.length).toBe(2);
expect(mockedState.stages).toEqual([
{
title: 'test',
jobs: jasmine.anything(),
id: 0,
dropdownPath: stages[0].dropdown_path,
name: stages[0].name,
status: stages[0].status,
isCollapsed: false,
isLoading: false,
jobs: [],
},
{
title: 'build',
jobs: jasmine.anything(),
},
]);
});
it('sets jobs in stages', () => {
mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, jobs);
expect(mockedState.stages[0].jobs.length).toBe(3);
expect(mockedState.stages[1].jobs.length).toBe(1);
expect(mockedState.stages).toEqual([
{
title: jasmine.anything(),
jobs: jobs.filter(job => job.stage === 'test').map(job => ({
id: job.id,
name: job.name,
status: job.status,
stage: job.stage,
duration: job.duration,
})),
},
{
title: jasmine.anything(),
jobs: jobs.filter(job => job.stage === 'build').map(job => ({
id: job.id,
name: job.name,
status: job.status,
stage: job.stage,
duration: job.duration,
})),
id: 1,
dropdownPath: stages[1].dropdown_path,
name: stages[1].name,
status: stages[1].status,
isCollapsed: false,
isLoading: false,
jobs: [],
},
]);
});
});
describe(types.REQUEST_JOBS, () => {
beforeEach(() => {
mockedState.stages = stages.map((stage, i) => ({
...stage,
id: i,
}));
});
it('sets isLoading on stage', () => {
mutations[types.REQUEST_JOBS](mockedState, mockedState.stages[0].id);
expect(mockedState.stages[0].isLoading).toBe(true);
});
});
describe(types.RECEIVE_JOBS_ERROR, () => {
beforeEach(() => {
mockedState.stages = stages.map((stage, i) => ({
...stage,
id: i,
}));
});
it('sets isLoading on stage after error', () => {
mutations[types.RECEIVE_JOBS_ERROR](mockedState, mockedState.stages[0].id);
expect(mockedState.stages[0].isLoading).toBe(false);
});
});
describe(types.RECEIVE_JOBS_SUCCESS, () => {
let data;
beforeEach(() => {
mockedState.stages = stages.map((stage, i) => ({
...stage,
id: i,
}));
data = {
latest_statuses: [...jobs],
};
});
it('updates loading', () => {
mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, { id: mockedState.stages[0].id, data });
expect(mockedState.stages[0].isLoading).toBe(false);
});
it('sets jobs on stage', () => {
mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, { id: mockedState.stages[0].id, data });
expect(mockedState.stages[0].jobs.length).toBe(jobs.length);
expect(mockedState.stages[0].jobs).toEqual(
jobs.map(job => ({
id: job.id,
name: job.name,
status: job.status,
path: job.build_path,
})),
);
});
});
describe(types.TOGGLE_STAGE_COLLAPSE, () => {
beforeEach(() => {
mockedState.stages = stages.map((stage, i) => ({
...stage,
id: i,
isCollapsed: false,
}));
});
it('toggles collapsed state', () => {
mutations[types.TOGGLE_STAGE_COLLAPSE](mockedState, mockedState.stages[0].id);
expect(mockedState.stages[0].isCollapsed).toBe(true);
mutations[types.TOGGLE_STAGE_COLLAPSE](mockedState, mockedState.stages[0].id);
expect(mockedState.stages[0].isCollapsed).toBe(false);
});
});
});

View File

@ -37,40 +37,4 @@ describe('Multi-file store branch mutations', () => {
expect(localState.projects.Example.branches.master.commit.title).toBe('Example commit');
});
});
describe('SET_LAST_COMMIT_PIPELINE', () => {
it('sets the pipeline for the last commit on current project', () => {
localState.projects = {
Example: {
branches: {
master: {
commit: {},
},
},
},
};
mutations.SET_LAST_COMMIT_PIPELINE(localState, {
projectId: 'Example',
branchId: 'master',
pipeline: {
id: '50',
details: {
status: {
icon: 'status_passed',
text: 'passed',
},
},
},
});
expect(localState.projects.Example.branches.master.commit.pipeline.id).toBe('50');
expect(localState.projects.Example.branches.master.commit.pipeline.details.status.text).toBe(
'passed',
);
expect(localState.projects.Example.branches.master.commit.pipeline.details.status.icon).toBe(
'status_passed',
);
});
});
});

View File

@ -0,0 +1,32 @@
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import Tab from '~/vue_shared/components/tabs/tab.vue';
describe('Tab component', () => {
const Component = Vue.extend(Tab);
let vm;
beforeEach(() => {
vm = mountComponent(Component);
});
it('sets localActive to equal active', done => {
vm.active = true;
vm.$nextTick(() => {
expect(vm.localActive).toBe(true);
done();
});
});
it('sets active class', done => {
vm.active = true;
vm.$nextTick(() => {
expect(vm.$el.classList).toContain('active');
done();
});
});
});

View File

@ -0,0 +1,68 @@
import Vue from 'vue';
import Tabs from '~/vue_shared/components/tabs/tabs';
import Tab from '~/vue_shared/components/tabs/tab.vue';
describe('Tabs component', () => {
let vm;
beforeEach(done => {
vm = new Vue({
components: {
Tabs,
Tab,
},
template: `
<div>
<tabs>
<tab title="Testing" active>
First tab
</tab>
<tab>
<template slot="title">Test slot</template>
Second tab
</tab>
</tabs>
</div>
`,
}).$mount();
setTimeout(done);
});
describe('tab links', () => {
it('renders links for tabs', () => {
expect(vm.$el.querySelectorAll('a').length).toBe(2);
});
it('renders link titles from props', () => {
expect(vm.$el.querySelector('a').textContent).toContain('Testing');
});
it('renders link titles from slot', () => {
expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot');
});
it('renders active class', () => {
expect(vm.$el.querySelector('a').classList).toContain('active');
});
it('updates active class on click', done => {
vm.$el.querySelectorAll('a')[1].click();
setTimeout(() => {
expect(vm.$el.querySelector('a').classList).not.toContain('active');
expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active');
done();
});
});
});
describe('content', () => {
it('renders content panes', () => {
expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2);
expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab');
expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab');
});
});
});