diff options
Diffstat (limited to 'app/assets')
21 files changed, 656 insertions, 49 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index ce1069276ab..dbdc4de7986 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -25,7 +25,7 @@ const Api = { 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', + pipelineJobsPath: '/:project_path/pipelines/:id/builds.json', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -246,8 +246,8 @@ const Api = { pipelineJobs(projectPath, pipelineId, params = {}) { const url = Api.buildUrl(this.pipelineJobsPath) - .replace(':id', encodeURIComponent(projectPath)) - .replace(':pipeline_id', pipelineId); + .replace(':project_path', projectPath) + .replace(':id', pipelineId); return axios.get(url, { params }); }, diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 0aaf5a112cb..c8bbdcced1a 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/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue new file mode 100644 index 00000000000..a6ca629a358 --- /dev/null +++ b/app/assets/javascripts/ide/components/jobs/item.vue @@ -0,0 +1,58 @@ +<script> +import CiIcon from '../../../vue_shared/components/ci_icon.vue'; + +export default { + components: { + 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.build_path" + target="_blank" + v-text="jobId" + > + </a> + </span> + </div> +</template> + +<style scoped> +.ide-job-item { + display: flex; + padding: 16px; +} + +.ide-job-item:not(:last-child) { + border-bottom: 1px solid #e5e5e5; +} + +.ide-job-item .ci-status-icon { + display: flex; + justify-content: center; + height: 20px; + margin-top: -2px; + overflow: hidden; +} +</style> 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..fd6bfdf86d0 --- /dev/null +++ b/app/assets/javascripts/ide/components/jobs/list.vue @@ -0,0 +1,38 @@ +<script> +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, + }, + }, +}; +</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" + /> + </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..7f1a0ed1218 --- /dev/null +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -0,0 +1,127 @@ +<script> +import { mapActions } from 'vuex'; +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; + }, + }, + created() { + this.fetchJobs(this.stage); + }, + mounted() { + const { stageTitle } = this.$refs; + + this.showTooltip = stageTitle.scrollWidth > stageTitle.offsetWidth; + }, + methods: { + ...mapActions('pipelines', ['fetchJobs']), + }, +}; +</script> + +<template> + <div + class="panel panel-default prepend-top-default" + > + <div + class="panel-heading" + @click="() => stage.isCollapsed = !stage.isCollapsed" + > + <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 class="append-right-8"> + <span class="badge"> + {{ jobsCount }} + </span> + </div> + <icon + :name="collapseIcon" + css-classes="pull-right" + /> + </div> + <div + class="panel-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> + +<style scoped> +.panel-heading { + display: flex; + cursor: pointer; +} +.panel-heading .ci-status-icon { + display: flex; + align-items: center; +} + +.panel-heading .pull-right { + margin: auto 0 auto auto; +} + +.panel-body { + padding: 0; +} + +.ide-stage-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +</style> 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..485412867f6 --- /dev/null +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -0,0 +1,78 @@ +<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']), + }, + 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="setRightPane($options.rightSidebarViews.pipelines)" + > + <icon + :size="16" + name="pipeline" + /> + </button> + </li> + </ul> + </nav> + </div> +</template> + +<style> +.ide-right-sidebar { + width: auto; + min-width: 60px; +} + +.ide-right-sidebar .ide-activity-bar { + border-left: 1px solid #eaeaea; +} + +.ide-right-sidebar .multi-file-commit-panel-inner { + width: 350px; + padding: 8px 16px; + background-color: #fff; + border-left: 1px solid #eaeaea; +} +</style> 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..8eed902d4e2 --- /dev/null +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -0,0 +1,104 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import LoadingIcon from '../../../vue_shared/components/loading_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 JobsList from '../jobs/list.vue'; + +export default { + components: { + LoadingIcon, + CiIcon, + Tabs, + Tab, + JobsList, + }, + computed: { + ...mapGetters(['currentProject']), + ...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages']), + ...mapState('pipelines', ['isLoadingPipeline', 'latestPipeline', 'stages', 'isLoadingJobs']), + statusIcon() { + return { + group: this.latestPipeline.status, + icon: `status_${this.latestPipeline.status}`, + }; + }, + }, + created() { + return this.fetchLatestPipeline().then(() => this.fetchStages()); + }, + methods: { + ...mapActions('pipelines', ['fetchLatestPipeline', 'fetchStages']), + }, +}; +</script> + +<template> + <div> + <loading-icon + v-if="isLoadingPipeline && !latestPipeline" + class="prepend-top-default" + size="2" + /> + <template v-else-if="latestPipeline"> + <header + class="ide-tree-header ide-pipeline-header" + > + <ci-icon + :status="statusIcon" + :size="24" + /> + <span class="prepend-left-8"> + <strong> + Pipeline + </strong> + <a + :href="currentProject.web_url + '/pipelines/' + latestPipeline.id" + target="_blank" + > + #{{ latestPipeline.id }} + </a> + </span> + </header> + <tabs> + <tab active> + <template slot="title"> + Jobs + <span + v-if="!isLoadingJobs || jobsCount" + class="badge" + > + {{ jobsCount }} + </span> + </template> + <jobs-list + :loading="isLoadingJobs" + :stages="stages" + /> + </tab> + <tab> + <template slot="title"> + Failed Jobs + <span + v-if="!isLoadingJobs || failedJobsCount" + class="badge" + > + {{ failedJobsCount }} + </span> + </template> + <jobs-list + :loading="isLoadingJobs" + :stages="failedStages" + /> + </tab> + </tabs> + </template> + </div> +</template> + +<style> +.ide-pipeline-header .ci-status-icon { + display: flex; +} +</style> 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/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 1a98b42761e..2b55a09f2a6 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -169,6 +169,10 @@ export const burstUnusedSeal = ({ state, commit }) => { } }; +export const setRightPane = ({ commit }, view) => { + commit(types.SET_RIGHT_PANE, view); +}; + export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js index 07f7b201f2e..b5d78b381ea 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js @@ -1,3 +1,4 @@ +import axios from 'axios'; import { __ } from '../../../../locale'; import Api from '../../../../api'; import flash from '../../../../flash'; @@ -21,29 +22,39 @@ export const fetchLatestPipeline = ({ dispatch, rootState }, sha) => { .catch(() => dispatch('receiveLatestPipelineError')); }; -export const requestJobs = ({ commit }) => commit(types.REQUEST_JOBS); -export const receiveJobsError = ({ commit }) => { - flash(__('There was an error loading jobs')); - commit(types.RECEIVE_JOBS_ERROR); +export const requestStages = ({ commit }) => commit(types.REQUEST_STAGES); +export const receiveStagesError = ({ commit }) => { + flash(__('There was an error loading job stages')); + commit(types.RECEIVE_STAGES_ERROR); }; -export const receiveJobsSuccess = ({ commit }, data) => commit(types.RECEIVE_JOBS_SUCCESS, data); +export const receiveStagesSuccess = ({ commit }, data) => + commit(types.RECEIVE_STAGES_SUCCESS, data); + +export const fetchStages = ({ dispatch, state, rootState }) => { + dispatch('requestStages'); -export const fetchJobs = ({ dispatch, state, rootState }, page = '1') => { - dispatch('requestJobs'); + Api.pipelineJobs(rootState.currentProjectId, state.latestPipeline.id) + .then(({ data }) => dispatch('receiveStagesSuccess', data)) + .catch(() => dispatch('receiveStagesError')); +}; - Api.pipelineJobs(rootState.currentProjectId, state.latestPipeline.id, { - page, - }) - .then(({ data, headers }) => { - const nextPage = headers && headers['x-next-page']; +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, id); +}; +export const receiveJobsSuccess = ({ commit }, { id, data }) => + commit(types.RECEIVE_JOBS_SUCCESS, { id, data }); - dispatch('receiveJobsSuccess', data); +export const fetchJobs = ({ dispatch }, stage) => { + dispatch('requestJobs', stage.id); - if (nextPage) { - dispatch('fetchJobs', nextPage); - } + axios + .get(stage.dropdown_path) + .then(({ data }) => { + dispatch('receiveJobsSuccess', { id: stage.id, data }); }) - .catch(() => dispatch('receiveJobsError')); + .catch(() => dispatch('receiveJobsError', stage.id)); }; export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js index d6c91f5b64d..95bed0f9050 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js @@ -1,7 +1,15 @@ export const hasLatestPipeline = state => !state.isLoadingPipeline && !!state.latestPipeline; -export const failedJobs = state => +export const failedStages = state => + state.stages.filter(stage => stage.status.text === 'failed').map(stage => ({ + ...stage, + jobs: stage.jobs.filter(job => job.status.text === '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 === 'failed').length, + 0, ); + +export const jobsCount = state => state.stages.reduce((acc, stage) => acc + stage.jobs.length, 0); 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..0911b8ee6fb 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js @@ -2,6 +2,10 @@ export const REQUEST_LATEST_PIPELINE = 'REQUEST_LATEST_PIPELINE'; export const RECEIVE_LASTEST_PIPELINE_ERROR = 'RECEIVE_LASTEST_PIPELINE_ERROR'; export const RECEIVE_LASTEST_PIPELINE_SUCCESS = 'RECEIVE_LASTEST_PIPELINE_SUCCESS'; +export const REQUEST_STAGES = 'REQUEST_STAGES'; +export const RECEIVE_STAGES_ERROR = 'RECEIVE_STAGES_ERROR'; +export const RECEIVE_STAGES_SUCCESS = 'RECEIVE_STAGES_SUCCESS'; + export const REQUEST_JOBS = 'REQUEST_JOBS'; export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR'; export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js index 2b16e57b386..0713aa4a3f5 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js @@ -18,36 +18,55 @@ export default { }; } }, - [types.REQUEST_JOBS](state) { + [types.REQUEST_STAGES](state) { state.isLoadingJobs = true; }, - [types.RECEIVE_JOBS_ERROR](state) { + [types.RECEIVE_STAGES_ERROR](state) { state.isLoadingJobs = false; }, - [types.RECEIVE_JOBS_SUCCESS](state, jobs) { + [types.RECEIVE_STAGES_SUCCESS](state, stages) { 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); + state.stages = stages.map((stage, i) => { + const foundStage = state.stages.find(s => s.id === i); + return { + ...stage, + id: i, + isCollapsed: foundStage ? foundStage.isCollapsed : false, + isLoading: foundStage ? foundStage.isLoading : false, + jobs: foundStage ? foundStage.jobs : [], + }; + }); + }, + [types.REQUEST_JOBS](state, id) { + state.stages = state.stages.reduce( + (acc, stage) => + acc.concat({ + ...stage, + isLoading: id === stage.id ? true : stage.isLoading, + }), + [], + ); + }, + [types.RECEIVE_JOBS_ERROR](state, id) { + state.stages = state.stages.reduce( + (acc, stage) => + acc.concat({ + ...stage, + isLoading: id === stage.id ? false : stage.isLoading, + }), + [], + ); + }, + [types.RECEIVE_JOBS_SUCCESS](state, { id, data }) { + state.stages = state.stages.reduce( + (acc, stage) => + acc.concat({ + ...stage, + isLoading: id === stage.id ? false : stage.isLoading, + jobs: id === stage.id ? data.latest_statuses : stage.jobs, + }), + [], + ); }, }; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 0a3f8d031c4..4e8340b3e9e 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -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..633c93ed0a8 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -148,6 +148,11 @@ export default { unusedSeal: false, }); }, + [types.SET_RIGHT_PANE](state, view) { + Object.assign(state, { + rightPane: state.rightPane === view ? null : view, + }); + }, ...projectMutations, ...mergeRequestMutation, ...fileMutations, diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index e7411f16a4f..ef8d678dd43 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -23,4 +23,5 @@ export default () => ({ currentActivityView: activityBarViews.edit, unusedSeal: true, fileFindVisible: false, + rightPane: null, }); 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..ac804c0ab3d --- /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, + class: tab.localActive ? 'active' : null, + }, + [ + h( + 'a', + { + 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 0bbd6eb27c1..8df2906f906 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -189,6 +189,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 35985d3c8f3..4e8b5d3b1b7 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -909,6 +909,13 @@ width: 1px; background: $white-light; } + + &.is-right { + &::after { + right: auto; + left: -1px; + } + } } } |