summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2018-06-01 08:45:35 +0000
committerFilipa Lacerda <filipa@gitlab.com>2018-06-01 08:45:35 +0000
commit5b9e4d986a70b0dd4088598c500fd0cd9e85f25e (patch)
tree5a89d79a60b547835bda6451deaca2bb434343a6
parente12471862bd2c29d4e63dc4cbeece9d9008ef4a0 (diff)
parent745d353801ce8a961c010edc02925fa8470158fd (diff)
downloadgitlab-ce-5b9e4d986a70b0dd4088598c500fd0cd9e85f25e.tar.gz
Merge branch 'ide-jobs-list-components' into 'master'
Show CI jobs in IDE Closes #44604 See merge request gitlab-org/gitlab-ce!19106
-rw-r--r--app/assets/javascripts/api.js16
-rw-r--r--app/assets/javascripts/ide/components/ide.vue6
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue15
-rw-r--r--app/assets/javascripts/ide/components/jobs/item.vue46
-rw-r--r--app/assets/javascripts/ide/components/jobs/list.vue44
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue108
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue65
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue146
-rw-r--r--app/assets/javascripts/ide/constants.js4
-rw-r--r--app/assets/javascripts/ide/ide_router.js2
-rw-r--r--app/assets/javascripts/ide/index.js10
-rw-r--r--app/assets/javascripts/ide/stores/actions.js6
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js62
-rw-r--r--app/assets/javascripts/ide/stores/index.js23
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/actions.js89
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/constants.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/getters.js21
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/mutations.js73
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/state.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/utils.js7
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js11
-rw-r--r--app/assets/javascripts/ide/stores/mutations/branch.js9
-rw-r--r--app/assets/javascripts/ide/stores/state.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/tabs/tab.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/tabs/tabs.js64
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss4
-rw-r--r--app/assets/stylesheets/pages/repo.scss119
-rw-r--r--app/views/ide/index.html.haml4
-rw-r--r--spec/javascripts/ide/components/jobs/item_spec.js29
-rw-r--r--spec/javascripts/ide/components/jobs/list_spec.js67
-rw-r--r--spec/javascripts/ide/components/jobs/stage_spec.js95
-rw-r--r--spec/javascripts/ide/components/pipelines/list_spec.js117
-rw-r--r--spec/javascripts/ide/helpers.js2
-rw-r--r--spec/javascripts/ide/mock_data.js70
-rw-r--r--spec/javascripts/ide/stores/actions/project_spec.js111
-rw-r--r--spec/javascripts/ide/stores/modules/pipelines/actions_spec.js265
-rw-r--r--spec/javascripts/ide/stores/modules/pipelines/getters_spec.js31
-rw-r--r--spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js162
-rw-r--r--spec/javascripts/ide/stores/mutations/branch_spec.js36
-rw-r--r--spec/javascripts/vue_shared/components/tabs/tab_spec.js32
-rw-r--r--spec/javascripts/vue_shared/components/tabs/tabs_spec.js68
44 files changed, 1571 insertions, 547 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index ce1069276ab..47bf55b1c23 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -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) {
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 2c184ea726c..f5f832521c5 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 6f60cfbf184..368a2995ed9 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue
new file mode 100644
index 00000000000..c33936021d4
--- /dev/null
+++ b/app/assets/javascripts/ide/components/jobs/item.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue
new file mode 100644
index 00000000000..bdd0364c9b9
--- /dev/null
+++ b/app/assets/javascripts/ide/components/jobs/list.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
new file mode 100644
index 00000000000..5b24bb1f5a7
--- /dev/null
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
new file mode 100644
index 00000000000..703c4a70cfa
--- /dev/null
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
new file mode 100644
index 00000000000..06455fac439
--- /dev/null
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 83fe22f40a4..33cd20caf52 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -20,3 +20,7 @@ export const viewerTypes = {
edit: 'editor',
diff: 'diff',
};
+
+export const rightSidebarViews = {
+ pipelines: 'pipelines-list',
+};
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index a21cec4e8d8..b52618f4fde 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -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) {
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index c5835cd3b06..2d74192e6b3 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -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');
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 1a98b42761e..3dc365eaead 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -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';
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 75cfd9946d7..46af47d2f81 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -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();
-};
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
index 699710055e3..5d26cb8d626 100644
--- a/app/assets/javascripts/ide/stores/index.js
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -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();
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
index 07f7b201f2e..1ebe487263b 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
@@ -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 = ({ 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');
- 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 () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/constants.js b/app/assets/javascripts/ide/stores/modules/pipelines/constants.js
new file mode 100644
index 00000000000..f5b96327e40
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/constants.js
@@ -0,0 +1,4 @@
+// eslint-disable-next-line import/prefer-default-export
+export const states = {
+ failed: 'failed',
+};
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js
index d6c91f5b64d..f545453806f 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js
@@ -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 () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js
index 6b5701670a6..3ddc8409c5b 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js
@@ -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';
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
index 2b16e57b386..745797e1ee5 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
@@ -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,
+ }));
},
};
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/state.js b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
index 6f22542aaea..0f83b315fff 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/state.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
@@ -1,5 +1,5 @@
export default () => ({
- isLoadingPipeline: false,
+ isLoadingPipeline: true,
isLoadingJobs: false,
latestPipeline: null,
stages: [],
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/utils.js b/app/assets/javascripts/ide/stores/modules/pipelines/utils.js
new file mode 100644
index 00000000000..9f4b0d7d726
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/utils.js
@@ -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,
+});
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 0a3f8d031c4..fbfb92105d6 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -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';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index a257e2ef025..eeaa7cb0ec3 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -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,
diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js
index f17ec4da308..e09f88878f4 100644
--- a/app/assets/javascripts/ide/stores/mutations/branch.js
+++ b/app/assets/javascripts/ide/stores/mutations/branch.js
@@ -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,
- });
- },
};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index e7411f16a4f..4aac4696075 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -23,4 +23,6 @@ export default () => ({
currentActivityView: activityBarViews.edit,
unusedSeal: true,
fileFindVisible: false,
+ rightPane: null,
+ links: {},
});
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index fcab8f571dd..03f924ba99d 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -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>
diff --git a/app/assets/javascripts/vue_shared/components/tabs/tab.vue b/app/assets/javascripts/vue_shared/components/tabs/tab.vue
new file mode 100644
index 00000000000..2a35d6bc151
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/tabs/tab.vue
@@ -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>
diff --git a/app/assets/javascripts/vue_shared/components/tabs/tabs.js b/app/assets/javascripts/vue_shared/components/tabs/tabs.js
new file mode 100644
index 00000000000..4362264caa5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/tabs/tabs.js
@@ -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]);
+ },
+};
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
index 3bbb50117bc..11e21edfc1b 100644
--- a/app/assets/stylesheets/framework/gitlab_theme.scss
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -192,6 +192,10 @@
&.active {
color: $color-700;
box-shadow: inset 3px 0 $color-700;
+
+ &.is-right {
+ box-shadow: inset -3px 0 $color-700;
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index d5c6048037a..6bbcb15329c 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -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;
+}
diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml
index da9331b45dd..9f8b0acd763 100644
--- a/app/views/ide/index.html.haml
+++ b/app/views/ide/index.html.haml
@@ -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...')
diff --git a/spec/javascripts/ide/components/jobs/item_spec.js b/spec/javascripts/ide/components/jobs/item_spec.js
new file mode 100644
index 00000000000..7c1dd4e475c
--- /dev/null
+++ b/spec/javascripts/ide/components/jobs/item_spec.js
@@ -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);
+ });
+});
diff --git a/spec/javascripts/ide/components/jobs/list_spec.js b/spec/javascripts/ide/components/jobs/list_spec.js
new file mode 100644
index 00000000000..b24853c56fa
--- /dev/null
+++ b/spec/javascripts/ide/components/jobs/list_spec.js
@@ -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]]);
+ });
+});
diff --git a/spec/javascripts/ide/components/jobs/stage_spec.js b/spec/javascripts/ide/components/jobs/stage_spec.js
new file mode 100644
index 00000000000..fc3831f2d05
--- /dev/null
+++ b/spec/javascripts/ide/components/jobs/stage_spec.js
@@ -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);
+ });
+});
diff --git a/spec/javascripts/ide/components/pipelines/list_spec.js b/spec/javascripts/ide/components/pipelines/list_spec.js
new file mode 100644
index 00000000000..2bb5aa08c3b
--- /dev/null
+++ b/spec/javascripts/ide/components/pipelines/list_spec.js
@@ -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();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js
index 98db6defc7a..5c7e2db0e96 100644
--- a/spec/javascripts/ide/helpers.js
+++ b/spec/javascripts/ide/helpers.js
@@ -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);
};
diff --git a/spec/javascripts/ide/mock_data.js b/spec/javascripts/ide/mock_data.js
index c68ae050641..8ad51e122b6 100644
--- a/spec/javascripts/ide/mock_data.js
+++ b/spec/javascripts/ide/mock_data.js
@@ -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],
},
},
],
diff --git a/spec/javascripts/ide/stores/actions/project_spec.js b/spec/javascripts/ide/stores/actions/project_spec.js
index 8e078ae7138..d71fc0e035e 100644
--- a/spec/javascripts/ide/stores/actions/project_spec.js
+++ b/spec/javascripts/ide/stores/actions/project_spec.js
@@ -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,
- );
- });
- });
});
diff --git a/spec/javascripts/ide/stores/modules/pipelines/actions_spec.js b/spec/javascripts/ide/stores/modules/pipelines/actions_spec.js
index 85fbcf8084b..f26eaf9c81f 100644
--- a/spec/javascripts/ide/stores/modules/pipelines/actions_spec.js
+++ b/spec/javascripts/ide/stores/modules/pipelines/actions_spec.js
@@ -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,
- );
- });
-
- it('dispatches success with latest pipeline', done => {
- testAction(
- fetchLatestPipeline,
- '123',
- mockedState,
- [],
- [
- { type: 'requestLatestPipeline' },
- { type: 'receiveLatestPipelineSuccess', payload: pipelines[0] },
- ],
- done,
- );
- });
-
- it('calls axios with correct params', () => {
- const apiSpy = spyOn(axios, 'get').and.callThrough();
-
- fetchLatestPipeline({ dispatch() {}, rootState: state }, '123');
-
- expect(apiSpy).toHaveBeenCalledWith(jasmine.anything(), {
- params: {
- sha: '123',
- per_page: '1',
- },
- });
+ spyOn(axios, 'get').and.callThrough();
+ spyOn(Visibility, 'hidden').and.returnValue(false);
+
+ const dispatch = jasmine.createSpy('dispatch');
+ const rootGetters = {
+ lastCommit: { id: 'abc123def456ghi789jkl' },
+ currentProject: { path_with_namespace: 'abc/def' },
+ };
+
+ fetchLatestPipeline({ dispatch, rootGetters });
+
+ expect(dispatch.calls.argsFor(0)).toEqual(['requestLatestPipeline']);
+
+ 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,
+ );
+ });
+ });
});
diff --git a/spec/javascripts/ide/stores/modules/pipelines/getters_spec.js b/spec/javascripts/ide/stores/modules/pipelines/getters_spec.js
index b2a7e8a9025..4514896b5ea 100644
--- a/spec/javascripts/ide/stores/modules/pipelines/getters_spec.js
+++ b/spec/javascripts/ide/stores/modules/pipelines/getters_spec.js
@@ -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(),
- },
- ]);
- });
- });
});
diff --git a/spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js b/spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js
index 8262e916243..6285c01d483 100644
--- a/spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js
+++ b/spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js
@@ -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);
+ 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, () => {
- it('sets jobs loading to true', () => {
- mutations[types.REQUEST_JOBS](mockedState);
+ 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.isLoadingJobs).toBe(true);
+ expect(mockedState.stages[0].isLoading).toBe(true);
});
});
describe(types.RECEIVE_JOBS_ERROR, () => {
- it('sets jobs loading to false', () => {
- mutations[types.RECEIVE_JOBS_ERROR](mockedState);
+ 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.isLoadingJobs).toBe(false);
+ expect(mockedState.stages[0].isLoading).toBe(false);
});
});
describe(types.RECEIVE_JOBS_SUCCESS, () => {
- it('sets jobs loading to false on success', () => {
- mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, jobs);
+ let data;
- expect(mockedState.isLoadingJobs).toBe(false);
+ beforeEach(() => {
+ mockedState.stages = stages.map((stage, i) => ({
+ ...stage,
+ id: i,
+ }));
+
+ data = {
+ latest_statuses: [...jobs],
+ };
});
- it('sets stages', () => {
- mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, jobs);
+ it('updates loading', () => {
+ mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, { id: mockedState.stages[0].id, data });
- expect(mockedState.stages.length).toBe(2);
- expect(mockedState.stages).toEqual([
- {
- title: 'test',
- jobs: jasmine.anything(),
- },
- {
- title: 'build',
- jobs: jasmine.anything(),
- },
- ]);
+ expect(mockedState.stages[0].isLoading).toBe(false);
});
- it('sets jobs in stages', () => {
- mutations[types.RECEIVE_JOBS_SUCCESS](mockedState, jobs);
+ 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,
+ })),
+ );
+ });
+ });
- 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,
- })),
- },
- ]);
+ 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);
});
});
});
diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js
index f2f1f2a9a2e..29eb859ddaf 100644
--- a/spec/javascripts/ide/stores/mutations/branch_spec.js
+++ b/spec/javascripts/ide/stores/mutations/branch_spec.js
@@ -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',
- );
- });
- });
});
diff --git a/spec/javascripts/vue_shared/components/tabs/tab_spec.js b/spec/javascripts/vue_shared/components/tabs/tab_spec.js
new file mode 100644
index 00000000000..8437fe37738
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/tabs/tab_spec.js
@@ -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();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/tabs/tabs_spec.js b/spec/javascripts/vue_shared/components/tabs/tabs_spec.js
new file mode 100644
index 00000000000..50ba18cd338
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/tabs/tabs_spec.js
@@ -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');
+ });
+ });
+});