Commit 5b9e4d98 authored by Filipa Lacerda's avatar Filipa Lacerda

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

Show CI jobs in IDE

Closes #44604

See merge request gitlab-org/gitlab-ce!19106
parents e1247186 745d3538
...@@ -24,8 +24,6 @@ const Api = { ...@@ -24,8 +24,6 @@ const Api = {
commitPipelinesPath: '/:project_id/commit/:sha/pipelines', commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches', 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) { group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
...@@ -238,20 +236,6 @@ const Api = { ...@@ -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) { buildUrl(url) {
let urlRoot = ''; let urlRoot = '';
if (gon.relative_url_root != null) { if (gon.relative_url_root != null) {
......
...@@ -6,6 +6,7 @@ import RepoTabs from './repo_tabs.vue'; ...@@ -6,6 +6,7 @@ import RepoTabs from './repo_tabs.vue';
import IdeStatusBar from './ide_status_bar.vue'; import IdeStatusBar from './ide_status_bar.vue';
import RepoEditor from './repo_editor.vue'; import RepoEditor from './repo_editor.vue';
import FindFile from './file_finder/index.vue'; import FindFile from './file_finder/index.vue';
import RightPane from './panes/right.vue';
const originalStopCallback = Mousetrap.stopCallback; const originalStopCallback = Mousetrap.stopCallback;
...@@ -16,6 +17,7 @@ export default { ...@@ -16,6 +17,7 @@ export default {
IdeStatusBar, IdeStatusBar,
RepoEditor, RepoEditor,
FindFile, FindFile,
RightPane,
}, },
computed: { computed: {
...mapState([ ...mapState([
...@@ -25,6 +27,7 @@ export default { ...@@ -25,6 +27,7 @@ export default {
'currentMergeRequestId', 'currentMergeRequestId',
'fileFindVisible', 'fileFindVisible',
'emptyStateSvgPath', 'emptyStateSvgPath',
'currentProjectId',
]), ]),
...mapGetters(['activeFile', 'hasChanges']), ...mapGetters(['activeFile', 'hasChanges']),
}, },
...@@ -122,6 +125,9 @@ export default { ...@@ -122,6 +125,9 @@ export default {
</div> </div>
</template> </template>
</div> </div>
<right-pane
v-if="currentProjectId"
/>
</div> </div>
<ide-status-bar :file="activeFile"/> <ide-status-bar :file="activeFile"/>
</article> </article>
......
...@@ -31,6 +31,7 @@ export default { ...@@ -31,6 +31,7 @@ export default {
computed: { computed: {
...mapState(['currentBranchId', 'currentProjectId']), ...mapState(['currentBranchId', 'currentProjectId']),
...mapGetters(['currentProject', 'lastCommit']), ...mapGetters(['currentProject', 'lastCommit']),
...mapState('pipelines', ['latestPipeline']),
}, },
watch: { watch: {
lastCommit() { lastCommit() {
...@@ -51,14 +52,14 @@ export default { ...@@ -51,14 +52,14 @@ export default {
} }
}, },
methods: { methods: {
...mapActions(['pipelinePoll', 'stopPipelinePolling']), ...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']),
startTimer() { startTimer() {
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
this.commitAgeUpdate(); this.commitAgeUpdate();
}, 1000); }, 1000);
}, },
initPipelinePolling() { initPipelinePolling() {
this.pipelinePoll(); this.fetchLatestPipeline();
this.isPollingInitialized = true; this.isPollingInitialized = true;
}, },
commitAgeUpdate() { commitAgeUpdate() {
...@@ -81,18 +82,18 @@ export default { ...@@ -81,18 +82,18 @@ export default {
> >
<span <span
class="ide-status-pipeline" class="ide-status-pipeline"
v-if="lastCommit.pipeline && lastCommit.pipeline.details" v-if="latestPipeline && latestPipeline.details"
> >
<ci-icon <ci-icon
:status="lastCommit.pipeline.details.status" :status="latestPipeline.details.status"
v-tooltip v-tooltip
:title="lastCommit.pipeline.details.status.text" :title="latestPipeline.details.status.text"
/> />
Pipeline Pipeline
<a <a
class="monospace" class="monospace"
:href="lastCommit.pipeline.details.status.details_path">#{{ lastCommit.pipeline.id }}</a> :href="latestPipeline.details.status.details_path">#{{ latestPipeline.id }}</a>
{{ lastCommit.pipeline.details.status.text }} {{ latestPipeline.details.status.text }}
for for
</span> </span>
......
<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>
<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>
<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>
<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>
<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>
...@@ -20,3 +20,7 @@ export const viewerTypes = { ...@@ -20,3 +20,7 @@ export const viewerTypes = {
edit: 'editor', edit: 'editor',
diff: 'diff', diff: 'diff',
}; };
export const rightSidebarViews = {
pipelines: 'pipelines-list',
};
...@@ -63,7 +63,7 @@ router.beforeEach((to, from, next) => { ...@@ -63,7 +63,7 @@ router.beforeEach((to, from, next) => {
.then(() => { .then(() => {
const fullProjectId = `${to.params.namespace}/${to.params.project}`; 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]; const branchId = baseSplit[0].slice(-1) === '/' ? baseSplit[0].slice(0, -1) : baseSplit[0];
if (branchId) { if (branchId) {
......
import Vue from 'vue'; import Vue from 'vue';
import { mapActions } from 'vuex';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue'; import ide from './components/ide.vue';
import store from './stores'; import store from './stores';
...@@ -17,11 +18,18 @@ export function initIde(el) { ...@@ -17,11 +18,18 @@ export function initIde(el) {
ide, ide,
}, },
created() { created() {
this.$store.dispatch('setEmptyStateSvgs', { this.setEmptyStateSvgs({
emptyStateSvgPath: el.dataset.emptyStateSvgPath, emptyStateSvgPath: el.dataset.emptyStateSvgPath,
noChangesStateSvgPath: el.dataset.noChangesStateSvgPath, noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
committedStateSvgPath: el.dataset.committedStateSvgPath, committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
}); });
this.setLinks({
ciHelpPagePath: el.dataset.ciHelpPagePath,
});
},
methods: {
...mapActions(['setEmptyStateSvgs', 'setLinks']),
}, },
render(createElement) { render(createElement) {
return createElement('ide'); return createElement('ide');
......
...@@ -169,6 +169,12 @@ export const burstUnusedSeal = ({ state, commit }) => { ...@@ -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/tree';
export * from './actions/file'; export * from './actions/file';
export * from './actions/project'; export * from './actions/project';
......
import Visibility from 'visibilityjs';
import flash from '~/flash'; import flash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import service from '../../services'; import service from '../../services';
import * as types from '../mutation_types'; import * as types from '../mutation_types';
import Poll from '../../../lib/utils/poll';
let eTagPoll;
export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) => export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
...@@ -85,61 +81,3 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) ...@@ -85,61 +81,3 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {})
.catch(() => { .catch(() => {
flash(__('Error loading last commit.'), 'alert', document, null, false, true); 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();
};
...@@ -9,13 +9,16 @@ import pipelines from './modules/pipelines'; ...@@ -9,13 +9,16 @@ import pipelines from './modules/pipelines';
Vue.use(Vuex); Vue.use(Vuex);
export default new Vuex.Store({ export const createStore = () =>
state: state(), new Vuex.Store({
actions, state: state(),
mutations, actions,
getters, mutations,
modules: { getters,
commit: commitModule, modules: {
pipelines, commit: commitModule,
}, pipelines,
}); },
});
export default createStore();
import Visibility from 'visibilityjs';
import axios from 'axios';
import { __ } from '../../../../locale'; import { __ } from '../../../../locale';
import Api from '../../../../api';
import flash from '../../../../flash'; import flash from '../../../../flash';
import Poll from '../../../../lib/utils/poll';
import service from '../../../services';
import * as types from './mutation_types'; 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 requestLatestPipeline = ({ commit }) => commit(types.REQUEST_LATEST_PIPELINE);
export const receiveLatestPipelineError = ({ commit }) => { export const receiveLatestPipelineError = ({ commit, dispatch }) => {
flash(__('There was an error loading latest pipeline')); flash(__('There was an error loading latest pipeline'));
commit(types.RECEIVE_LASTEST_PIPELINE_ERROR); commit(types.RECEIVE_LASTEST_PIPELINE_ERROR);
dispatch('stopPipelinePolling');
};
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 receiveLatestPipelineSuccess = ({ commit }, pipeline) =>
commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, pipeline);
export const fetchLatestPipeline = ({ dispatch, rootState }, sha) => { export const fetchLatestPipeline = ({ dispatch, rootGetters }) => {
if (eTagPoll) return;
dispatch('requestLatestPipeline'); dispatch('requestLatestPipeline');
return Api.pipelines(rootState.currentProjectId, { sha, per_page: '1' }) eTagPoll = new Poll({
.then(({ data }) => { resource: service,
dispatch('receiveLatestPipelineSuccess', data.pop()); method: 'lastCommitPipelines',
}) data: { getters: rootGetters },
.catch(() => dispatch('receiveLatestPipelineError')); 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 requestJobs = ({ commit }, id) => commit(types.REQUEST_JOBS, id);
export const receiveJobsError = ({ commit }) => { export const receiveJobsError = ({ commit }, id) => {
flash(__('There was an error loading jobs')); 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') => { export const fetchJobs = ({ dispatch }, stage) => {
dispatch('requestJobs'); dispatch('requestJobs', stage.id);
Api.pipelineJobs(rootState.currentProjectId, state.latestPipeline.id, { axios
page, .get(stage.dropdownPath)
}) .then(({ data }) => dispatch('receiveJobsSuccess', { id: stage.id, data }))
.then(({ data, headers }) => { .catch(() => dispatch('receiveJobsError', stage.id));
const nextPage = headers && headers['x-next-page'];
dispatch('receiveJobsSuccess', data);
if (nextPage) {
dispatch('fetchJobs', nextPage);
}
})
.catch(() => dispatch('receiveJobsError'));
}; };
export const toggleStageCollapsed = ({ commit }, stageId) =>
commit(types.TOGGLE_STAGE_COLLAPSE, stageId);
export default () => {}; export default () => {};
// eslint-disable-next-line import/prefer-default-export
export const states = {
failed: 'failed',
};
import { states } from './constants';
export const hasLatestPipeline = state => !state.isLoadingPipeline && !!state.latestPipeline; 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( 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 () => {};
...@@ -5,3 +5,5 @@ export const RECEIVE_LASTEST_PIPELINE_SUCCESS = 'RECEIVE_LASTEST_PIPELINE_SUCCES ...@@ -5,3 +5,5 @@ export const RECEIVE_LASTEST_PIPELINE_SUCCESS = 'RECEIVE_LASTEST_PIPELINE_SUCCES
export const REQUEST_JOBS = 'REQUEST_JOBS'; export const REQUEST_JOBS = 'REQUEST_JOBS';
export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR'; export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR';
export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
export const TOGGLE_STAGE_COLLAPSE = 'TOGGLE_STAGE_COLLAPSE';
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import * as types from './mutation_types'; import * as types from './mutation_types';
import { normalizeJob } from './utils';
export default { export default {
[types.REQUEST_LATEST_PIPELINE](state) { [types.REQUEST_LATEST_PIPELINE](state) {
...@@ -14,40 +15,52 @@ export default { ...@@ -14,40 +15,52 @@ export default {
if (pipeline) { if (pipeline) {
state.latestPipeline = { state.latestPipeline = {
id: pipeline.id, 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) { [types.REQUEST_JOBS](state, id) {
state.isLoadingJobs = true; state.stages = state.stages.map(stage => ({
...stage,
isLoading: stage.id === id ? true : stage.isLoading,
}));
}, },
[types.RECEIVE_JOBS_ERROR](state) { [types.RECEIVE_JOBS_ERROR](state, id) {
state.isLoadingJobs = false; state.stages = state.stages.map(stage => ({
...stage,
isLoading: stage.id === id ? false : stage.isLoading,
}));
}, },
[types.RECEIVE_JOBS_SUCCESS](state, jobs) { [types.RECEIVE_JOBS_SUCCESS](state, { id, data }) {
state.isLoadingJobs = false; state.stages = state.stages.map(stage => ({
...stage,
state.stages = jobs.reduce((acc, job) => { isLoading: stage.id === id ? false : stage.isLoading,
let stage = acc.find(s => s.title === job.stage); jobs: stage.id === id ? data.latest_statuses.map(normalizeJob) : stage.jobs,
}));
if (!stage) { },
stage = { [types.TOGGLE_STAGE_COLLAPSE](state, id) {
title: job.stage, state.stages = state.stages.map(stage => ({
jobs: [], ...stage,
}; isCollapsed: stage.id === id ? !stage.isCollapsed : stage.isCollapsed,
}));
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);
}, },
}; };
export default () => ({ export default () => ({
isLoadingPipeline: false, isLoadingPipeline: true,
isLoadingJobs: false, isLoadingJobs: false,
latestPipeline: null, latestPipeline: null,
stages: [], stages: [],
......
// 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,
});
...@@ -6,6 +6,7 @@ export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED'; ...@@ -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_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS'; export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS'; export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS';
export const SET_LINKS = 'SET_LINKS';
// Project Mutation Types // Project Mutation Types
export const SET_PROJECT = 'SET_PROJECT'; export const SET_PROJECT = 'SET_PROJECT';
...@@ -23,7 +24,6 @@ export const SET_BRANCH = 'SET_BRANCH'; ...@@ -23,7 +24,6 @@ export const SET_BRANCH = 'SET_BRANCH';
export const SET_BRANCH_COMMIT = 'SET_BRANCH_COMMIT'; export const SET_BRANCH_COMMIT = 'SET_BRANCH_COMMIT';
export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE';
export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN';
export const SET_LAST_COMMIT_PIPELINE = 'SET_LAST_COMMIT_PIPELINE';
// Tree mutation types // Tree mutation types
export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
...@@ -66,3 +66,5 @@ export const UPDATE_ACTIVITY_BAR_VIEW = 'UPDATE_ACTIVITY_BAR_VIEW'; ...@@ -66,3 +66,5 @@ export const UPDATE_ACTIVITY_BAR_VIEW = 'UPDATE_ACTIVITY_BAR_VIEW';
export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG'; export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER'; export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL'; export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL';
export const SET_RIGHT_PANE = 'SET_RIGHT_PANE';
...@@ -114,12 +114,13 @@ export default { ...@@ -114,12 +114,13 @@ export default {
}, },
[types.SET_EMPTY_STATE_SVGS]( [types.SET_EMPTY_STATE_SVGS](
state, state,
{ emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath }, { emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath },
) { ) {
Object.assign(state, { Object.assign(state, {
emptyStateSvgPath, emptyStateSvgPath,
noChangesStateSvgPath, noChangesStateSvgPath,
committedStateSvgPath, committedStateSvgPath,
pipelinesEmptyStateSvgPath,
}); });
}, },
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) { [types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
...@@ -148,6 +149,14 @@ export default { ...@@ -148,6 +149,14 @@ export default {
unusedSeal: false, 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, ...projectMutations,
...mergeRequestMutation, ...mergeRequestMutation,
...fileMutations, ...fileMutations,
......
...@@ -14,10 +14,6 @@ export default { ...@@ -14,10 +14,6 @@ export default {
treeId: `${projectPath}/${branchName}`, treeId: `${projectPath}/${branchName}`,
active: true, active: true,
workingReference: '', workingReference: '',
commit: {
...branch.commit,
pipeline: {},
},
}, },
}, },
}); });
...@@ -32,9 +28,4 @@ export default { ...@@ -32,9 +28,4 @@ export default {
commit, commit,
}); });
}, },
[types.SET_LAST_COMMIT_PIPELINE](state, { projectId, branchId, pipeline }) {
Object.assign(state.projects[projectId].branches[branchId].commit, {
pipeline,
});
},
}; };
...@@ -23,4 +23,6 @@ export default () => ({ ...@@ -23,4 +23,6 @@ export default () => ({
currentActivityView: activityBarViews.edit, currentActivityView: activityBarViews.edit,
unusedSeal: true, unusedSeal: true,
fileFindVisible: false, fileFindVisible: false,
rightPane: null,
links: {},
}); });
...@@ -22,6 +22,8 @@ import Icon from '../../vue_shared/components/icon.vue'; ...@@ -22,6 +22,8 @@ import Icon from '../../vue_shared/components/icon.vue';
* - Jobs show view header * - Jobs show view header
* - Jobs show view sidebar * - Jobs show view sidebar
*/ */
const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
export default { export default {
components: { components: {
Icon, Icon,
...@@ -31,17 +33,36 @@ export default { ...@@ -31,17 +33,36 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
size: {
type: Number,
required: false,
default: 16,
validator(value) {
return validSizes.includes(value);
},
},
borderless: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
cssClass() { cssClass() {
const status = this.status.group; const status = this.status.group;
return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; 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> </script>
<template> <template>
<span :class="cssClass"> <span :class="cssClass">
<icon :name="status.icon" /> <icon
:name="icon"
:size="size"
/>
</span> </span>
</template> </template>
<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>
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]);
},
};
...@@ -192,6 +192,10 @@ ...@@ -192,6 +192,10 @@
&.active { &.active {
color: $color-700; color: $color-700;
box-shadow: inset 3px 0 $color-700; box-shadow: inset 3px 0 $color-700;
&.is-right {
box-shadow: inset -3px 0 $color-700;
}
} }
} }
} }
......
...@@ -909,6 +909,16 @@ ...@@ -909,6 +909,16 @@
width: 1px; width: 1px;
background: $white-light; background: $white-light;
} }
&.is-right {
padding-right: $gl-padding;
padding-left: $gl-padding + 1px;
&::after {
right: auto;
left: -1px;
}
}
} }
} }
...@@ -1121,3 +1131,112 @@ ...@@ -1121,3 +1131,112 @@
white-space: nowrap; 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;
}
...@@ -3,7 +3,9 @@ ...@@ -3,7 +3,9 @@
#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), #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'), "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 .text-center
= icon('spinner spin 2x') = icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...') %h2.clgray= _('Loading the GitLab IDE...')
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);
});
});
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]]);
});
});
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);
});
});
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();
});
});
});
});
import { decorateData } from '~/ide/stores/utils'; import { decorateData } from '~/ide/stores/utils';
import state from '~/ide/stores/state'; import state from '~/ide/stores/state';
import commitState from '~/ide/stores/modules/commit/state'; import commitState from '~/ide/stores/modules/commit/state';
import pipelinesState from '~/ide/stores/modules/pipelines/state';
export const resetStore = store => { export const resetStore = store => {
const newState = { const newState = {
...state(), ...state(),
commit: commitState(), commit: commitState(),
pipelines: pipelinesState(),
}; };
store.replaceState(newState); store.replaceState(newState);
}; };
......
...@@ -19,13 +19,48 @@ export const pipelines = [ ...@@ -19,13 +19,48 @@ export const pipelines = [
id: 1, id: 1,
ref: 'master', ref: 'master',
sha: '123', sha: '123',
status: 'failed', details: {
status: {
icon: 'status_failed',
group: 'failed',
text: 'Failed',
},
},
commit: { id: '123' },
}, },
{ {
id: 2, id: 2,
ref: 'master', ref: 'master',
sha: '213', 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 = [ ...@@ -33,28 +68,44 @@ export const jobs = [
{ {
id: 1, id: 1,
name: 'test', name: 'test',
status: 'failed', path: 'testing',
status: {
icon: 'status_passed',
text: 'passed',
},
stage: 'test', stage: 'test',
duration: 1, duration: 1,
}, },
{ {
id: 2, id: 2,
name: 'test 2', name: 'test 2',
status: 'failed', path: 'testing2',
status: {
icon: 'status_passed',
text: 'passed',
},
stage: 'test', stage: 'test',
duration: 1, duration: 1,
}, },
{ {
id: 3, id: 3,
name: 'test 3', name: 'test 3',
status: 'failed', path: 'testing3',
status: {
icon: 'status_passed',
text: 'passed',
},
stage: 'test', stage: 'test',
duration: 1, duration: 1,
}, },
{ {
id: 4, id: 4,
name: 'test 3', name: 'test 4',
status: 'failed', path: 'testing4',
status: {
icon: 'status_failed',
text: 'failed',
},
stage: 'build', stage: 'build',
duration: 1, duration: 1,
}, },
...@@ -68,14 +119,16 @@ export const fullPipelinesResponse = { ...@@ -68,14 +119,16 @@ export const fullPipelinesResponse = {
pipelines: [ pipelines: [
{ {
id: '51', id: '51',
path: 'test',
commit: { commit: {
id: 'xxxxxxxxxxxxxxxxxxxx', id: '123',
}, },
details: { details: {
status: { status: {
icon: 'status_failed', icon: 'status_failed',
text: 'failed', text: 'failed',
}, },
stages: [...stages],
}, },
}, },
{ {
...@@ -88,6 +141,7 @@ export const fullPipelinesResponse = { ...@@ -88,6 +141,7 @@ export const fullPipelinesResponse = {
icon: 'status_passed', icon: 'status_passed',
text: 'passed', text: 'passed',
}, },
stages: [...stages],
}, },
}, },
], ],
......
import Visibility from 'visibilityjs'; import { refreshLastCommitData } from '~/ide/stores/actions';
import MockAdapter from 'axios-mock-adapter';
import { refreshLastCommitData, pollSuccessCallBack } from '~/ide/stores/actions';
import store from '~/ide/stores'; import store from '~/ide/stores';
import service from '~/ide/services'; import service from '~/ide/services';
import axios from '~/lib/utils/axios_utils';
import { fullPipelinesResponse } from '../../mock_data';
import { resetStore } from '../../helpers'; import { resetStore } from '../../helpers';
import testAction from '../../../helpers/vuex_action_helper'; import testAction from '../../../helpers/vuex_action_helper';
describe('IDE store project actions', () => { 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(() => { beforeEach(() => {
store.state.projects['abc/def'] = {}; store.state.projects['abc/def'] = {};
}); });
...@@ -101,92 +80,4 @@ describe('IDE store project actions', () => { ...@@ -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,
);
});
});
}); });
...@@ -37,35 +37,4 @@ describe('IDE pipeline getters', () => { ...@@ -37,35 +37,4 @@ describe('IDE pipeline getters', () => {
expect(getters.hasLatestPipeline(mockedState)).toBe(true); 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(),
},
]);
});
});
}); });
import mutations from '~/ide/stores/modules/pipelines/mutations'; import mutations from '~/ide/stores/modules/pipelines/mutations';
import state from '~/ide/stores/modules/pipelines/state'; import state from '~/ide/stores/modules/pipelines/state';
import * as types from '~/ide/stores/modules/pipelines/mutation_types'; 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', () => { describe('IDE pipelines mutations', () => {
let mockedState; let mockedState;
...@@ -28,93 +28,147 @@ describe('IDE pipelines mutations', () => { ...@@ -28,93 +28,147 @@ describe('IDE pipelines mutations', () => {
describe(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, () => { describe(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, () => {
it('sets loading to false on 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); expect(mockedState.isLoadingPipeline).toBe(false);
}); });
it('sets latestPipeline', () => { 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({ expect(mockedState.latestPipeline).toEqual({
id: pipelines[0].id, id: '51',
status: pipelines[0].status, path: 'test',
commit: { id: '123' },
details: { status: jasmine.any(Object) },
yamlError: undefined,
}); });
}); });
it('does not set latest pipeline if pipeline is null', () => { it('does not set latest pipeline if pipeline is null', () => {
mutations[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](mockedState, null); mutations[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](mockedState, null);
expect(mockedState.latestPipeline).toEqual(null); expect(mockedState.latestPipeline).toEqual(false);
});
it('sets stages', () => {
mutations[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](
mockedState,
fullPipelinesResponse.data.pipelines[0],
);
expect(mockedState.stages.length).toBe(2);
expect(mockedState.stages).toEqual([
{
id: 0,
dropdownPath: stages[0].dropdown_path,
name: stages[0].name,
status: stages[0].status,
isCollapsed: false,
isLoading: false,
jobs: [],
},
{
id: 1,
dropdownPath: stages[1].dropdown_path,
name: stages[1].name,
status: stages[1].status,
isCollapsed: false,
isLoading: false,
jobs: [],
},
]);
}); });
}); });
describe(types.REQUEST_JOBS, () => { describe(types.REQUEST_JOBS, () => {
it('sets jobs loading to true', () => { beforeEach(() => {
mutations[types.REQUEST_JOBS](mockedState); 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.isLoadingJobs).toBe(true); expect(mockedState.stages[0].isLoading).toBe(true);
}); });
}); });
describe(types.RECEIVE_JOBS_ERROR, () => { describe(types.RECEIVE_JOBS_ERROR, () => {
it('sets jobs loading to false', () => { beforeEach(() => {
mutations[types.RECEIVE_JOBS_ERROR](mockedState); 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.isLoadingJobs).toBe(false); expect(mockedState.stages[0].isLoading).toBe(false);
}); });
}); });
describe(types.RECEIVE_JOBS_SUCCESS, () => { describe(types.RECEIVE_JOBS_SUCCESS, () => {
it('sets jobs loading to false on success', () => { let data;
mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, jobs);
expect(mockedState.isLoadingJobs).toBe(false); beforeEach(() => {
mockedState.stages = stages.map((stage, i) => ({
...stage,
id: i,
}));
data = {
latest_statuses: [...jobs],
};
}); });
it('sets stages', () => { it('updates loading', () => {
mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, jobs); mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, { id: mockedState.stages[0].id, data });
expect(mockedState.stages.length).toBe(2); expect(mockedState.stages[0].isLoading).toBe(false);
expect(mockedState.stages).toEqual([
{
title: 'test',
jobs: jasmine.anything(),
},
{
title: 'build',
jobs: jasmine.anything(),
},
]);
}); });
it('sets jobs in stages', () => { it('sets jobs on stage', () => {
mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, jobs); 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,
})),
);
});
});
expect(mockedState.stages[0].jobs.length).toBe(3); describe(types.TOGGLE_STAGE_COLLAPSE, () => {
expect(mockedState.stages[1].jobs.length).toBe(1); beforeEach(() => {
expect(mockedState.stages).toEqual([ mockedState.stages = stages.map((stage, i) => ({
{ ...stage,
title: jasmine.anything(), id: i,
jobs: jobs.filter(job => job.stage === 'test').map(job => ({ isCollapsed: false,
id: job.id, }));
name: job.name, });
status: job.status,
stage: job.stage, it('toggles collapsed state', () => {
duration: job.duration, mutations[types.TOGGLE_STAGE_COLLAPSE](mockedState, mockedState.stages[0].id);
})),
}, expect(mockedState.stages[0].isCollapsed).toBe(true);
{
title: jasmine.anything(), mutations[types.TOGGLE_STAGE_COLLAPSE](mockedState, mockedState.stages[0].id);
jobs: jobs.filter(job => job.stage === 'build').map(job => ({
id: job.id, expect(mockedState.stages[0].isCollapsed).toBe(false);
name: job.name,
status: job.status,
stage: job.stage,
duration: job.duration,
})),
},
]);
}); });
}); });
}); });
...@@ -37,40 +37,4 @@ describe('Multi-file store branch mutations', () => { ...@@ -37,40 +37,4 @@ describe('Multi-file store branch mutations', () => {
expect(localState.projects.Example.branches.master.commit.title).toBe('Example commit'); 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',
);
});
});
}); });
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();
});
});
});
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');
});
});
});
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