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 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 JobApp from './components/job_app.vue';
import createStore from './store'; import createStore from './store';
export default () => { const initializeJobPage = (element) => {
const element = document.getElementById('js-job-vue-app');
const store = createStore(); const store = createStore();
// Let's start initializing the store (i.e. fetching data) right away // Let's start initializing the store (i.e. fetching data) right away
...@@ -51,3 +52,35 @@ export default () => { ...@@ -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 ...@@ -4,8 +4,8 @@ class Projects::JobsController < Projects::ApplicationController
include SendFileUpload include SendFileUpload
include ContinueParams include ContinueParams
before_action :find_job_as_build, except: [:index, :play] before_action :find_job_as_build, except: [:index, :play, :show]
before_action :find_job_as_processable, only: [:play] before_action :find_job_as_processable, only: [:play, :show]
before_action :authorize_read_build_trace!, only: [:trace, :raw] before_action :authorize_read_build_trace!, only: [:trace, :raw]
before_action :authorize_read_build! before_action :authorize_read_build!
before_action :authorize_update_build!, before_action :authorize_update_build!,
......
...@@ -19,6 +19,13 @@ module Ci ...@@ -19,6 +19,13 @@ module Ci
} }
end end
def bridge_data(build)
{
"build_name" => build.name,
"empty-state-illustration-path" => image_path('illustrations/job-trigger-md.svg')
}
end
def job_counts def job_counts
{ {
"all" => limited_counter_with_delimiter(@all_builds), "all" => limited_counter_with_delimiter(@all_builds),
......
...@@ -7,4 +7,7 @@ ...@@ -7,4 +7,7 @@
= render_if_exists "shared/shared_runners_minutes_limit_flash_message" = 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,8 +16,12 @@ module Gitlab ...@@ -16,8 +16,12 @@ module Gitlab
def details_path def details_path
return unless can?(user, :read_pipeline, downstream_pipeline) return unless can?(user, :read_pipeline, 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) project_pipeline_path(downstream_project, downstream_pipeline)
end end
end
def has_action? def has_action?
false false
......
...@@ -29925,6 +29925,12 @@ msgstr "" ...@@ -29925,6 +29925,12 @@ msgstr ""
msgid "Retry migration" msgid "Retry migration"
msgstr "" msgstr ""
msgid "Retry the downstream pipeline"
msgstr ""
msgid "Retry the trigger job"
msgstr ""
msgid "Retry this job" msgid "Retry this job"
msgstr "" msgstr ""
...@@ -35897,6 +35903,9 @@ 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." msgid "This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes."
msgstr "" 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." 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 "" msgstr ""
...@@ -38602,6 +38611,9 @@ msgstr "" ...@@ -38602,6 +38611,9 @@ msgstr ""
msgid "View documentation" msgid "View documentation"
msgstr "" msgstr ""
msgid "View downstream pipeline"
msgstr ""
msgid "View eligible approvers" msgid "View eligible approvers"
msgstr "" 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,8 +29,16 @@ RSpec.describe Gitlab::Ci::Status::Bridge::Common do ...@@ -29,8 +29,16 @@ RSpec.describe Gitlab::Ci::Status::Bridge::Common do
end end
it { expect(subject).to have_details } it { expect(subject).to have_details }
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}" } it { expect(subject.details_path).to include "pipelines/#{downstream_pipeline.id}" }
end end
end
context 'when user does not have access to read downstream pipeline' do context 'when user does not have access to read downstream pipeline' do
it { expect(subject).not_to have_details } it { expect(subject).not_to have_details }
......
...@@ -13,20 +13,26 @@ RSpec.describe 'projects/jobs/show' do ...@@ -13,20 +13,26 @@ RSpec.describe 'projects/jobs/show' do
end end
before do before do
assign(:build, build.present)
assign(:project, project) assign(:project, project)
assign(:builds, builds) assign(:builds, builds)
allow(view).to receive(:can?).and_return(true) allow(view).to receive(:can?).and_return(true)
end end
context 'when job is running' do context 'when showing a CI build' do
let(:build) { create(:ci_build, :trace_live, :running, pipeline: pipeline) }
before do before do
assign(:build, build.present)
render render
end end
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 it 'does not show retry button' do
expect(rendered).not_to have_link('Retry') expect(rendered).not_to have_link('Retry')
end end
...@@ -35,4 +41,19 @@ RSpec.describe 'projects/jobs/show' do ...@@ -35,4 +41,19 @@ RSpec.describe 'projects/jobs/show' do
expect(rendered).not_to have_link('New issue') expect(rendered).not_to have_link('New issue')
end end
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 '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 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