diff options
-rw-r--r-- | app/assets/javascripts/api.js | 14 | ||||
-rw-r--r-- | app/assets/javascripts/ide/components/ide.vue | 4 | ||||
-rw-r--r-- | app/assets/javascripts/ide/components/ide_status_bar.vue | 37 | ||||
-rw-r--r-- | app/assets/javascripts/ide/services/index.js | 4 | ||||
-rw-r--r-- | app/assets/javascripts/ide/stores/actions/project.js | 96 | ||||
-rw-r--r-- | app/assets/javascripts/ide/stores/mutation_types.js | 1 | ||||
-rw-r--r-- | app/assets/javascripts/ide/stores/mutations/branch.js | 9 | ||||
-rw-r--r-- | app/assets/stylesheets/framework/variables.scss | 2 | ||||
-rw-r--r-- | app/assets/stylesheets/pages/repo.scss | 12 | ||||
-rw-r--r-- | changelogs/unreleased/44579-ide-add-pipeline-to-status-bar.yml | 5 | ||||
-rw-r--r-- | spec/javascripts/api_spec.js | 21 | ||||
-rw-r--r-- | spec/javascripts/ide/mock_data.js | 34 | ||||
-rw-r--r-- | spec/javascripts/ide/stores/actions/project_spec.js | 159 | ||||
-rw-r--r-- | spec/javascripts/ide/stores/mutations/branch_spec.js | 36 |
14 files changed, 386 insertions, 48 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index eb919241318..ce1069276ab 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -21,6 +21,7 @@ const Api = { issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', usersPath: '/api/:version/users.json', commitPath: '/api/:version/projects/:id/repository/commits', + 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', @@ -166,6 +167,19 @@ const Api = { }); }, + commitPipelines(projectId, sha) { + const encodedProjectId = projectId + .split('/') + .map(fragment => encodeURIComponent(fragment)) + .join('/'); + + const url = Api.buildUrl(Api.commitPipelinesPath) + .replace(':project_id', encodedProjectId) + .replace(':sha', encodeURIComponent(sha)); + + return axios.get(url); + }, + branchSingle(id, branch) { const url = Api.buildUrl(Api.branchSinglePath) .replace(':id', encodeURIComponent(id)) diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 1ec69adce09..0aaf5a112cb 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -123,8 +123,6 @@ export default { </template> </div> </div> - <ide-status-bar - :file="activeFile" - /> + <ide-status-bar :file="activeFile"/> </article> </template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 70c6d53c3ab..6f60cfbf184 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,14 +1,16 @@ <script> -import { mapGetters } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; +import CiIcon from '../../vue_shared/components/ci_icon.vue'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; export default { components: { icon, userAvatarImage, + CiIcon, }, directives: { tooltip, @@ -27,8 +29,16 @@ export default { }; }, computed: { + ...mapState(['currentBranchId', 'currentProjectId']), ...mapGetters(['currentProject', 'lastCommit']), }, + watch: { + lastCommit() { + if (!this.isPollingInitialized) { + this.initPipelinePolling(); + } + }, + }, mounted() { this.startTimer(); }, @@ -36,13 +46,21 @@ export default { if (this.intervalId) { clearInterval(this.intervalId); } + if (this.isPollingInitialized) { + this.stopPipelinePolling(); + } }, methods: { + ...mapActions(['pipelinePoll', 'stopPipelinePolling']), startTimer() { this.intervalId = setInterval(() => { this.commitAgeUpdate(); }, 1000); }, + initPipelinePolling() { + this.pipelinePoll(); + this.isPollingInitialized = true; + }, commitAgeUpdate() { if (this.lastCommit) { this.lastCommitFormatedAge = this.timeFormated(this.lastCommit.committed_date); @@ -61,6 +79,23 @@ export default { class="ide-status-branch" v-if="lastCommit && lastCommitFormatedAge" > + <span + class="ide-status-pipeline" + v-if="lastCommit.pipeline && lastCommit.pipeline.details" + > + <ci-icon + :status="lastCommit.pipeline.details.status" + v-tooltip + :title="lastCommit.pipeline.details.status.text" + /> + Pipeline + <a + class="monospace" + :href="lastCommit.pipeline.details.status.details_path">#{{ lastCommit.pipeline.id }}</a> + {{ lastCommit.pipeline.details.status.text }} + for + </span> + <icon name="commit" /> diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index a12e637616a..e8b51f2b516 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -75,4 +75,8 @@ export default { }, }); }, + lastCommitPipelines({ getters }) { + const commitSha = getters.lastCommit.id; + return Api.commitPipelines(getters.currentProject.path_with_namespace, commitSha); + }, }; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index eff9bc03651..cece9154c82 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -1,6 +1,11 @@ +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, dispatch }, @@ -21,7 +26,7 @@ export const getProjectData = ( }) .catch(() => { flash( - 'Error loading project data. Please try again.', + __('Error loading project data. Please try again.'), 'alert', document, null, @@ -59,7 +64,7 @@ export const getBranchData = ( }) .catch(() => { flash( - 'Error loading branch data. Please try again.', + __('Error loading branch data. Please try again.'), 'alert', document, null, @@ -73,25 +78,74 @@ export const getBranchData = ( } }); -export const refreshLastCommitData = ( - { commit, state, dispatch }, - { projectId, branchId } = {}, -) => service - .getBranchData(projectId, branchId) - .then(({ data }) => { - commit(types.SET_BRANCH_COMMIT, { - projectId, - branchId, - commit: data.commit, +export const refreshLastCommitData = ({ commit, state, dispatch }, { projectId, branchId } = {}) => + service + .getBranchData(projectId, branchId) + .then(({ data }) => { + commit(types.SET_BRANCH_COMMIT, { + projectId, + branchId, + commit: data.commit, + }); + }) + .catch(() => { + flash(__('Error loading last commit.'), 'alert', document, null, false, true); }); - }) - .catch(() => { - flash( - 'Error loading last commit.', - 'alert', - document, - null, - false, - true, + +export const pollSuccessCallBack = ({ commit, state, dispatch }, { 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/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index a3fb3232f1d..0a3f8d031c4 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -23,6 +23,7 @@ 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'; diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js index e09f88878f4..f17ec4da308 100644 --- a/app/assets/javascripts/ide/stores/mutations/branch.js +++ b/app/assets/javascripts/ide/stores/mutations/branch.js @@ -14,6 +14,10 @@ export default { treeId: `${projectPath}/${branchName}`, active: true, workingReference: '', + commit: { + ...branch.commit, + pipeline: {}, + }, }, }, }); @@ -28,4 +32,9 @@ 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/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index b8ea8ee14c5..86f82ec14ce 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -230,7 +230,7 @@ $row-hover: $blue-50; $row-hover-border: $blue-200; $progress-color: #c0392b; $header-height: 40px; -$ide-statusbar-height: 27px; +$ide-statusbar-height: 25px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; $limited-layout-width-sm: 790px; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 175d2779bb7..35985d3c8f3 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -22,7 +22,6 @@ height: calc(100vh - #{$header-height}); margin-top: 0; border-top: 1px solid $white-dark; - border-bottom: 1px solid $white-dark; padding-bottom: $ide-statusbar-height; &.is-collapsed { @@ -380,7 +379,7 @@ .ide-status-bar { border-top: 1px solid $white-dark; - padding: $gl-bar-padding $gl-padding; + padding: 2px $gl-padding-8 0; background: $white-light; display: flex; justify-content: space-between; @@ -391,12 +390,19 @@ left: 0; width: 100%; + font-size: 12px; + line-height: 22px; + + * { + font-size: inherit; + } + > div + div { padding-left: $gl-padding; } svg { - vertical-align: middle; + vertical-align: sub; } } diff --git a/changelogs/unreleased/44579-ide-add-pipeline-to-status-bar.yml b/changelogs/unreleased/44579-ide-add-pipeline-to-status-bar.yml new file mode 100644 index 00000000000..21e7c795815 --- /dev/null +++ b/changelogs/unreleased/44579-ide-add-pipeline-to-status-bar.yml @@ -0,0 +1,5 @@ +--- +title: Add pipeline status to the status bar of the Web IDE +merge_request: +author: +type: added diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 3d7ccf432be..e8435116221 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -341,4 +341,25 @@ describe('Api', () => { .catch(done.fail); }); }); + + describe('commitPipelines', () => { + it('fetches pipelines for a given commit', done => { + const projectId = 'example/foobar'; + const commitSha = 'abc123def'; + const expectedUrl = `${dummyUrlRoot}/${projectId}/commit/${commitSha}/pipelines`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.commitPipelines(projectId, commitSha) + .then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/ide/mock_data.js b/spec/javascripts/ide/mock_data.js index 7e641c7984b..c68ae050641 100644 --- a/spec/javascripts/ide/mock_data.js +++ b/spec/javascripts/ide/mock_data.js @@ -59,3 +59,37 @@ export const jobs = [ duration: 1, }, ]; + +export const fullPipelinesResponse = { + data: { + count: { + all: 2, + }, + pipelines: [ + { + id: '51', + commit: { + id: 'xxxxxxxxxxxxxxxxxxxx', + }, + details: { + status: { + icon: 'status_failed', + text: 'failed', + }, + }, + }, + { + id: '50', + commit: { + id: 'abc123def456ghi789jkl', + }, + details: { + status: { + icon: 'status_passed', + text: 'passed', + }, + }, + }, + ], + }, +}; diff --git a/spec/javascripts/ide/stores/actions/project_spec.js b/spec/javascripts/ide/stores/actions/project_spec.js index ebd08d95810..8e078ae7138 100644 --- a/spec/javascripts/ide/stores/actions/project_spec.js +++ b/spec/javascripts/ide/stores/actions/project_spec.js @@ -1,14 +1,33 @@ -import { - refreshLastCommitData, -} from '~/ide/stores/actions'; +import Visibility from 'visibilityjs'; +import MockAdapter from 'axios-mock-adapter'; +import { refreshLastCommitData, pollSuccessCallBack } 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.abcproject = {}; + store.state.projects['abc/def'] = {}; }); afterEach(() => { @@ -17,18 +36,16 @@ describe('IDE store project actions', () => { describe('refreshLastCommitData', () => { beforeEach(() => { - store.state.currentProjectId = 'abcproject'; + store.state.currentProjectId = 'abc/def'; store.state.currentBranchId = 'master'; - store.state.projects.abcproject = { + store.state.projects['abc/def'] = { + id: 4, branches: { master: { commit: null, }, }, }; - }); - - it('calls the service', done => { spyOn(service, 'getBranchData').and.returnValue( Promise.resolve({ data: { @@ -36,14 +53,16 @@ describe('IDE store project actions', () => { }, }), ); + }); + it('calls the service', done => { store .dispatch('refreshLastCommitData', { projectId: store.state.currentProjectId, branchId: store.state.currentBranchId, }) .then(() => { - expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master'); + expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'master'); done(); }) @@ -53,16 +72,118 @@ describe('IDE store project actions', () => { it('commits getBranchData', done => { testAction( refreshLastCommitData, - {}, - {}, - [{ - type: 'SET_BRANCH_COMMIT', - payload: { - projectId: 'abcproject', - branchId: 'master', - commit: { id: '123' }, + { + projectId: store.state.currentProjectId, + branchId: store.state.currentBranchId, + }, + store.state, + [ + { + type: 'SET_BRANCH_COMMIT', + payload: { + projectId: 'abc/def', + branchId: 'master', + commit: { id: '123' }, + }, + }, + ], // mutations + [ + { + type: 'getLastCommitPipeline', + payload: { + projectId: 'abc/def', + projectIdNumber: store.state.projects['abc/def'].id, + branchId: 'master', + }, + }, + ], // action + done, + ); + }); + }); + + 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 + ], // mutations [], // action done, ); diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js index 29eb859ddaf..f2f1f2a9a2e 100644 --- a/spec/javascripts/ide/stores/mutations/branch_spec.js +++ b/spec/javascripts/ide/stores/mutations/branch_spec.js @@ -37,4 +37,40 @@ 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', + ); + }); + }); }); |