Commit 8d9ccaa9 authored by pburdette's avatar pburdette Committed by Payton Burdette

Introduce jobs table vue

Do the groundwork for the new
jobs table. Introduce feature flag,
add tabs/count with filter and add
the graphql query with simple specs.
parent 2b932374
query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) {
project(fullPath: $fullPath) {
jobs(first: 20, statuses: $statuses) {
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
nodes {
detailedStatus {
icon
label
text
tooltip
action {
buttonTitle
icon
method
path
title
}
}
id
refName
refPath
tags
shortSha
commitPath
pipeline {
id
path
user {
webPath
avatarUrl
}
}
stage {
name
}
name
duration
finishedAt
coverage
retryable
playable
cancelable
active
}
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default (containerId = 'js-jobs-table') => {
const containerEl = document.getElementById(containerId);
if (!containerEl) {
return false;
}
const { fullPath, jobCounts, jobStatuses } = containerEl.dataset;
return new Vue({
el: containerEl,
apolloProvider,
provide: {
fullPath,
jobStatuses: JSON.parse(jobStatuses),
jobCounts: JSON.parse(jobCounts),
},
render(createElement) {
return createElement(JobsTableApp);
},
});
};
<script>
import { GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
const defaultTableClasses = {
tdClass: 'gl-p-5!',
thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!',
};
export default {
fields: [
{
key: 'status',
label: __('Status'),
...defaultTableClasses,
},
{
key: 'job',
label: __('Job'),
...defaultTableClasses,
},
{
key: 'pipeline',
label: __('Pipeline'),
...defaultTableClasses,
},
{
key: 'stage',
label: __('Stage'),
...defaultTableClasses,
},
{
key: 'name',
label: __('Name'),
...defaultTableClasses,
},
{
key: 'duration',
label: __('Duration'),
...defaultTableClasses,
},
{
key: 'coverage',
label: __('Coverage'),
...defaultTableClasses,
},
{
key: 'actions',
label: '',
...defaultTableClasses,
},
],
components: {
GlTable,
},
props: {
jobs: {
type: Array,
required: true,
},
},
};
</script>
<template>
<gl-table :items="jobs" :fields="$options.fields" />
</template>
<script>
import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
import { __ } from '~/locale';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableTabs from './jobs_table_tabs.vue';
export default {
i18n: {
errorMsg: __('There was an error fetching the jobs for your project.'),
},
components: {
GlAlert,
GlSkeletonLoader,
JobsTable,
JobsTableTabs,
},
inject: {
fullPath: {
default: '',
},
},
apollo: {
jobs: {
query: GetJobs,
variables() {
return {
fullPath: this.fullPath,
};
},
update({ project }) {
return project?.jobs;
},
error() {
this.hasError = true;
},
},
},
data() {
return {
jobs: null,
hasError: false,
isAlertDismissed: false,
};
},
computed: {
shouldShowAlert() {
return this.hasError && !this.isAlertDismissed;
},
},
methods: {
fetchJobsByStatus(scope) {
this.$apollo.queries.jobs.refetch({ statuses: scope });
},
},
};
</script>
<template>
<div>
<gl-alert
v-if="shouldShowAlert"
class="gl-mt-2"
variant="danger"
dismissible
@dismiss="isAlertDismissed = true"
>
{{ $options.i18n.errorMsg }}
</gl-alert>
<jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" />
<div v-if="$apollo.loading" class="gl-mt-5">
<gl-skeleton-loader
preserve-aspect-ratio="none"
equal-width-lines
:lines="5"
:width="600"
:height="66"
/>
</div>
<jobs-table v-else :jobs="jobs.nodes" />
</div>
</template>
<script>
import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlBadge,
GlTab,
GlTabs,
},
inject: {
jobCounts: {
default: {},
},
jobStatuses: {
default: {},
},
},
computed: {
tabs() {
return [
{
text: __('All'),
count: this.jobCounts.all,
scope: null,
testId: 'jobs-all-tab',
},
{
text: __('Pending'),
count: this.jobCounts.pending,
scope: this.jobStatuses.pending,
testId: 'jobs-pending-tab',
},
{
text: __('Running'),
count: this.jobCounts.running,
scope: this.jobStatuses.running,
testId: 'jobs-running-tab',
},
{
text: __('Finished'),
count: this.jobCounts.finished,
scope: [this.jobStatuses.success, this.jobStatuses.failed, this.jobStatuses.canceled],
testId: 'jobs-finished-tab',
},
];
},
},
};
</script>
<template>
<gl-tabs>
<gl-tab
v-for="tab in tabs"
:key="tab.text"
:title-link-attributes="{ 'data-testid': tab.testId }"
@click="$emit('fetchJobsByStatus', tab.scope)"
>
<template #title>
<span>{{ tab.text }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
</template>
</gl-tab>
</gl-tabs>
</template>
import Vue from 'vue'; import Vue from 'vue';
import initJobsTable from '~/jobs/components/table';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
const remainingTimeElements = document.querySelectorAll('.js-remaining-time'); if (gon.features?.jobsTableVue) {
remainingTimeElements.forEach( initJobsTable();
(el) => } else {
new Vue({ const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
el,
render(h) { remainingTimeElements.forEach(
return h(GlCountdown, { (el) =>
props: { new Vue({
endDateString: el.dateTime, el,
}, render(h) {
}); return h(GlCountdown, {
}, props: {
}), endDateString: el.dateTime,
); },
});
},
}),
);
}
...@@ -15,6 +15,9 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -15,6 +15,9 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action do
push_frontend_feature_flag(:jobs_table_vue, @project, default_enabled: :yaml)
end
layout 'project' layout 'project'
......
...@@ -18,6 +18,21 @@ module Ci ...@@ -18,6 +18,21 @@ module Ci
"retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs') "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs')
} }
end end
def job_counts
{
"all" => limited_counter_with_delimiter(@all_builds),
"pending" => limited_counter_with_delimiter(@all_builds.pending),
"running" => limited_counter_with_delimiter(@all_builds.running),
"finished" => limited_counter_with_delimiter(@all_builds.finished)
}
end
def job_statuses
statuses = Ci::HasStatus::AVAILABLE_STATUSES
statuses.to_h { |status| [status, status.upcase] }
end
end end
end end
......
- page_title _("Jobs") - page_title _("Jobs")
- add_page_specific_style 'page_bundles/ci_status' - add_page_specific_style 'page_bundles/ci_status'
.top-area - if Feature.enabled?(:jobs_table_vue, @project, default_enabled: :yaml)
- build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) } #js-jobs-table{ data: { full_path: @project.full_path, job_counts: job_counts.to_json, job_statuses: job_statuses.to_json } }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope - else
.top-area
- build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
.content-list.builds-content-list .content-list.builds-content-list
= render "table", builds: @builds, project: @project = render "table", builds: @builds, project: @project
---
name: jobs_table_vue
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57155
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/327500
milestone: '13.11'
type: development
group: group::continuous integration
default_enabled: false
...@@ -31695,6 +31695,9 @@ msgstr "" ...@@ -31695,6 +31695,9 @@ msgstr ""
msgid "There was an error fetching the environments information." msgid "There was an error fetching the environments information."
msgstr "" msgstr ""
msgid "There was an error fetching the jobs for your project."
msgstr ""
msgid "There was an error fetching the top labels for the selected group" msgid "There was an error fetching the top labels for the selected group"
msgstr "" msgstr ""
......
...@@ -12,6 +12,8 @@ RSpec.describe 'Project Jobs Permissions' do ...@@ -12,6 +12,8 @@ RSpec.describe 'Project Jobs Permissions' do
let_it_be(:job) { create(:ci_build, :running, :coverage, :trace_artifact, pipeline: pipeline) } let_it_be(:job) { create(:ci_build, :running, :coverage, :trace_artifact, pipeline: pipeline) }
before do before do
stub_feature_flags(jobs_table_vue: false)
sign_in(user) sign_in(user)
project.enable_ci project.enable_ci
......
...@@ -9,6 +9,7 @@ RSpec.describe 'User browses jobs' do ...@@ -9,6 +9,7 @@ RSpec.describe 'User browses jobs' do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_feature_flags(jobs_table_vue: false)
project.add_maintainer(user) project.add_maintainer(user)
project.enable_ci project.enable_ci
project.update_attribute(:build_coverage_regex, /Coverage (\d+)%/) project.update_attribute(:build_coverage_regex, /Coverage (\d+)%/)
......
...@@ -20,6 +20,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -20,6 +20,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
end end
before do before do
stub_feature_flags(jobs_table_vue: false)
project.add_role(user, user_access_level) project.add_role(user, user_access_level)
sign_in(user) sign_in(user)
end end
......
import { GlTable } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import { mockJobsInTable } from '../../mock_data';
describe('Jobs Table', () => {
let wrapper;
const findTable = () => wrapper.findComponent(GlTable);
const createComponent = (props = {}) => {
wrapper = shallowMount(JobsTable, {
propsData: {
jobs: mockJobsInTable,
...props,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('displays a table', () => {
expect(findTable().exists()).toBe(true);
});
});
import { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
describe('Jobs Table Tabs', () => {
let wrapper;
const defaultProps = {
jobCounts: { all: 848, pending: 0, running: 0, finished: 704 },
};
const findTab = (testId) => wrapper.findByTestId(testId);
const createComponent = () => {
wrapper = extendedWrapper(
mount(JobsTableTabs, {
provide: {
...defaultProps,
},
}),
);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it.each`
tabId | text | count
${'jobs-all-tab'} | ${'All'} | ${defaultProps.jobCounts.all}
${'jobs-pending-tab'} | ${'Pending'} | ${defaultProps.jobCounts.pending}
${'jobs-running-tab'} | ${'Running'} | ${defaultProps.jobCounts.running}
${'jobs-finished-tab'} | ${'Finished'} | ${defaultProps.jobCounts.finished}
`('displays the right tab text and badge count', ({ tabId, text, count }) => {
expect(trimText(findTab(tabId).text())).toBe(`${text} ${count}`);
});
});
...@@ -1276,3 +1276,131 @@ export const mockPipelineDetached = { ...@@ -1276,3 +1276,131 @@ export const mockPipelineDetached = {
name: 'test-branch', name: 'test-branch',
}, },
}; };
export const mockJobsInTable = [
{
detailedStatus: {
icon: 'status_manual',
label: 'manual play action',
text: 'manual',
tooltip: 'manual action',
action: {
buttonTitle: 'Trigger this manual action',
icon: 'play',
method: 'post',
path: '/root/ci-project/-/jobs/2004/play',
title: 'Play',
__typename: 'StatusAction',
},
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/2004',
refName: 'master',
refPath: '/root/ci-project/-/commits/master',
tags: [],
shortSha: '2d5d8323',
commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/423',
path: '/root/ci-project/-/pipelines/423',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'User',
},
__typename: 'Pipeline',
},
stage: { name: 'test', __typename: 'CiStage' },
name: 'test_manual_job',
duration: null,
finishedAt: null,
coverage: null,
retryable: false,
playable: true,
cancelable: false,
active: false,
__typename: 'CiJob',
},
{
detailedStatus: {
icon: 'status_skipped',
label: 'skipped',
text: 'skipped',
tooltip: 'skipped',
action: null,
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/2021',
refName: 'master',
refPath: '/root/ci-project/-/commits/master',
tags: [],
shortSha: '2d5d8323',
commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/425',
path: '/root/ci-project/-/pipelines/425',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'User',
},
__typename: 'Pipeline',
},
stage: { name: 'test', __typename: 'CiStage' },
name: 'coverage_job',
duration: null,
finishedAt: null,
coverage: null,
retryable: false,
playable: false,
cancelable: false,
active: false,
__typename: 'CiJob',
},
{
detailedStatus: {
icon: 'status_success',
label: 'passed',
text: 'passed',
tooltip: 'passed',
action: {
buttonTitle: 'Retry this job',
icon: 'retry',
method: 'post',
path: '/root/ci-project/-/jobs/2015/retry',
title: 'Retry',
__typename: 'StatusAction',
},
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/2015',
refName: 'master',
refPath: '/root/ci-project/-/commits/master',
tags: [],
shortSha: '2d5d8323',
commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/424',
path: '/root/ci-project/-/pipelines/424',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'User',
},
__typename: 'Pipeline',
},
stage: { name: 'deploy', __typename: 'CiStage' },
name: 'artifact_job',
duration: 2,
finishedAt: '2021-04-01T17:36:18Z',
coverage: null,
retryable: true,
playable: false,
cancelable: false,
active: false,
__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