Commit 36a75876 authored by Marius Bobin's avatar Marius Bobin

Merge branch '341497-refactor-jobs-tab-pipeline-view' into 'master'

Refactor jobs tab to Vue/GraphQL

See merge request gitlab-org/gitlab!76146
parents 7e9c1ef8 3287edbf
import { s__, __ } from '~/locale';
import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
export const GRAPHQL_PAGE_SIZE = 30;
......@@ -33,3 +34,66 @@ export const PLAY_JOB_CONFIRMATION_MESSAGE = s__(
`DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after its timer finishes.`,
);
export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?');
/* Table constants */
const defaultTableClasses = {
tdClass: 'gl-p-5!',
thClass: DEFAULT_TH_CLASSES,
};
// eslint-disable-next-line @gitlab/require-i18n-strings
const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`;
export const DEFAULT_FIELDS = [
{
key: 'status',
label: __('Status'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'job',
label: __('Job'),
...defaultTableClasses,
columnClass: 'gl-w-20p',
},
{
key: 'pipeline',
label: __('Pipeline'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'stage',
label: __('Stage'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'name',
label: __('Name'),
...defaultTableClasses,
columnClass: 'gl-w-15p',
},
{
key: 'duration',
label: __('Duration'),
...defaultTableClasses,
columnClass: 'gl-w-15p',
},
{
key: 'coverage',
label: __('Coverage'),
tdClass: coverageTdClasses,
thClass: defaultTableClasses.thClass,
columnClass: 'gl-w-10p',
},
{
key: 'actions',
label: '',
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
];
export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline');
<script>
import { GlTable } from '@gitlab/ui';
import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
import { s__, __ } from '~/locale';
import { s__ } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import ActionsCell from './cells/actions_cell.vue';
import DurationCell from './cells/duration_cell.vue';
import JobCell from './cells/job_cell.vue';
import PipelineCell from './cells/pipeline_cell.vue';
const defaultTableClasses = {
tdClass: 'gl-p-5!',
thClass: DEFAULT_TH_CLASSES,
};
// eslint-disable-next-line @gitlab/require-i18n-strings
const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`;
import { DEFAULT_FIELDS } from './constants';
export default {
i18n: {
emptyText: s__('Jobs|No jobs to show'),
},
fields: [
{
key: 'status',
label: __('Status'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'job',
label: __('Job'),
...defaultTableClasses,
columnClass: 'gl-w-20p',
},
{
key: 'pipeline',
label: __('Pipeline'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'stage',
label: __('Stage'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'name',
label: __('Name'),
...defaultTableClasses,
columnClass: 'gl-w-15p',
},
{
key: 'duration',
label: __('Duration'),
...defaultTableClasses,
columnClass: 'gl-w-15p',
},
{
key: 'coverage',
label: __('Coverage'),
tdClass: coverageTdClasses,
thClass: defaultTableClasses.thClass,
columnClass: 'gl-w-10p',
},
{
key: 'actions',
label: '',
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
],
components: {
ActionsCell,
CiBadge,
......@@ -83,6 +25,11 @@ export default {
type: Array,
required: true,
},
tableFields: {
type: Array,
required: false,
default: () => DEFAULT_FIELDS,
},
},
methods: {
formatCoverage(coverage) {
......@@ -95,7 +42,7 @@ export default {
<template>
<gl-table
:items="jobs"
:fields="$options.fields"
:fields="tableFields"
:tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }"
:empty-text="$options.i18n.emptyText"
show-empty
......
<script>
import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
import produce from 'immer';
import createFlash from '~/flash';
import { __ } from '~/locale';
import eventHub from '~/jobs/components/table/event_hub';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import { JOBS_TAB_FIELDS } from '~/jobs/components/table/constants';
import getPipelineJobs from '../../graphql/queries/get_pipeline_jobs.query.graphql';
export default {
fields: JOBS_TAB_FIELDS,
components: {
GlIntersectionObserver,
GlLoadingIcon,
GlSkeletonLoader,
JobsTable,
},
inject: {
fullPath: {
default: '',
},
pipelineIid: {
default: '',
},
},
apollo: {
jobs: {
query: getPipelineJobs,
variables() {
return {
...this.queryVariables,
};
},
update(data) {
return data.project?.pipeline?.jobs?.nodes || [];
},
result({ data }) {
this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {};
},
error() {
createFlash({ message: __('An error occured while fetching the pipelines jobs.') });
},
},
},
data() {
return {
jobs: [],
jobsPageInfo: {},
firstLoad: true,
};
},
computed: {
queryVariables() {
return {
fullPath: this.fullPath,
iid: this.pipelineIid,
};
},
},
mounted() {
eventHub.$on('jobActionPerformed', this.handleJobAction);
},
beforeDestroy() {
eventHub.$off('jobActionPerformed', this.handleJobAction);
},
methods: {
handleJobAction() {
this.firstLoad = true;
this.$apollo.queries.jobs.refetch();
},
fetchMoreJobs() {
this.firstLoad = false;
this.$apollo.queries.jobs.fetchMore({
variables: {
...this.queryVariables,
after: this.jobsPageInfo.endCursor,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
const results = produce(fetchMoreResult, (draftData) => {
draftData.project.pipeline.jobs.nodes = [
...previousResult.project.pipeline.jobs.nodes,
...draftData.project.pipeline.jobs.nodes,
];
});
return results;
},
});
},
},
};
</script>
<template>
<div>
<div v-if="$apollo.loading && firstLoad" class="gl-mt-5">
<gl-skeleton-loader :width="1248" :height="73">
<circle cx="748.031" cy="37.7193" r="15.0307" />
<circle cx="787.241" cy="37.7193" r="15.0307" />
<circle cx="827.759" cy="37.7193" r="15.0307" />
<circle cx="866.969" cy="37.7193" r="15.0307" />
<circle cx="380" cy="37" r="18" />
<rect x="432" y="19" width="126.587" height="15" />
<rect x="432" y="41" width="247" height="15" />
<rect x="158" y="19" width="86.1" height="15" />
<rect x="158" y="41" width="168" height="15" />
<rect x="22" y="19" width="96" height="36" />
<rect x="924" y="30" width="96" height="15" />
<rect x="1057" y="20" width="166" height="35" />
</gl-skeleton-loader>
</div>
<jobs-table v-else :jobs="jobs" :table-fields="$options.fields" />
<gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs">
<gl-loading-icon v-if="$apollo.loading" size="md" />
</gl-intersection-observer>
</div>
</template>
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) {
project(fullPath: $fullPath) {
id
pipeline(iid: $iid) {
id
jobs(after: $after, first: 20) {
pageInfo {
...PageInfo
}
nodes {
artifacts {
nodes {
downloadPath
fileType
}
}
allowFailure
status
scheduledAt
manualJob
triggered
createdByTag
detailedStatus {
id
detailsPath
group
icon
label
text
tooltip
action {
id
buttonTitle
icon
method
path
title
}
}
id
refName
refPath
tags
shortSha
commitPath
stage {
id
name
}
name
duration
finishedAt
coverage
retryable
playable
cancelable
active
stuck
userPermissions {
readBuild
readJobArtifacts
updateBuild
}
}
}
}
}
}
......@@ -3,6 +3,7 @@ import { __ } from '~/locale';
import createDagApp from './pipeline_details_dag';
import { createPipelinesDetailApp } from './pipeline_details_graph';
import { createPipelineHeaderApp } from './pipeline_details_header';
import { createPipelineJobsApp } from './pipeline_details_jobs';
import { apolloProvider } from './pipeline_shared_client';
import { createTestDetails } from './pipeline_test_details';
......@@ -11,6 +12,7 @@ const SELECTORS = {
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
PIPELINE_JOBS: '#js-pipeline-jobs-vue',
};
export default async function initPipelineDetailsBundle() {
......@@ -55,4 +57,14 @@ export default async function initPipelineDetailsBundle() {
message: __('An error occurred while loading the Test Reports tab.'),
});
}
try {
if (gon.features?.jobsTabVue) {
createPipelineJobsApp(SELECTORS.PIPELINE_JOBS);
}
} catch {
createFlash({
message: __('An error occurred while loading the Jobs tab.'),
});
}
}
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import JobsApp from './components/jobs/jobs_app.vue';
Vue.use(VueApollo);
Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export const createPipelineJobsApp = (selector) => {
const containerEl = document.querySelector(selector);
if (!containerEl) {
return false;
}
const { fullPath, pipelineIid } = containerEl.dataset;
return new Vue({
el: containerEl,
apolloProvider,
provide: {
fullPath,
pipelineIid,
},
render(createElement) {
return createElement(JobsApp);
},
});
};
......@@ -14,6 +14,10 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :ensure_pipeline, only: [:show, :downloadable_artifacts]
before_action do
push_frontend_feature_flag(:jobs_tab_vue, @project, default_enabled: :yaml)
end
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }
......
......@@ -29,17 +29,20 @@
#js-tab-builds.tab-pane
- if stages.present?
.table-holder.pipeline-holder
%table.table.ci-table.pipeline
%thead
%tr
%th= _('Status')
%th= _('Name')
%th= _('Job ID')
%th
%th= _('Coverage')
%th
= render partial: "projects/stage/stage", collection: stages, as: :stage
- if Feature.enabled?(:jobs_tab_vue, @project, default_enabled: :yaml)
#js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } }
- else
.table-holder.pipeline-holder
%table.table.ci-table.pipeline
%thead
%tr
%th= _('Status')
%th= _('Name')
%th= _('Job ID')
%th
%th= _('Coverage')
%th
= render partial: "projects/stage/stage", collection: stages, as: :stage
- if @pipeline.failed_builds.present?
#js-tab-failures.build-failures.tab-pane.build-page
......
---
name: jobs_tab_vue
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76146
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/347371
milestone: '14.6'
type: development
group: group::pipeline execution
default_enabled: false
......@@ -3653,6 +3653,9 @@ msgstr ""
msgid "An error in reporting in which a test result incorrectly indicates the presence of a vulnerability in a system when the vulnerability is not present."
msgstr ""
msgid "An error occured while fetching the pipelines jobs."
msgstr ""
msgid "An error occurred adding a draft to the thread."
msgstr ""
......@@ -3860,6 +3863,9 @@ msgstr ""
msgid "An error occurred while loading projects."
msgstr ""
msgid "An error occurred while loading the Jobs tab."
msgstr ""
msgid "An error occurred while loading the Needs tab."
msgstr ""
......
......@@ -24,14 +24,15 @@ RSpec.describe 'Commits' do
end
context 'commit status is Generic Commit Status' do
let!(:status) { create(:generic_commit_status, pipeline: pipeline) }
let!(:status) { create(:generic_commit_status, pipeline: pipeline, ref: pipeline.ref) }
before do
project.add_reporter(user)
end
describe 'Commit builds' do
describe 'Commit builds with jobs_tab_feature flag off' do
before do
stub_feature_flags(jobs_tab_vue: false)
visit pipeline_path(pipeline)
end
......@@ -89,8 +90,9 @@ RSpec.describe 'Commits' do
end
end
context 'Download artifacts' do
context 'Download artifacts with jobs_tab_vue feature flag off' do
before do
stub_feature_flags(jobs_tab_vue: false)
create(:ci_job_artifact, :archive, file: artifacts_file, job: build)
end
......@@ -118,8 +120,9 @@ RSpec.describe 'Commits' do
end
end
context "when logged as reporter" do
context "when logged as reporter and with jobs_tab_vue feature flag off" do
before do
stub_feature_flags(jobs_tab_vue: false)
project.add_reporter(user)
create(:ci_job_artifact, :archive, file: artifacts_file, job: build)
visit pipeline_path(pipeline)
......
......@@ -53,6 +53,7 @@ RSpec.describe 'Pipeline', :js do
pipeline: pipeline,
name: 'jenkins',
stage: 'external',
ref: 'master',
target_url: 'http://gitlab.com/status')
end
end
......@@ -915,7 +916,7 @@ RSpec.describe 'Pipeline', :js do
end
end
describe 'GET /:project/-/pipelines/:id/builds' do
describe 'GET /:project/-/pipelines/:id/builds with jobs_tab_vue feature flag turned off' do
include_context 'pipeline builds'
let_it_be(:project) { create(:project, :repository) }
......@@ -923,6 +924,7 @@ RSpec.describe 'Pipeline', :js do
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
stub_feature_flags(jobs_tab_vue: false)
visit builds_project_pipeline_path(project, pipeline)
end
......
......@@ -625,7 +625,7 @@ RSpec.describe 'Pipelines', :js do
create_build('test', 1, 'audit', :created)
create_build('deploy', 2, 'production', :created)
create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3)
create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3, ref: 'master')
visit project_pipeline_path(project, pipeline)
wait_for_requests
......
import { GlIntersectionObserver, GlSkeletonLoader } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import JobsApp from '~/pipelines/components/jobs/jobs_app.vue';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import getPipelineJobsQuery from '~/pipelines/graphql/queries/get_pipeline_jobs.query.graphql';
import { mockPipelineJobsQueryResponse } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
jest.mock('~/flash');
describe('Jobs app', () => {
let wrapper;
let resolverSpy;
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findJobsTable = () => wrapper.findComponent(JobsTable);
const triggerInfiniteScroll = () =>
wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
const createMockApolloProvider = (resolver) => {
const requestHandlers = [[getPipelineJobsQuery, resolver]];
return createMockApollo(requestHandlers);
};
const createComponent = (resolver) => {
wrapper = shallowMount(JobsApp, {
provide: {
fullPath: 'root/ci-project',
pipelineIid: 1,
},
localVue,
apolloProvider: createMockApolloProvider(resolver),
});
};
beforeEach(() => {
resolverSpy = jest.fn().mockResolvedValue(mockPipelineJobsQueryResponse);
});
afterEach(() => {
wrapper.destroy();
});
it('displays the loading state', () => {
createComponent(resolverSpy);
expect(findSkeletonLoader().exists()).toBe(true);
expect(findJobsTable().exists()).toBe(false);
});
it('displays the jobs table', async () => {
createComponent(resolverSpy);
await waitForPromises();
expect(findJobsTable().exists()).toBe(true);
expect(findSkeletonLoader().exists()).toBe(false);
expect(createFlash).not.toHaveBeenCalled();
});
it('handles job fetch error correctly', async () => {
resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error'));
createComponent(resolverSpy);
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occured while fetching the pipelines jobs.',
});
});
it('handles infinite scrolling by calling fetchMore', async () => {
createComponent(resolverSpy);
await waitForPromises();
triggerInfiniteScroll();
expect(resolverSpy).toHaveBeenCalledWith({
after: 'eyJpZCI6Ijg0NyJ9',
fullPath: 'root/ci-project',
iid: 1,
});
});
it('does not display main loading state again after fetchMore', async () => {
createComponent(resolverSpy);
expect(findSkeletonLoader().exists()).toBe(true);
await waitForPromises();
triggerInfiniteScroll();
expect(findSkeletonLoader().exists()).toBe(false);
});
});
......@@ -505,3 +505,132 @@ export const mockSearch = [
export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag'];
export const mockPipelineJobsQueryResponse = {
data: {
project: {
id: 'gid://gitlab/Project/20',
__typename: 'Project',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/224',
__typename: 'Pipeline',
jobs: {
__typename: 'CiJobConnection',
pageInfo: {
endCursor: 'eyJpZCI6Ijg0NyJ9',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'eyJpZCI6IjYyMCJ9',
__typename: 'PageInfo',
},
nodes: [
{
artifacts: {
nodes: [
{
downloadPath: '/root/ci-project/-/jobs/620/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
allowFailure: false,
status: 'SUCCESS',
scheduledAt: null,
manualJob: false,
triggered: null,
createdByTag: false,
detailedStatus: {
id: 'success-620-620',
detailsPath: '/root/ci-project/-/jobs/620',
group: 'success',
icon: 'status_success',
label: 'passed',
text: 'passed',
tooltip: 'passed (retried)',
action: null,
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/620',
refName: 'main',
refPath: '/root/ci-project/-/commits/main',
tags: [],
shortSha: '5acce24b',
commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e',
stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' },
name: 'coverage_job',
duration: 4,
finishedAt: '2021-12-06T14:13:49Z',
coverage: 82.71,
retryable: false,
playable: false,
cancelable: false,
active: false,
stuck: false,
userPermissions: {
readBuild: true,
readJobArtifacts: true,
updateBuild: true,
__typename: 'JobPermissions',
},
__typename: 'CiJob',
},
{
artifacts: {
nodes: [
{
downloadPath: '/root/ci-project/-/jobs/619/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
allowFailure: false,
status: 'SUCCESS',
scheduledAt: null,
manualJob: false,
triggered: null,
createdByTag: false,
detailedStatus: {
id: 'success-619-619',
detailsPath: '/root/ci-project/-/jobs/619',
group: 'success',
icon: 'status_success',
label: 'passed',
text: 'passed',
tooltip: 'passed (retried)',
action: null,
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/619',
refName: 'main',
refPath: '/root/ci-project/-/commits/main',
tags: [],
shortSha: '5acce24b',
commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e',
stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' },
name: 'test_job_two',
duration: 4,
finishedAt: '2021-12-06T14:13:44Z',
coverage: null,
retryable: false,
playable: false,
cancelable: false,
active: false,
stuck: false,
userPermissions: {
readBuild: true,
readJobArtifacts: true,
updateBuild: true,
__typename: 'JobPermissions',
},
__typename: 'CiJob',
},
],
},
},
},
},
};
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