Commit 7b5db255 authored by mgandres's avatar mgandres

Create bridge vue app

This creates a separate vue app for the bridge show page.
The app is separated from the existing jobs page to keep
the separation of data and behaviors clean on the frontend
and because it will use graphQL in the future to implement
trigger retry actions (job vue app uses REST and vuex).
parent 15e47a89
<script>
import BridgeEmptyState from './components/empty_state.vue';
import BridgeSidebar from './components/sidebar.vue';
export default {
name: 'BridgePageApp',
components: {
BridgeEmptyState,
BridgeSidebar,
},
inject: {
buildName: {
type: Object,
required: false,
default: {},
},
},
};
</script>
<template>
<div>
<!-- TODO: get job details and show CI header -->
<bridge-empty-state />
<bridge-sidebar />
</div>
</template>
<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: {
downstreamPipelinePath: {
type: String,
default: '#',
},
emptyStateIllustrationPath: {
type: String,
require: true,
},
},
};
</script>
<template>
<!-- TODO: add downstream pipeline path -->
<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 center">{{ $options.i18n.title }}</h1>
<gl-button class="gl-mt-3" 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';
export default {
name: 'BridgeSidebar',
i18n: {
...JOB_SIDEBAR,
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 (breakpoint === 'xs' || breakpoint === 'sm') {
this.isSidebarExpanded = false;
} else if (!this.isSidebarExpanded) {
this.isSidebarExpanded = true;
}
},
},
};
</script>
<template>
<aside
class="right-sidebar build-sidebar"
:class="{
'right-sidebar-expanded': isSidebarExpanded,
'right-sidebar-collapsed': !isSidebarExpanded,
}"
data-offset-top="101"
data-spy="affix"
>
<div class="sidebar-container">
<div class="blocks-container">
<div class="gl-py-5 gl-display-flex gl-align-items-center">
<tooltip-on-truncate :title="buildName" truncate-target="child"
><h4 class="my-0 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
class="retry-trigger-job-btn"
data-testid="retry-dropdown"
text="Retry"
category="primary"
variant="confirm"
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 -->
</div>
</div>
</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-vue-app');
const bridgeElement = document.getElementById('js-bridge-vue-app');
if (jobElement) {
initializeJobPage(jobElement);
} else {
initializeBridgePage(bridgeElement);
}
};
......@@ -227,6 +227,12 @@
}
}
.retry-trigger-job-btn {
ul {
left: -110px !important;
}
}
@include media-breakpoint-down(md) {
.content-list {
&.builds-content-list {
......
......@@ -19,6 +19,15 @@ module Ci
}
end
def bridge_data(build)
{
"build_name" => build.name,
# TODO: replace with official design
"empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg')
}
end
def job_counts
{
"all" => limited_counter_with_delimiter(@all_builds),
......
......@@ -10,4 +10,4 @@
- if @build.is_a? ::Ci::Build
#js-job-vue-app{ data: jobs_data }
- else
.empty-row
#js-bridge-vue-app{ data: bridge_data(@build) }
......@@ -29916,6 +29916,12 @@ msgstr ""
msgid "Retry migration"
msgstr ""
msgid "Retry the downstream pipeline"
msgstr ""
msgid "Retry the trigger job"
msgstr ""
msgid "Retry this job"
msgstr ""
......@@ -35885,6 +35891,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 ""
......@@ -38590,6 +38599,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 = () => {
wrapper = shallowMount(BridgeEmptyState, {
provide: {
downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM,
emptyStateIllustrationPath: MOCK_EMPTY_ILLUSTRATION_PATH,
},
});
};
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);
});
});
});
import { GlButton } 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('[data-testid="retry-dropdown"]');
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()).toContain('right-sidebar-expanded');
expect(findSidebar().classes()).not.toContain('right-sidebar-collapsed');
findToggle().vm.$emit('click');
await nextTick();
expect(findSidebar().classes()).toContain('right-sidebar-collapsed');
expect(findSidebar().classes()).not.toContain();
});
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);
const sidebarClass = isSidebarExpanded
? 'right-sidebar-expanded'
: 'right-sidebar-collapsed';
wrapper.vm.onResize();
await nextTick();
expect(findSidebar().classes()).toContain(sidebarClass);
},
);
});
});
});
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
......@@ -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-vue-app')
expect(rendered).not_to have_css('#js-bridge-vue-app')
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-vue-app')
expect(rendered).not_to have_css('#js-job-vue-app')
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