Commit 82f393ae authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'pb-introduce-jobs-table-vue' into 'master'

Introduce jobs_table_vue feature flag with blank table [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!57155
parents 3461e56a 1bac9ee2
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,7 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -15,6 +15,7 @@ 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 :push_jobs_table_vue, only: [:index]
layout 'project' layout 'project'
...@@ -256,4 +257,8 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -256,4 +257,8 @@ class Projects::JobsController < Projects::ApplicationController
::Gitlab::Workhorse.channel_websocket(service) ::Gitlab::Workhorse.channel_websocket(service)
end end
def push_jobs_table_vue
push_frontend_feature_flag(:jobs_table_vue, @project, default_enabled: :yaml)
end
end end
...@@ -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
...@@ -31707,6 +31707,9 @@ msgstr "" ...@@ -31707,6 +31707,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