Commit 83b9fdb7 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch 'bridge-page-vue-app' into 'master'

Create show page for a trigger job

See merge request gitlab-org/gitlab!76115
parents a06c7ef3 ebd89848
<script>
import BridgeEmptyState from './components/empty_state.vue';
import BridgeSidebar from './components/sidebar.vue';
export default {
name: 'BridgePageApp',
components: {
BridgeEmptyState,
BridgeSidebar,
},
};
</script>
<template>
<div>
<!-- TODO: get job details and show CI header -->
<!-- TODO: add downstream pipeline path -->
<bridge-empty-state downstream-pipeline-path="#" />
<bridge-sidebar />
</div>
</template>
export const SIDEBAR_COLLAPSE_BREAKPOINTS = ['xs', 'sm'];
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
name: 'BridgeEmptyState',
i18n: {
title: __('This job triggers a downstream pipeline'),
linkBtnText: __('View downstream pipeline'),
},
components: {
GlButton,
},
inject: {
emptyStateIllustrationPath: {
type: String,
require: true,
},
},
props: {
downstreamPipelinePath: {
type: String,
required: false,
default: undefined,
},
},
};
</script>
<template>
<div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
<img :src="emptyStateIllustrationPath" />
<h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
<gl-button
v-if="downstreamPipelinePath"
class="gl-mt-3"
category="secondary"
variant="confirm"
size="medium"
:href="downstreamPipelinePath"
>
{{ $options.i18n.linkBtnText }}
</gl-button>
</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';
export default {
styles: {
top: '75px',
width: '290px',
},
name: 'BridgeSidebar',
i18n: {
...JOB_SIDEBAR,
retryButton: __('Retry'),
retryTriggerJob: __('Retry the trigger job'),
retryDownstreamPipeline: __('Retry the downstream pipeline'),
},
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
components: {
GlButton,
GlDropdown,
GlDropdownItem,
TooltipOnTruncate,
},
inject: {
buildName: {
type: String,
default: '',
},
},
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;
}
},
},
};
</script>
<template>
<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"
><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate">
{{ buildName }}
</h4>
</tooltip-on-truncate>
<!-- TODO: implement retry actions -->
<div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
<gl-dropdown
:text="$options.i18n.retryButton"
category="primary"
variant="confirm"
right
size="medium"
>
<gl-dropdown-item>{{ $options.i18n.retryTriggerJob }}</gl-dropdown-item>
<gl-dropdown-item>{{ $options.i18n.retryDownstreamPipeline }}</gl-dropdown-item>
</gl-dropdown>
</div>
<gl-button
:aria-label="$options.i18n.toggleSidebar"
data-testid="sidebar-expansion-toggle"
category="tertiary"
class="gl-md-display-none gl-ml-2"
icon="chevron-double-lg-right"
@click="toggleSidebar"
/>
</div>
<!-- TODO: get job details and show commit block, stage dropdown, jobs list -->
</aside>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import BridgeApp from './bridge/app.vue';
import JobApp from './components/job_app.vue';
import createStore from './store';
export default () => {
const element = document.getElementById('js-job-vue-app');
const initializeJobPage = (element) => {
const store = createStore();
// Let's start initializing the store (i.e. fetching data) right away
......@@ -51,3 +52,35 @@ export default () => {
},
});
};
const initializeBridgePage = (el) => {
const { buildName, emptyStateIllustrationPath } = el.dataset;
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
provide: {
buildName,
emptyStateIllustrationPath,
},
render(h) {
return h(BridgeApp);
},
});
};
export default () => {
const jobElement = document.getElementById('js-job-page');
const bridgeElement = document.getElementById('js-bridge-page');
if (jobElement) {
initializeJobPage(jobElement);
} else {
initializeBridgePage(bridgeElement);
}
};
......@@ -4,8 +4,8 @@ class Projects::JobsController < Projects::ApplicationController
include SendFileUpload
include ContinueParams
before_action :find_job_as_build, except: [:index, :play]
before_action :find_job_as_processable, only: [:play]
before_action :find_job_as_build, except: [:index, :play, :show]
before_action :find_job_as_processable, only: [:play, :show]
before_action :authorize_read_build_trace!, only: [:trace, :raw]
before_action :authorize_read_build!
before_action :authorize_update_build!,
......
......@@ -19,6 +19,13 @@ module Ci
}
end
def bridge_data(build)
{
"build_name" => build.name,
"empty-state-illustration-path" => image_path('illustrations/job-trigger-md.svg')
}
end
def job_counts
{
"all" => limited_counter_with_delimiter(@all_builds),
......
......@@ -7,4 +7,7 @@
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
#js-job-vue-app{ data: jobs_data }
- if @build.is_a? ::Ci::Build
#js-job-page{ data: jobs_data }
- else
#js-bridge-page{ data: bridge_data(@build) }
---
name: ci_retry_downstream_pipeline
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76115
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/347424
milestone: '14.16'
type: development
group: group::pipeline authoring
default_enabled: false
......@@ -16,7 +16,11 @@ module Gitlab
def details_path
return unless can?(user, :read_pipeline, downstream_pipeline)
project_pipeline_path(downstream_project, downstream_pipeline)
if Feature.enabled?(:ci_retry_downstream_pipeline, subject.project, default_enabled: :yaml)
project_job_path(subject.project, subject)
else
project_pipeline_path(downstream_project, downstream_pipeline)
end
end
def has_action?
......
......@@ -29925,6 +29925,12 @@ msgstr ""
msgid "Retry migration"
msgstr ""
msgid "Retry the downstream pipeline"
msgstr ""
msgid "Retry the trigger job"
msgstr ""
msgid "Retry this job"
msgstr ""
......@@ -35897,6 +35903,9 @@ msgstr ""
msgid "This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes."
msgstr ""
msgid "This job triggers a downstream pipeline"
msgstr ""
msgid "This job will automatically run after its timer finishes. Often they are used for incremental roll-out deploys to production environments. When unscheduled it converts into a manual action."
msgstr ""
......@@ -38602,6 +38611,9 @@ msgstr ""
msgid "View documentation"
msgstr ""
msgid "View downstream pipeline"
msgstr ""
msgid "View eligible approvers"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import BridgeApp from '~/jobs/bridge/app.vue';
import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue';
import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue';
describe('Bridge Show Page', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(BridgeApp, {});
};
const findEmptyState = () => wrapper.findComponent(BridgeEmptyState);
const findSidebar = () => wrapper.findComponent(BridgeSidebar);
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
it('renders sidebar', () => {
expect(findSidebar().exists()).toBe(true);
});
});
});
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue';
import { MOCK_EMPTY_ILLUSTRATION_PATH, MOCK_PATH_TO_DOWNSTREAM } from '../mock_data';
describe('Bridge Empty State', () => {
let wrapper;
const createComponent = (props) => {
wrapper = shallowMount(BridgeEmptyState, {
provide: {
emptyStateIllustrationPath: MOCK_EMPTY_ILLUSTRATION_PATH,
},
propsData: {
downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM,
...props,
},
});
};
const findSvg = () => wrapper.find('img');
const findTitle = () => wrapper.find('h1');
const findLinkBtn = () => wrapper.findComponent(GlButton);
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders illustration', () => {
expect(findSvg().exists()).toBe(true);
});
it('renders title', () => {
expect(findTitle().exists()).toBe(true);
expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title);
});
it('renders CTA button', () => {
expect(findLinkBtn().exists()).toBe(true);
expect(findLinkBtn().text()).toBe(wrapper.vm.$options.i18n.linkBtnText);
expect(findLinkBtn().attributes('href')).toBe(MOCK_PATH_TO_DOWNSTREAM);
});
});
describe('without downstream pipeline', () => {
beforeEach(() => {
createComponent({ downstreamPipelinePath: undefined });
});
it('does not render CTA button', () => {
expect(findLinkBtn().exists()).toBe(false);
});
});
});
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';
describe('Bridge Sidebar', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(BridgeSidebar, {
provide: {
buildName: BUILD_NAME,
},
});
};
const findSidebar = () => wrapper.find('aside');
const findRetryDropdown = () => wrapper.find(GlDropdown);
const findToggle = () => wrapper.find(GlButton);
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders retry dropdown', () => {
expect(findRetryDropdown().exists()).toBe(true);
});
});
describe('sidebar expansion', () => {
beforeEach(() => {
createComponent();
});
it('toggles expansion on button click', async () => {
expect(findSidebar().classes()).not.toContain('gl-display-none');
findToggle().vm.$emit('click');
await nextTick();
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');
}
},
);
});
});
});
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';
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::JobsHelper do
describe 'jobs data' do
let(:project) { create(:project, :repository) }
let(:bridge) { create(:ci_bridge, status: :pending) }
subject(:bridge_data) { helper.bridge_data(bridge) }
before do
allow(helper)
.to receive(:image_path)
.and_return('/path/to/illustration')
end
it 'returns bridge data' do
expect(bridge_data).to eq({
"build_name" => bridge.name,
"empty-state-illustration-path" => '/path/to/illustration'
})
end
end
end
......@@ -29,7 +29,15 @@ RSpec.describe Gitlab::Ci::Status::Bridge::Common do
end
it { expect(subject).to have_details }
it { expect(subject.details_path).to include "pipelines/#{downstream_pipeline.id}" }
it { expect(subject.details_path).to include "jobs/#{bridge.id}" }
context 'with ci_retry_downstream_pipeline ff disabled' do
before do
stub_feature_flags(ci_retry_downstream_pipeline: false)
end
it { expect(subject.details_path).to include "pipelines/#{downstream_pipeline.id}" }
end
end
context 'when user does not have access to read downstream pipeline' do
......
......@@ -13,26 +13,47 @@ RSpec.describe 'projects/jobs/show' do
end
before do
assign(:build, build.present)
assign(:project, project)
assign(:builds, builds)
allow(view).to receive(:can?).and_return(true)
end
context 'when job is running' do
let(:build) { create(:ci_build, :trace_live, :running, pipeline: pipeline) }
context 'when showing a CI build' do
before do
assign(:build, build.present)
render
end
it 'does not show retry button' do
expect(rendered).not_to have_link('Retry')
it 'shows job vue app' do
expect(rendered).to have_css('#js-job-page')
expect(rendered).not_to have_css('#js-bridge-page')
end
context 'when job is running' do
let(:build) { create(:ci_build, :trace_live, :running, pipeline: pipeline) }
it 'does not show retry button' do
expect(rendered).not_to have_link('Retry')
end
it 'does not show New issue button' do
expect(rendered).not_to have_link('New issue')
end
end
end
context 'when showing a bridge job' do
let(:bridge) { create(:ci_bridge, status: :pending) }
before do
assign(:build, bridge)
render
end
it 'does not show New issue button' do
expect(rendered).not_to have_link('New issue')
it 'shows bridge vue app' do
expect(rendered).to have_css('#js-bridge-page')
expect(rendered).not_to have_css('#js-job-page')
end
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