Commit 7f2243d3 authored by Scott Hampton's avatar Scott Hampton

Create the pipeline dashboard MVC

Add the pipeline dashboard MVC to the operations page.

This feature is behind a feature flag.
parent 16896bc6
<script>
import { mapState, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlDashboardSkeleton } from '@gitlab/ui';
import DashboardProject from './project.vue';
import NewDashboardProject from './new_project.vue';
import ProjectSearch from './project_search.vue';
export default {
components: {
DashboardProject,
NewDashboardProject,
ProjectSearch,
GlLoadingIcon,
GlDashboardSkeleton,
},
props: {
addPath: {
......@@ -33,6 +36,15 @@ export default {
addIsDisabled() {
return !this.projectTokens.length;
},
showNewPipelineDashboard() {
return gon && gon.features && gon.features.pipelineDashboard;
},
dashboardClasses() {
return {
'm-0': !this.showNewPipelineDashboard,
'dashboard-cards': this.showNewPipelineDashboard,
};
},
},
created() {
this.setProjectEndpoints({
......@@ -73,14 +85,21 @@ export default {
</div>
</div>
<div class="prepend-top-default">
<div v-if="projects.length" class="row m-0 prepend-top-default">
<div
v-for="project in projects"
:key="project.id"
class="col-12 col-md-6 odds-md-pad-right evens-md-pad-left"
>
<dashboard-project :project="project" />
</div>
<div v-if="projects.length" :class="dashboardClasses" class="row prepend-top-default">
<template v-if="showNewPipelineDashboard">
<div v-for="project in projects" :key="project.id" class="col-12 col-md-6 col-xl-4 px-2">
<new-dashboard-project :project="project" />
</div>
</template>
<template v-else>
<div
v-for="project in projects"
:key="project.id"
class="col-12 col-md-6 odds-md-pad-right evens-md-pad-left"
>
<dashboard-project :project="project" />
</div>
</template>
</div>
<div v-else-if="!isLoadingProjects" class="row prepend-top-20 text-center">
<div class="col-12 d-flex justify-content-center svg-content">
......@@ -106,6 +125,7 @@ export default {
</a>
</div>
</div>
<gl-dashboard-skeleton v-else-if="showNewPipelineDashboard" />
<gl-loading-icon v-else :size="2" class="prepend-top-20" />
</div>
</div>
......
<script>
import { __, n__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
count: {
type: Number,
required: false,
default: 0,
},
},
computed: {
alertClasses() {
return {
'text-tertiary': this.count <= 0,
'text-warning': this.count > 0,
};
},
alertCount() {
return sprintf(__('%{count} %{alerts}'), {
count: this.count,
alerts: this.pluralizedAlerts,
});
},
pluralizedAlerts() {
return n__('Alert', 'Alerts', this.count);
},
},
};
</script>
<template>
<div class="dashboard-card-alert row">
<div class="col-12">
<icon
:class="alertClasses"
class="align-text-bottom js-dashboard-alerts-icon"
name="warning"
/>
<span class="js-alert-count text-secondary prepend-left-4"> {{ alertCount }} </span>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import _ from 'underscore';
import { GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import Commit from '~/vue_shared/components/commit.vue';
import ProjectHeader from './new_project_header.vue';
import Alerts from './new_alerts.vue';
import ProjectPipeline from './project_pipeline.vue';
import { STATUS_FAILED, STATUS_RUNNING } from '../../constants';
export default {
components: {
ProjectHeader,
UserAvatarLink,
Commit,
Alerts,
ProjectPipeline,
GlTooltip,
Icon,
},
mixins: [timeagoMixin],
props: {
project: {
type: Object,
required: true,
},
},
tooltips: {
timeAgo: __('Finished'),
triggerer: __('Triggerer'),
},
computed: {
hasPipelineFailed() {
return (
this.lastPipeline &&
this.lastPipeline.details &&
this.lastPipeline.details.status &&
this.lastPipeline.details.status.group === STATUS_FAILED
);
},
hasPipelineErrors() {
return this.project.alert_count > 0;
},
cardClasses() {
return {
'dashboard-card-body-warning': !this.hasPipelineFailed && this.hasPipelineErrors,
'dashboard-card-body-failed': this.hasPipelineFailed,
'bg-secondary': !this.hasPipelineFailed && !this.hasPipelineErrors,
};
},
noPipelineMessage() {
return __('The branch for this project has no active pipeline configuration.');
},
user() {
return this.lastPipeline && !_.isEmpty(this.lastPipeline.user)
? this.lastPipeline.user
: null;
},
lastPipeline() {
return !_.isEmpty(this.project.last_pipeline) ? this.project.last_pipeline : null;
},
commitRef() {
return this.lastPipeline && !_.isEmpty(this.lastPipeline.ref)
? {
...this.lastPipeline.ref,
ref_url: this.lastPipeline.ref.path,
}
: {};
},
finishedTime() {
return (
this.lastPipeline && this.lastPipeline.details && this.lastPipeline.details.finished_at
);
},
finishedTimeTitle() {
return this.tooltipTitle(this.finishedTime);
},
shouldShowTimeAgo() {
return (
this.lastPipeline &&
this.lastPipeline.details &&
this.lastPipeline.details.status &&
this.lastPipeline.details.status.group !== STATUS_RUNNING &&
this.finishedTime
);
},
},
methods: {
...mapActions(['removeProject']),
},
};
</script>
<template>
<div class="dashboard-card card border-0">
<project-header
:project="project"
:has-pipeline-failed="hasPipelineFailed"
:has-errors="hasPipelineErrors"
@remove="removeProject"
/>
<div :class="cardClasses" class="dashboard-card-body card-body">
<div v-if="lastPipeline" class="row">
<div class="col-1 align-self-center">
<user-avatar-link
v-if="user"
:link-href="user.path"
:img-src="user.avatar_url"
:tooltip-text="user.name"
:img-size="32"
/>
</div>
<div class="col-10 col-sm-6 pr-0 pl-5 align-self-center align-middle ci-table">
<commit
:tag="commitRef.tag"
:commit-ref="commitRef"
:short-sha="lastPipeline.commit.short_id"
:commit-url="lastPipeline.commit.commit_url"
:title="lastPipeline.commit.title"
:author="lastPipeline.commit.author"
:show-branch="true"
/>
</div>
<div class="col-sm-5 pl-0 text-right align-self-center d-none d-sm-block">
<div v-if="shouldShowTimeAgo" class="text-secondary">
<icon
name="clock"
class="dashboard-card-time-ago-icon align-text-bottom js-dashboard-project-clock-icon"
/>
<time ref="timeAgo" class="js-dashboard-project-time-ago">
{{ timeFormated(finishedTime) }}
</time>
<gl-tooltip :target="() => $refs.timeAgo">
<div class="bold">{{ $options.tooltips.timeAgo }}</div>
<div>{{ finishedTimeTitle }}</div>
</gl-tooltip>
</div>
<alerts :count="project.alert_count" />
</div>
<div class="col-12">
<project-pipeline
:project-name="project.name_with_namespace"
:last-pipeline="lastPipeline"
:has-pipeline-failed="hasPipelineFailed"
/>
</div>
</div>
<div v-else class="h-100 d-flex justify-content-center align-items-center">
<div class="text-plain text-metric text-center bold w-75">
{{ noPipelineMessage }}
</div>
</div>
</div>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
Icon,
ProjectAvatar,
GlButton,
GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
project: {
type: Object,
required: true,
},
hasPipelineFailed: {
type: Boolean,
required: false,
default: false,
},
hasErrors: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
headerClasses() {
return {
'dashboard-card-header-warning': this.hasErrors,
'dashboard-card-header-failed': this.hasPipelineFailed,
'bg-light': !this.hasErrors && !this.hasPipelineFailed,
};
},
},
methods: {
onRemove() {
this.$emit('remove', this.project.remove_path);
},
},
};
</script>
<template>
<div :class="headerClasses" class="card-header border-0 py-2 d-flex align-items-center">
<project-avatar :project="project" :size="24" class="flex-shrink-0 border rounded" />
<div class="flex-grow-1 block-truncated">
<gl-link
v-gl-tooltip
class="js-project-link cgray"
:href="project.web_url"
:title="project.name_with_namespace"
>
<span class="js-project-namespace">{{ project.namespace.name }} /</span>
<span class="js-project-name bold"> {{ project.name }}</span>
</gl-link>
</div>
<div class="dropdown js-more-actions">
<gl-button
v-gl-tooltip
class="js-more-actions-toggle d-flex align-items-center bg-transparent border-0 p-0 ml-2"
data-toggle="dropdown"
:title="__('More actions')"
>
<icon name="ellipsis_v" class="text-secondary" />
</gl-button>
<ul class="dropdown-menu dropdown-menu-right">
<li>
<gl-button class="btn btn-transparent js-remove-button" @click="onRemove">
<span class="text-danger"> {{ __('Remove') }} </span>
</gl-button>
</li>
</ul>
</div>
</div>
</template>
......@@ -79,7 +79,8 @@ export default {
>
<div class="d-flex align-items-end justify-content-end">
<div class="prepend-top-default text-secondary d-flex align-items-center flex-wrap">
<icon name="calendar" class="append-right-4" /> {{ lastDeployed }}
<icon name="calendar" class="append-right-4 js-dashboard-project-calendar-icon" />
{{ lastDeployed }}
</div>
</div>
</div>
......
<script>
import { __, sprintf } from '~/locale';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
import { GlLink, GlTooltip } from '@gitlab/ui';
import { STATUS_FAILED } from '../../constants';
export default {
components: {
CiBadgeLink,
CiIcon,
Icon,
GlLink,
GlTooltip,
},
props: {
lastPipeline: {
type: Object,
required: true,
},
hasPipelineFailed: {
type: Boolean,
required: false,
default: false,
},
},
relations: {
current: __('Current Project'),
downstream: __('Downstream'),
upstream: __('Upstream'),
},
computed: {
downstreamPipelines() {
return this.lastPipeline.triggered;
},
upstreamPipeline() {
return this.lastPipeline.triggered_by;
},
downstreamPipelinesHaveFailed() {
return (
this.downstreamPipelines &&
this.downstreamPipelines.some(
pipeline =>
pipeline.details &&
pipeline.details.status &&
pipeline.details.status.group === STATUS_FAILED,
)
);
},
pipelineClasses() {
const hasFailures = this.hasPipelineFailed || this.downstreamPipelinesHaveFailed;
return {
'dashboard-card-footer-failed': hasFailures,
'bg-light': !hasFailures,
};
},
hasDownstreamPipelines() {
return this.downstreamPipelines && this.downstreamPipelines.length > 0;
},
hasExtraDownstream() {
return this.downstreamCount > this.shownDownstreamCount;
},
/*
Returns a subset of the downstream pipelines, because we can only fit 5 of them
on a mobile screen before we have to truncate.
*/
shownDownstreamPipelines() {
return this.downstreamPipelines.slice(0, 5);
},
shownDownstreamCount() {
return this.shownDownstreamPipelines.length;
},
downstreamCount() {
return this.downstreamPipelines.length;
},
/*
Returns the number of extra downstream status to be shown in the icon
The plus sign is only shown on single digits, otherwise the number is cut off
*/
extraDownstreamText() {
const extra = this.downstreamCount - this.shownDownstreamCount;
const plus = extra < 10 ? '+' : '';
return `${plus}${extra}`;
},
extraDownstreamTitle() {
const extra = this.downstreamCount - this.shownDownstreamCount;
return sprintf('%{extra} more downstream pipelines', {
extra,
});
},
},
};
</script>
<template>
<div :class="pipelineClasses" class="dashboard-card-footer py-1 px-2 mt-3">
<template v-if="upstreamPipeline">
<gl-link
ref="upstreamStatus"
:href="upstreamPipeline.details.status.details_path"
class="d-inline-block align-middle"
>
<ci-icon
class="d-flex js-upstream-pipeline-status"
:status="upstreamPipeline.details.status"
/>
</gl-link>
<gl-tooltip :target="() => $refs.upstreamStatus">
<div class="bold">{{ $options.relations.upstream }}</div>
<div>{{ upstreamPipeline.details.status.tooltip }}</div>
<div class="text-tertiary">{{ upstreamPipeline.project.full_name }}</div>
</gl-tooltip>
<icon name="arrow-right" class="dashboard-card-footer-arrow align-middle mx-1" />
</template>
<ci-badge-link
ref="status"
class="bg-white"
:status="lastPipeline.details.status"
:show-text="true"
/>
<gl-tooltip :target="() => $refs.status">
<div class="bold">{{ $options.relations.current }}</div>
<div>{{ lastPipeline.details.status.tooltip }}</div>
</gl-tooltip>
<template v-if="hasDownstreamPipelines">
<icon name="arrow-right" class="dashboard-card-footer-arrow align-middle mx-1" />
<div
v-for="(pipeline, index) in shownDownstreamPipelines"
:key="pipeline.id"
:style="`z-index: ${shownDownstreamPipelines.length + 1 - index}`"
class="dashboard-card-footer-downstream position-relative d-inline"
>
<gl-link
ref="downstreamStatus"
:href="pipeline.details.status.details_path"
class="d-inline-block align-middle"
>
<ci-icon class="d-flex js-downstream-pipeline-status" :status="pipeline.details.status" />
</gl-link>
<gl-tooltip :target="() => $refs.downstreamStatus[index]">
<div class="bold">{{ $options.relations.downstream }}</div>
<div>{{ pipeline.details.status.tooltip }}</div>
<div class="text-tertiary">{{ pipeline.project.full_name }}</div>
</gl-tooltip>
</div>
<div v-if="hasExtraDownstream" class="d-inline">
<gl-link
ref="extraDownstream"
:href="lastPipeline.details.status.details_path"
class="dashboard-card-footer-extra rounded-circle d-inline-block bold align-middle text-white text-center js-downstream-extra-icon"
>
{{ extraDownstreamText }}
</gl-link>
<gl-tooltip :target="() => $refs.extraDownstream">
{{ extraDownstreamTitle }}
</gl-tooltip>
</div>
</template>
</div>
</template>
export const STATUS_FAILED = 'failed';
export const STATUS_RUNNING = 'running';
import Visibility from 'visibilityjs';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import createFlash from '~/flash';
import { __, s__, n__, sprintf } from '~/locale';
import * as types from './mutation_types';
let eTagPoll;
export const clearProjectsEtagPoll = () => {
eTagPoll = null;
};
export const stopProjectsPolling = () => {
if (eTagPoll) eTagPoll.stop();
};
export const restartProjectsPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
export const forceProjectsRequest = () => {
if (eTagPoll) eTagPoll.makeRequest();
};
export const addProjectsToDashboard = ({ state, dispatch }) => {
axios
.post(state.projectEndpoints.add, {
......@@ -51,7 +68,9 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data)
s__(
'OperationsDashboard|Unable to add %{invalidProjects}. The Operations Dashboard is available for public projects, and private projects in groups with a Gold plan.',
),
{ invalidProjects },
{
invalidProjects,
},
),
);
dispatch('filterProjectTokensById', invalid);
......@@ -82,17 +101,34 @@ export const clearProjectSearchResults = ({ commit }) => {
};
export const fetchProjects = ({ state, dispatch }) => {
if (eTagPoll) return;
dispatch('requestProjects');
axios
.get(state.projectEndpoints.list)
.then(response => dispatch('receiveProjectsSuccess', response.data))
.catch(() => dispatch('receiveProjectsError'))
.then(() => dispatch('requestProjects'))
.catch(() => {});
eTagPoll = new Poll({
resource: {
fetchProjects: () => axios.get(state.projectEndpoints.list),
},
method: 'fetchProjects',
successCallback: ({ data }) => dispatch('receiveProjectsSuccess', data),
errorCallback: () => dispatch('receiveProjectsError'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
dispatch('restartProjectsPolling');
} else {
dispatch('stopProjectsPolling');
}
});
};
export const requestProjects = ({ commit }) => {
commit(types.TOGGLE_IS_LOADING_PROJECTS);
commit(types.REQUEST_PROJECTS);
};
export const receiveProjectsSuccess = ({ commit }, data) => {
......@@ -112,7 +148,7 @@ export const removeProject = ({ dispatch }, removePath) => {
};
export const requestRemoveProjectSuccess = ({ dispatch }) => {
dispatch('fetchProjects');
dispatch('forceProjectsRequest');
};
export const requestRemoveProjectError = () => {
......
......@@ -10,5 +10,4 @@ export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_PROJECT_TOKENS = 'SET_PROJECT_TOKENS';
export const REMOVE_PROJECT_TOKEN_AT = 'REMOVE_PROJECT_TOKEN_AT';
export const TOGGLE_IS_LOADING_PROJECTS = 'TOGGLE_IS_LOADING_PROJECTS';
export const REQUEST_PROJECTS = 'REQUEST_PROJECTS';
......@@ -27,6 +27,7 @@ export default {
},
[types.SET_PROJECTS](state, projects) {
state.projects = projects || [];
state.isLoadingProjects = false;
},
[types.SET_PROJECT_TOKENS](state, tokens) {
state.projectTokens = tokens;
......@@ -34,7 +35,7 @@ export default {
[types.REMOVE_PROJECT_TOKEN_AT](state, index) {
state.projectTokens.splice(index, 1);
},
[types.TOGGLE_IS_LOADING_PROJECTS](state) {
state.isLoadingProjects = !state.isLoadingProjects;
[types.REQUEST_PROJECTS](state) {
state.isLoadingProjects = true;
},
};
......@@ -14,25 +14,6 @@
}
}
.operations-dashboard {
.branch-commit {
* {
vertical-align: middle;
}
.icon-container,
.commit-icon {
display: inline;
color: $gl-text-color-tertiary;
}
.ref-name {
font-weight: $gl-font-weight-bold;
color: $gl-text-color;
}
}
}
.tokenized-input-wrapper {
height: auto;
padding: 2px $gl-padding-8;
......
......@@ -3,12 +3,17 @@
class OperationsController < ApplicationController
before_action :authorize_read_operations_dashboard!
before_action do
push_frontend_feature_flag(:pipeline_dashboard)
end
respond_to :json, only: [:list]
def index
end
def list
Gitlab::PollingInterval.set_header(response, interval: 120_000)
projects = load_projects(current_user)
render json: { projects: serialize_as_json(projects) }
......
---
title: Adding pipelines to the operations dashboard
merge_request: 9197
author:
type: added
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Alerts from 'ee/operations/components/dashboard/new_alerts.vue';
const localVue = createLocalVue();
describe('alerts component', () => {
const AlertsComponent = localVue.extend(Alerts);
let wrapper;
const mount = (propsData = {}) => shallowMount(AlertsComponent, { propsData, sync: false });
afterEach(() => {
wrapper.destroy();
});
it('renders multiple alert count when multiple alerts are present', () => {
wrapper = mount({
count: 2,
});
expect(wrapper.element.querySelector('.js-alert-count').innerText.trim()).toBe('2 Alerts');
});
it('renders count for one alert when there is one alert', () => {
wrapper = mount({
count: 1,
});
expect(wrapper.element.querySelector('.js-alert-count').innerText.trim()).toBe('1 Alert');
});
describe('wrapped components', () => {
describe('icon', () => {
it('renders warning', () => {
wrapper = mount({
count: 1,
});
expect(wrapper.element.querySelector('.js-dashboard-alerts-icon')).not.toBe(null);
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import ProjectHeader from 'ee/operations/components/dashboard/new_project_header.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { removeWhitespace } from 'spec/helpers/vue_component_helper';
import { mockOneProject, mockText } from '../../new_mock_data';
const localVue = createLocalVue();
describe('project header component', () => {
let wrapper;
const factory = () => {
wrapper = shallowMount(localVue.extend(ProjectHeader), {
propsData: {
project: mockOneProject,
},
localVue,
sync: false,
});
};
beforeEach(() => {
factory();
});
afterEach(() => {
wrapper.destroy();
});
it('renders project name with namespace', () => {
const namespace = wrapper.find('.js-project-namespace').text();
const name = wrapper.find('.js-project-name').text();
expect(removeWhitespace(namespace).trim()).toBe(`${mockOneProject.namespace.name} /`);
expect(removeWhitespace(name).trim()).toBe(mockOneProject.name);
});
it('links project name to project', () => {
const path = mockOneProject.web_url;
expect(wrapper.find('.js-project-link').attributes('href')).toBe(path);
});
describe('wrapped components', () => {
describe('project avatar', () => {
it('renders', () => {
expect(wrapper.findAll(ProjectAvatar).length).toBe(1);
});
it('binds project', () => {
expect(wrapper.find(ProjectAvatar).props('project')).toEqual(mockOneProject);
});
});
});
describe('dropdown menu', () => {
it('renders removal button', () => {
expect(
wrapper
.find('.js-remove-button')
.text()
.trim(),
).toBe(mockText.REMOVE_PROJECT);
});
it('emits project removal link on click', () => {
wrapper.find('.js-remove-button').vm.$emit('click');
expect(wrapper.emittedByOrder()).toEqual([
{ name: 'remove', args: [mockOneProject.remove_path] },
]);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import Commit from '~/vue_shared/components/commit.vue';
import Project from 'ee/operations/components/dashboard/new_project.vue';
import ProjectHeader from 'ee/operations/components/dashboard/new_project_header.vue';
import Alerts from 'ee/operations/components/dashboard/new_alerts.vue';
import store from 'ee/operations/store';
import { mockOneProject } from '../../new_mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('project component', () => {
const ProjectComponent = localVue.extend(Project);
let wrapper;
beforeEach(() => {
wrapper = shallowMount(ProjectComponent, {
sync: false,
store,
localVue,
propsData: { project: mockOneProject },
});
});
afterEach(() => {
wrapper.destroy();
});
describe('wrapped components', () => {
describe('project header', () => {
it('binds project', () => {
const header = wrapper.find(ProjectHeader);
expect(header.props('project')).toEqual(mockOneProject);
});
});
describe('alerts', () => {
it('binds alert count to count', () => {
const alert = wrapper.find(Alerts);
expect(alert.props('count')).toBe(mockOneProject.alert_count);
});
});
describe('commit', () => {
let commit;
beforeEach(() => {
commit = wrapper.find(Commit);
});
it('binds commitRef', () => {
expect(commit.props('commitRef')).toBe(wrapper.vm.commitRef);
});
it('binds short_id to shortSha', () => {
expect(commit.props('shortSha')).toBe(
wrapper.props().project.last_pipeline.commit.short_id,
);
});
it('binds commitUrl', () => {
expect(commit.props('commitUrl')).toBe(
wrapper.props().project.last_pipeline.commit.commit_url,
);
});
it('binds title', () => {
expect(commit.props('title')).toBe(wrapper.props().project.last_pipeline.commit.title);
});
it('binds author', () => {
expect(commit.props('author')).toBe(wrapper.props().project.last_pipeline.commit.author);
});
it('binds tag', () => {
expect(commit.props('tag')).toBe(wrapper.props().project.last_pipeline.ref.tag);
});
});
describe('deploy finished at', () => {
it('renders clock icon', () => {
expect(wrapper.contains('.js-dashboard-project-clock-icon')).toBe(true);
});
it('renders time ago of finished time', () => {
const timeago = '1 day ago';
const container = wrapper.element.querySelector('.js-dashboard-project-time-ago');
expect(container.innerText.trim()).toBe(timeago);
});
});
});
});
import { mount, createLocalVue } from '@vue/test-utils';
import ProjectPipeline from 'ee/operations/components/dashboard/project_pipeline.vue';
import { mockPipelineData } from '../../new_mock_data';
const localVue = createLocalVue();
describe('project pipeline component', () => {
const ProjectPipelineComponent = localVue.extend(ProjectPipeline);
let wrapper;
const mountComponent = (propsData = {}) =>
mount(ProjectPipelineComponent, { propsData, sync: false });
afterEach(() => {
wrapper.destroy();
});
describe('current pipeline only', () => {
it('should render success badge', () => {
wrapper = mountComponent({
lastPipeline: mockPipelineData(),
hasPipelineFailed: false,
});
expect(wrapper.contains('.js-ci-status-icon-success')).toBe(true);
});
it('should render failed badge', () => {
wrapper = mountComponent({
lastPipeline: mockPipelineData('failed'),
hasPipelineFailed: true,
});
expect(wrapper.contains('.js-ci-status-icon-failed')).toBe(true);
});
it('should render running badge', () => {
wrapper = mountComponent({
lastPipeline: mockPipelineData('running'),
hasPipelineFailed: false,
});
expect(wrapper.contains('.js-ci-status-icon-running')).toBe(true);
});
});
describe('upstream pipeline', () => {
it('should render upstream success badge', () => {
const lastPipeline = mockPipelineData('success');
lastPipeline.triggered_by = mockPipelineData('success');
wrapper = mountComponent({
lastPipeline,
hasPipelineFailed: false,
});
expect(wrapper.contains('.js-upstream-pipeline-status.js-ci-status-icon-success')).toBe(true);
});
});
describe('downstream pipeline', () => {
it('should render downstream success badge', () => {
const lastPipeline = mockPipelineData('success');
lastPipeline.triggered = [mockPipelineData('success')];
wrapper = mountComponent({
lastPipeline,
hasPipelineFailed: false,
});
expect(wrapper.contains('.js-downstream-pipeline-status.js-ci-status-icon-success')).toBe(
true,
);
});
it('should render downstream failed badge', () => {
const lastPipeline = mockPipelineData('success');
lastPipeline.triggered = [mockPipelineData('failed')];
wrapper = mountComponent({
lastPipeline,
hasPipelineFailed: false,
});
expect(wrapper.contains('.js-downstream-pipeline-status.js-ci-status-icon-failed')).toBe(
true,
);
});
it('should render downstream running badge', () => {
const lastPipeline = mockPipelineData('success');
lastPipeline.triggered = [mockPipelineData('running')];
wrapper = mountComponent({
lastPipeline,
hasPipelineFailed: false,
});
expect(wrapper.contains('.js-downstream-pipeline-status.js-ci-status-icon-running')).toBe(
true,
);
});
it('should render extra downstream icon', () => {
const lastPipeline = mockPipelineData('success');
// 5 is the max we can show, so put 6 in the array
lastPipeline.triggered = Array.from(new Array(6), (val, index) =>
mockPipelineData('running', index),
);
wrapper = mountComponent({
lastPipeline,
hasPipelineFailed: false,
});
expect(wrapper.contains('.js-downstream-extra-icon')).toBe(true);
});
});
});
......@@ -13,7 +13,10 @@ describe('project search component', () => {
const mockProjects = mockProjectData(1);
const [mockOneProject] = mockProjects;
const mockInputValue = 'mock-inputValue';
const mount = () => mountComponentWithStore(ProjectSearchComponent, { store });
const mount = () =>
mountComponentWithStore(ProjectSearchComponent, {
store,
});
let vm;
beforeEach(() => {
......@@ -55,7 +58,9 @@ describe('project search component', () => {
it('renders search description', () => {
store.state.inputValue = mockInputValue;
vm = mountComponentWithStore(ProjectSearchComponent, { store });
vm = mountComponentWithStore(ProjectSearchComponent, {
store,
});
expect(vm.$el.querySelector('.js-search-results').innerText.trim()).toBe(
`"${mockInputValue}" ${mockText.SEARCH_DESCRIPTION_SUFFIX}`,
......
import Vue from 'vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import Commit from '~/vue_shared/components/commit.vue';
import Project from 'ee/operations/components/dashboard/project.vue';
import ProjectHeader from 'ee/operations/components/dashboard/project_header.vue';
import Alerts from 'ee/operations/components/dashboard/alerts.vue';
import { getChildInstances } from '../../helpers';
import store from 'ee/operations/store';
import { mockOneProject } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('project component', () => {
const ProjectComponent = Vue.extend(Project);
const ProjectHeaderComponent = Vue.extend(ProjectHeader);
const AlertsComponent = Vue.extend(Alerts);
const CommitComponent = Vue.extend(Commit);
let vm;
const ProjectComponent = localVue.extend(Project);
let wrapper;
beforeEach(() => {
vm = mountComponentWithStore(ProjectComponent, {
props: {
wrapper = shallowMount(ProjectComponent, {
sync: false,
store,
localVue,
propsData: {
project: mockOneProject,
},
});
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('wrapped components', () => {
describe('project header', () => {
it('binds project', () => {
const [header] = getChildInstances(vm, ProjectHeaderComponent);
const header = wrapper.find(ProjectHeader);
expect(header.project).toEqual(mockOneProject);
expect(header.props('project')).toEqual(mockOneProject);
});
});
......@@ -39,64 +42,62 @@ describe('project component', () => {
let alert;
beforeEach(() => {
[alert] = getChildInstances(vm, AlertsComponent);
alert = wrapper.find(Alerts);
});
it('binds alert count to count', () => {
expect(alert.count).toBe(mockOneProject.alert_count);
expect(alert.props('count')).toBe(mockOneProject.alert_count);
});
it('binds last alert', () => {
expect(alert.lastAlert).toEqual(mockOneProject.last_alert);
expect(alert.props('lastAlert')).toEqual(mockOneProject.last_alert);
});
});
describe('commit', () => {
let commits;
let commit;
beforeEach(() => {
commits = getChildInstances(vm, CommitComponent);
[commit] = commits;
});
it('renders', () => {
expect(commits.length).toBe(1);
commit = wrapper.find(Commit);
});
it('binds commitRef', () => {
expect(commit.commitRef).toBe(vm.commitRef);
expect(commit.props('commitRef')).toBe(wrapper.vm.commitRef);
});
it('binds short_id to shortSha', () => {
expect(commit.shortSha).toBe(vm.project.last_deployment.commit.short_id);
expect(commit.props().shortSha).toBe(
wrapper.props().project.last_deployment.commit.short_id,
);
});
it('binds commitUrl', () => {
expect(commit.commitUrl).toBe(vm.project.last_deployment.commit.commit_url);
expect(commit.props().commitUrl).toBe(
wrapper.props().project.last_deployment.commit.commit_url,
);
});
it('binds title', () => {
expect(commit.title).toBe(vm.project.last_deployment.commit.title);
expect(commit.props().title).toBe(wrapper.props().project.last_deployment.commit.title);
});
it('binds author', () => {
expect(commit.author).toBe(vm.author);
expect(commit.props().author).toBe(wrapper.vm.author);
});
it('binds tag', () => {
expect(commit.tag).toBe(vm.project.last_deployment.tag);
expect(commit.props().tag).toBe(wrapper.props().project.last_deployment.tag);
});
});
describe('last deploy', () => {
it('renders calendar icon', () => {
expect(vm.$el.querySelector('.ic-calendar')).not.toBe(null);
expect(wrapper.contains('.js-dashboard-project-calendar-icon')).toBe(true);
});
it('renders time ago of last deploy', () => {
const timeago = '1 day ago';
const container = vm.$el.querySelector('.js-project-container');
const container = wrapper.element.querySelector('.js-project-container');
expect(container.innerText.trim()).toBe(timeago);
});
......
import { TEST_HOST } from 'spec/test_constants';
const AVATAR_URL = `${TEST_HOST}/dummy.jpg`;
export const mockText = {
ADD_PROJECTS: 'Add projects',
ADD_PROJECTS_ERROR: 'Something went wrong, unable to add projects to dashboard',
REMOVE_PROJECT_ERROR: 'Something went wrong, unable to remove project',
DASHBOARD_TITLE: 'Operations Dashboard',
EMPTY_TITLE: 'Add a project to the dashboard',
EMPTY_SUBTITLE:
"The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses.",
EMPTY_SVG_SOURCE: '/assets/illustrations/operations-dashboard_empty.svg',
NO_SEARCH_RESULTS: 'Sorry, no projects matched your search',
RECEIVE_PROJECTS_ERROR: 'Something went wrong, unable to get operations projects',
REMOVE_PROJECT: 'Remove',
SEARCH_PROJECTS: 'Search your projects',
SEARCH_DESCRIPTION_SUFFIX: 'in projects',
};
export function mockPipelineData(
status = 'success',
id = 1,
finishedTimeStamp = new Date(Date.now() - 86400000).toISOString(),
isTag = false,
) {
return {
id,
user: {
id: 1,
name: 'Test',
username: 'test',
state: 'active',
avatar_url: AVATAR_URL,
web_url: '/test',
status_tooltip_html: null,
path: '/test',
},
active: false,
path: '/test/test-project/pipelines/1',
details: {
status: {
icon: `status_${status}`,
text: status,
label: status,
group: status,
tooltip: status,
has_details: true,
details_path: '/test/test-project/pipelines/1',
illustration: null,
},
finished_at: finishedTimeStamp,
},
ref: {
name: 'master',
path: 'test/test-project/commits/master',
tag: isTag,
branch: true,
merge_request: false,
},
commit: {
id: 'e778416d94deaf75bdabcc8fdd6b7d21f482bcca',
short_id: 'e778416d',
title: "Add new file to the branch I'm working on",
message: "Add new file to the branch I'm working on",
author: {
id: 1,
name: 'Test',
username: 'test',
state: 'active',
avatar_url: AVATAR_URL,
status_tooltip_html: null,
path: '/test',
},
commit_url: '/test/test-project/commit/e778416d94deaf75bdabcc8fdd6b7d21f482bcca',
commit_path: '/test/test-project/commit/e778416d94deaf75bdabcc8fdd6b7d21f482bcca',
},
project: {
full_name: 'Test / test-project',
full_path: '/test/test-project',
name: 'test-project',
},
};
}
export function mockProjectData(
projectCount = 1,
currentPipelineStatus = 'success',
upstreamStatus = 'success',
alertCount = 0,
) {
return Array(projectCount)
.fill(null)
.map((_, index) => ({
id: index,
description: '',
name: 'test-project',
name_with_namespace: 'Test / test-project',
path: 'test-project',
path_with_namespace: 'test/test-project',
created_at: '2019-02-01T15:40:27.522Z',
default_branch: 'master',
tag_list: [],
avatar_url: null,
web_url: 'https://mock-web_url/',
namespace: {
id: 1,
name: 'test',
path: 'test',
kind: 'user',
full_path: 'user',
parent_id: null,
},
remove_path: '/-/operations?project_id=1',
last_pipeline: mockPipelineData(currentPipelineStatus),
upstream_pipeline: mockPipelineData(upstreamStatus),
downstream_pipelines: [],
alert_count: alertCount,
}));
}
export const [mockOneProject] = mockProjectData(1);
......@@ -284,6 +284,10 @@ describe('actions', () => {
});
describe('fetchProjects', () => {
afterEach(() => {
actions.clearProjectsEtagPoll();
});
it('calls project list endpoint', done => {
store.state.projectEndpoints.list = mockListEndpoint;
mockAxios.onGet(mockListEndpoint).replyOnce(200);
......@@ -293,11 +297,7 @@ describe('actions', () => {
null,
store.state,
[],
[
{ type: 'requestProjects' },
{ type: 'receiveProjectsSuccess' },
{ type: 'requestProjects' },
],
[{ type: 'requestProjects' }, { type: 'receiveProjectsSuccess' }],
done,
);
});
......@@ -311,11 +311,7 @@ describe('actions', () => {
null,
store.state,
[],
[
{ type: 'requestProjects' },
{ type: 'receiveProjectsError' },
{ type: 'requestProjects' },
],
[{ type: 'requestProjects' }, { type: 'receiveProjectsError' }],
done,
);
});
......@@ -327,7 +323,7 @@ describe('actions', () => {
actions.requestProjects,
null,
store.state,
[{ type: types.TOGGLE_IS_LOADING_PROJECTS }],
[{ type: types.REQUEST_PROJECTS }],
[],
done,
);
......@@ -412,7 +408,7 @@ describe('actions', () => {
null,
null,
[],
[{ type: 'fetchProjects' }],
[{ type: 'forceProjectsRequest' }],
done,
);
});
......
......@@ -72,6 +72,7 @@ describe('mutations', () => {
mutations[types.SET_PROJECTS](localState, projects);
expect(localState.projects).toEqual(projects);
expect(localState.isLoadingProjects).toEqual(false);
});
});
......@@ -83,4 +84,12 @@ describe('mutations', () => {
expect(localState.projectTokens.length).toBe(0);
});
});
describe('REQUEST_PROJECTS', () => {
it('sets loading projects to true', () => {
mutations[types.REQUEST_PROJECTS](localState);
expect(localState.isLoadingProjects).toEqual(true);
});
});
});
......@@ -3065,6 +3065,9 @@ msgstr ""
msgid "Current Branch"
msgstr ""
msgid "Current Project"
msgstr ""
msgid "Current node"
msgstr ""
......@@ -9871,6 +9874,9 @@ msgstr ""
msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
msgstr ""
msgid "The branch for this project has no active pipeline configuration."
msgstr ""
msgid "The character highlighter helps you keep the subject line to %{titleLength} characters and wrap the body at %{bodyLength} so they are readable in git."
msgstr ""
......@@ -10649,6 +10655,9 @@ msgstr ""
msgid "Trigger variables:"
msgstr ""
msgid "Triggerer"
msgstr ""
msgid "Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions."
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment