Commit 5329c9c8 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'show-bridge-job-details' into 'master'

Show status and commit in trigger job show page

See merge request gitlab-org/gitlab!76853
parents b5d1a632 eec7bddb
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, sprintf } from '~/locale';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import getPipelineQuery from './graphql/queries/pipeline.query.graphql';
import BridgeEmptyState from './components/empty_state.vue';
import BridgeSidebar from './components/sidebar.vue';
import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './components/constants';
export default {
name: 'BridgePageApp',
components: {
BridgeEmptyState,
BridgeSidebar,
CiHeader,
GlLoadingIcon,
},
inject: ['buildId', 'projectFullPath', 'pipelineIid'],
apollo: {
pipeline: {
query: getPipelineQuery,
variables() {
return {
fullPath: this.projectFullPath,
iid: this.pipelineIid,
};
},
update(data) {
if (!data?.project?.pipeline) {
return null;
}
const { pipeline } = data.project;
const stages = pipeline?.stages.edges.map((edge) => edge.node) || [];
const jobs = stages.map((stage) => stage.jobs.nodes).flat();
return {
...pipeline,
commit: {
...pipeline.commit,
commit_path: pipeline.commit.webPath,
short_id: pipeline.commit.shortId,
},
id: getIdFromGraphQLId(pipeline.id),
jobs,
stages,
};
},
},
},
data() {
return {
isSidebarExpanded: true,
pipeline: {},
};
},
computed: {
bridgeJob() {
return (
this.pipeline.jobs?.filter(
(job) => getIdFromGraphQLId(job.id) === Number(this.buildId),
)[0] || {}
);
},
bridgeName() {
return sprintf(__('Job %{jobName}'), { jobName: this.bridgeJob.name });
},
isPipelineLoading() {
return this.$apollo.queries.pipeline.loading;
},
},
created() {
window.addEventListener('resize', this.onResize);
},
mounted() {
this.onResize();
},
methods: {
toggleSidebar() {
this.isSidebarExpanded = !this.isSidebarExpanded;
},
onResize() {
const breakpoint = bp.getBreakpointSize();
if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) {
this.isSidebarExpanded = false;
} else if (!this.isSidebarExpanded) {
this.isSidebarExpanded = true;
}
},
},
};
</script>
<template>
<div>
<!-- TODO: get job details and show CI header -->
<!-- TODO: add downstream pipeline path -->
<bridge-empty-state downstream-pipeline-path="#" />
<bridge-sidebar />
<gl-loading-icon v-if="isPipelineLoading" size="lg" class="gl-mt-4" />
<div v-else>
<ci-header
class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
:status="bridgeJob.detailedStatus"
:time="bridgeJob.createdAt"
:user="pipeline.user"
:has-sidebar-button="true"
:item-name="bridgeName"
@clickedSidebarButton="toggleSidebar"
/>
<bridge-empty-state :downstream-pipeline-path="bridgeJob.downstreamPipeline.path" />
<bridge-sidebar
v-if="isSidebarExpanded"
:bridge-job="bridgeJob"
:commit="pipeline.commit"
:is-sidebar-expanded="isSidebarExpanded"
@toggleSidebar="toggleSidebar"
/>
</div>
</div>
</template>
<script>
import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { __ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { JOB_SIDEBAR } from '../../constants';
import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './constants';
import CommitBlock from '../../components/commit_block.vue';
export default {
styles: {
......@@ -18,41 +17,27 @@ export default {
retryTriggerJob: __('Retry the trigger job'),
retryDownstreamPipeline: __('Retry the downstream pipeline'),
},
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
sectionClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100', 'gl-py-5'],
components: {
CommitBlock,
GlButton,
GlDropdown,
GlDropdownItem,
TooltipOnTruncate,
},
inject: {
buildName: {
type: String,
default: '',
props: {
bridgeJob: {
type: Object,
required: true,
},
commit: {
type: Object,
required: true,
},
data() {
return {
isSidebarExpanded: true,
};
},
created() {
window.addEventListener('resize', this.onResize);
},
mounted() {
this.onResize();
},
methods: {
toggleSidebar() {
this.isSidebarExpanded = !this.isSidebarExpanded;
},
onResize() {
const breakpoint = bp.getBreakpointSize();
if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) {
this.isSidebarExpanded = false;
} else if (!this.isSidebarExpanded) {
this.isSidebarExpanded = true;
}
onSidebarButtonClick() {
this.$emit('toggleSidebar');
},
},
};
......@@ -61,14 +46,11 @@ export default {
<aside
class="gl-fixed gl-right-0 gl-px-5 gl-bg-gray-10 gl-h-full gl-border-l-solid gl-border-1 gl-border-gray-100 gl-z-index-200 gl-overflow-hidden"
:style="this.$options.styles"
:class="{
'gl-display-none': !isSidebarExpanded,
}"
>
<div class="gl-py-5 gl-display-flex gl-align-items-center">
<tooltip-on-truncate :title="buildName" truncate-target="child"
<tooltip-on-truncate :title="bridgeJob.name" truncate-target="child"
><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate">
{{ buildName }}
{{ bridgeJob.name }}
</h4>
</tooltip-on-truncate>
<!-- TODO: implement retry actions -->
......@@ -90,9 +72,10 @@ export default {
category="tertiary"
class="gl-md-display-none gl-ml-2"
icon="chevron-double-lg-right"
@click="toggleSidebar"
@click="onSidebarButtonClick"
/>
</div>
<!-- TODO: get job details and show commit block, stage dropdown, jobs list -->
<commit-block :commit="commit" :class="$options.sectionClass" />
<!-- TODO: show stage dropdown, jobs list -->
</aside>
</template>
query getPipelineData($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) {
id
pipeline(iid: $iid) {
id
iid
path
sha
ref
refPath
commit {
id
shortId
title
webPath
}
detailedStatus {
id
icon
group
}
stages {
edges {
node {
id
name
jobs {
nodes {
id
createdAt
name
scheduledAt
startedAt
status
triggered
detailedStatus {
id
detailsPath
icon
group
text
tooltip
}
downstreamPipeline {
id
path
}
stage {
id
name
}
}
}
}
}
}
user {
id
avatarUrl
name
username
webPath
webUrl
status {
message
}
}
}
}
}
......@@ -54,7 +54,13 @@ const initializeJobPage = (element) => {
};
const initializeBridgePage = (el) => {
const { buildName, emptyStateIllustrationPath } = el.dataset;
const {
buildId,
downstreamPipelinePath,
emptyStateIllustrationPath,
pipelineIid,
projectFullPath,
} = el.dataset;
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
......@@ -65,8 +71,11 @@ const initializeBridgePage = (el) => {
el,
apolloProvider,
provide: {
buildName,
buildId,
downstreamPipelinePath,
emptyStateIllustrationPath,
pipelineIid,
projectFullPath,
},
render(h) {
return h(BridgeApp);
......
......@@ -19,10 +19,12 @@ module Ci
}
end
def bridge_data(build)
def bridge_data(build, project)
{
"build_name" => build.name,
"empty-state-illustration-path" => image_path('illustrations/job-trigger-md.svg')
"build_id" => build.id,
"empty-state-illustration-path" => image_path('illustrations/job-trigger-md.svg'),
"pipeline_iid" => build.pipeline.iid,
"project_full_path" => project.full_path
}
end
......
......@@ -10,4 +10,4 @@
- if @build.is_a? ::Ci::Build
#js-job-page{ data: jobs_data }
- else
#js-bridge-page{ data: bridge_data(@build) }
#js-bridge-page{ data: bridge_data(@build, @project) }
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { GlLoadingIcon } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import getPipelineQuery from '~/jobs/bridge/graphql/queries/pipeline.query.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import BridgeApp from '~/jobs/bridge/app.vue';
import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue';
import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import {
MOCK_BUILD_ID,
MOCK_PIPELINE_IID,
MOCK_PROJECT_FULL_PATH,
mockPipelineQueryResponse,
} from './mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Bridge Show Page', () => {
let wrapper;
let mockApollo;
let mockPipelineQuery;
const createComponent = () => {
wrapper = shallowMount(BridgeApp, {});
const createComponent = (options) => {
wrapper = shallowMount(BridgeApp, {
provide: {
buildId: MOCK_BUILD_ID,
projectFullPath: MOCK_PROJECT_FULL_PATH,
pipelineIid: MOCK_PIPELINE_IID,
},
mocks: {
$apollo: {
queries: {
pipeline: {
loading: true,
},
},
},
},
...options,
});
};
const createComponentWithApollo = () => {
const handlers = [[getPipelineQuery, mockPipelineQuery]];
mockApollo = createMockApollo(handlers);
createComponent({
localVue,
apolloProvider: mockApollo,
mocks: {},
});
};
const findCiHeader = () => wrapper.findComponent(CiHeader);
const findEmptyState = () => wrapper.findComponent(BridgeEmptyState);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findSidebar = () => wrapper.findComponent(BridgeSidebar);
beforeEach(() => {
mockPipelineQuery = jest.fn();
});
afterEach(() => {
mockPipelineQuery.mockReset();
wrapper.destroy();
});
describe('template', () => {
describe('while pipeline query is loading', () => {
beforeEach(() => {
createComponent();
});
it('renders loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('after pipeline query is loaded', () => {
beforeEach(() => {
mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse);
createComponentWithApollo();
waitForPromises();
});
it('query is called with correct variables', async () => {
expect(mockPipelineQuery).toHaveBeenCalledTimes(1);
expect(mockPipelineQuery).toHaveBeenCalledWith({
fullPath: MOCK_PROJECT_FULL_PATH,
iid: MOCK_PIPELINE_IID,
});
});
it('renders CI header state', () => {
expect(findCiHeader().exists()).toBe(true);
});
it('renders empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
......@@ -30,4 +107,42 @@ describe('Bridge Show Page', () => {
expect(findSidebar().exists()).toBe(true);
});
});
describe('sidebar expansion', () => {
beforeEach(() => {
mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse);
createComponentWithApollo();
waitForPromises();
});
describe('on resize', () => {
it.each`
breakpoint | isSidebarExpanded
${'xs'} | ${false}
${'sm'} | ${false}
${'md'} | ${true}
${'lg'} | ${true}
${'xl'} | ${true}
`(
'sets isSidebarExpanded to `$isSidebarExpanded` when the breakpoint is "$breakpoint"',
async ({ breakpoint, isSidebarExpanded }) => {
jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(breakpoint);
window.dispatchEvent(new Event('resize'));
await nextTick();
expect(findSidebar().exists()).toBe(isSidebarExpanded);
},
);
});
it('toggles expansion on button click', async () => {
expect(findSidebar().exists()).toBe(true);
wrapper.vm.toggleSidebar();
await nextTick();
expect(findSidebar().exists()).toBe(false);
});
});
});
......@@ -6,14 +6,13 @@ import { MOCK_EMPTY_ILLUSTRATION_PATH, MOCK_PATH_TO_DOWNSTREAM } from '../mock_d
describe('Bridge Empty State', () => {
let wrapper;
const createComponent = (props) => {
const createComponent = ({ downstreamPipelinePath }) => {
wrapper = shallowMount(BridgeEmptyState, {
provide: {
emptyStateIllustrationPath: MOCK_EMPTY_ILLUSTRATION_PATH,
},
propsData: {
downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM,
...props,
downstreamPipelinePath,
},
});
};
......@@ -28,7 +27,7 @@ describe('Bridge Empty State', () => {
describe('template', () => {
beforeEach(() => {
createComponent();
createComponent({ downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM });
});
it('renders illustration', () => {
......
import { GlButton, GlDropdown } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue';
import { BUILD_NAME } from '../mock_data';
import CommitBlock from '~/jobs/components/commit_block.vue';
import { mockCommit, mockJob } from '../mock_data';
describe('Bridge Sidebar', () => {
let wrapper;
const createComponent = () => {
const createComponent = (props) => {
wrapper = shallowMount(BridgeSidebar, {
provide: {
buildName: BUILD_NAME,
propsData: {
bridgeJob: mockJob,
commit: mockCommit,
...props,
},
});
};
const findSidebar = () => wrapper.find('aside');
const findJobTitle = () => wrapper.find('h4');
const findCommitBlock = () => wrapper.findComponent(CommitBlock);
const findRetryDropdown = () => wrapper.find(GlDropdown);
const findToggle = () => wrapper.find(GlButton);
const findToggleBtn = () => wrapper.findComponent(GlButton);
afterEach(() => {
wrapper.destroy();
......@@ -29,9 +31,17 @@ describe('Bridge Sidebar', () => {
createComponent();
});
it('renders job name', () => {
expect(findJobTitle().text()).toBe(mockJob.name);
});
it('renders retry dropdown', () => {
expect(findRetryDropdown().exists()).toBe(true);
});
it('renders commit information', () => {
expect(findCommitBlock().exists()).toBe(true);
});
});
describe('sidebar expansion', () => {
......@@ -39,38 +49,12 @@ describe('Bridge Sidebar', () => {
createComponent();
});
it('toggles expansion on button click', async () => {
expect(findSidebar().classes()).not.toContain('gl-display-none');
it('emits toggle sidebar event on button click', async () => {
expect(wrapper.emitted('toggleSidebar')).toBe(undefined);
findToggle().vm.$emit('click');
await nextTick();
findToggleBtn().vm.$emit('click');
expect(findSidebar().classes()).toContain('gl-display-none');
});
describe('on resize', () => {
it.each`
breakpoint | isSidebarExpanded
${'xs'} | ${false}
${'sm'} | ${false}
${'md'} | ${true}
${'lg'} | ${true}
${'xl'} | ${true}
`(
'sets isSidebarExpanded to `$isSidebarExpanded` when the breakpoint is "$breakpoint"',
async ({ breakpoint, isSidebarExpanded }) => {
jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(breakpoint);
window.dispatchEvent(new Event('resize'));
await nextTick();
if (isSidebarExpanded) {
expect(findSidebar().classes()).not.toContain('gl-display-none');
} else {
expect(findSidebar().classes()).toContain('gl-display-none');
}
},
);
expect(wrapper.emitted('toggleSidebar')).toHaveLength(1);
});
});
});
export const MOCK_EMPTY_ILLUSTRATION_PATH = '/path/to/svg';
export const MOCK_PATH_TO_DOWNSTREAM = '/path/to/downstream/pipeline';
export const BUILD_NAME = 'Child Pipeline Trigger';
export const MOCK_BUILD_ID = '1331';
export const MOCK_PIPELINE_IID = '174';
export const MOCK_PROJECT_FULL_PATH = '/root/project/';
export const MOCK_SHA = '38f3d89147765427a7ce58be28cd76d14efa682a';
export const mockCommit = {
id: `gid://gitlab/CommitPresenter/${MOCK_SHA}`,
shortId: '38f3d891',
title: 'Update .gitlab-ci.yml file',
webPath: `/root/project/-/commit/${MOCK_SHA}`,
__typename: 'Commit',
};
export const mockJob = {
createdAt: '2021-12-10T09:05:45Z',
id: 'gid://gitlab/Ci::Build/1331',
name: 'triggerJobName',
scheduledAt: null,
startedAt: '2021-12-10T09:13:43Z',
status: 'SUCCESS',
triggered: null,
detailedStatus: {
id: '1',
detailsPath: '/root/project/-/jobs/1331',
icon: 'status_success',
group: 'success',
text: 'passed',
tooltip: 'passed',
__typename: 'DetailedStatus',
},
downstreamPipeline: {
id: '1',
path: '/root/project/-/pipelines/175',
},
stage: {
id: '1',
name: 'build',
__typename: 'CiStage',
},
__typename: 'CiJob',
};
export const mockUser = {
id: 'gid://gitlab/User/1',
avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
name: 'Administrator',
username: 'root',
webPath: '/root',
webUrl: 'http://gdk.test:3000/root',
status: {
message: 'making great things',
__typename: 'UserStatus',
},
__typename: 'UserCore',
};
export const mockStage = {
id: '1',
name: 'build',
jobs: {
nodes: [mockJob],
__typename: 'CiJobConnection',
},
__typename: 'CiStage',
};
export const mockPipelineQueryResponse = {
data: {
project: {
id: '1',
pipeline: {
commit: mockCommit,
id: 'gid://gitlab/Ci::Pipeline/174',
iid: '88',
path: '/root/project/-/pipelines/174',
sha: MOCK_SHA,
ref: 'main',
refPath: 'path/to/ref',
user: mockUser,
detailedStatus: {
id: '1',
icon: 'status_failed',
group: 'failed',
__typename: 'DetailedStatus',
},
stages: {
edges: [
{
node: mockStage,
__typename: 'CiStageEdge',
},
],
__typename: 'CiStageConnection',
},
__typename: 'Pipeline',
},
__typename: 'Project',
},
},
};
......@@ -5,9 +5,9 @@ require 'spec_helper'
RSpec.describe Ci::JobsHelper do
describe 'jobs data' do
let(:project) { create(:project, :repository) }
let(:bridge) { create(:ci_bridge, status: :pending) }
let(:bridge) { create(:ci_bridge) }
subject(:bridge_data) { helper.bridge_data(bridge) }
subject(:bridge_data) { helper.bridge_data(bridge, project) }
before do
allow(helper)
......@@ -17,8 +17,10 @@ RSpec.describe Ci::JobsHelper do
it 'returns bridge data' do
expect(bridge_data).to eq({
"build_name" => bridge.name,
"empty-state-illustration-path" => '/path/to/illustration'
"build_id" => bridge.id,
"empty-state-illustration-path" => '/path/to/illustration',
"pipeline_iid" => bridge.pipeline.iid,
"project_full_path" => project.full_path
})
end
end
......
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