Commit eb6e0e78 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas Committed by Simon Knox

Add actions cells for the jobs table

This is part of the refactor behind
the jobs_table_vue feature flag
parent c37c54e4
<script> <script>
import { GlButton, GlButtonGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
import {
ACTIONS_DOWNLOAD_ARTIFACTS,
ACTIONS_START_NOW,
ACTIONS_UNSCHEDULE,
ACTIONS_PLAY,
ACTIONS_RETRY,
CANCEL,
GENERIC_ERROR,
JOB_SCHEDULED,
PLAY_JOB_CONFIRMATION_MESSAGE,
RUN_JOB_NOW_HEADER_TITLE,
} from '../constants';
import eventHub from '../event_hub';
import cancelJobMutation from '../graphql/mutations/job_cancel.mutation.graphql';
import playJobMutation from '../graphql/mutations/job_play.mutation.graphql';
import retryJobMutation from '../graphql/mutations/job_retry.mutation.graphql';
import unscheduleJobMutation from '../graphql/mutations/job_unschedule.mutation.graphql';
export default { export default {
ACTIONS_DOWNLOAD_ARTIFACTS,
ACTIONS_START_NOW,
ACTIONS_UNSCHEDULE,
ACTIONS_PLAY,
ACTIONS_RETRY,
CANCEL,
GENERIC_ERROR,
PLAY_JOB_CONFIRMATION_MESSAGE,
RUN_JOB_NOW_HEADER_TITLE,
jobRetry: 'jobRetry',
jobCancel: 'jobCancel',
jobPlay: 'jobPlay',
jobUnschedule: 'jobUnschedule',
playJobModalId: 'play-job-modal',
components: {
GlButton,
GlButtonGroup,
GlCountdown,
GlModal,
GlSprintf,
},
directives: {
GlModalDirective,
},
inject: {
admin: {
default: false,
},
},
props: { props: {
job: { job: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
computed: {
artifactDownloadPath() {
return this.job.artifacts?.nodes[0]?.downloadPath;
},
canReadJob() {
return this.job.userPermissions?.readBuild;
},
isActive() {
return this.job.active;
},
manualJobPlayable() {
return this.job.playable && !this.admin && this.job.manualJob;
},
isRetryable() {
return this.job.retryable;
},
isScheduled() {
return this.job.status === JOB_SCHEDULED;
},
scheduledAt() {
return this.job.scheduledAt;
},
currentJobActionPath() {
return this.job.detailedStatus?.action?.path;
},
currentJobMethod() {
return this.job.detailedStatus?.action?.method;
},
shouldDisplayArtifacts() {
return this.job.userPermissions?.readJobArtifacts && this.job.artifacts?.nodes.length > 0;
},
},
methods: {
async postJobAction(name, mutation) {
try {
const {
data: {
[name]: { errors },
},
} = await this.$apollo.mutate({
mutation,
variables: { id: this.job.id },
});
if (errors.length > 0) {
this.reportFailure();
} else {
eventHub.$emit('jobActionPerformed');
}
} catch {
this.reportFailure();
}
},
reportFailure() {
const toastProps = {
text: this.$options.GENERIC_ERROR,
variant: 'danger',
};
this.$toast.show(toastProps.text, {
variant: toastProps.variant,
});
},
cancelJob() {
this.postJobAction(this.$options.jobCancel, cancelJobMutation);
},
retryJob() {
this.postJobAction(this.$options.jobRetry, retryJobMutation);
},
playJob() {
this.postJobAction(this.$options.jobPlay, playJobMutation);
},
unscheduleJob() {
this.postJobAction(this.$options.jobUnschedule, unscheduleJobMutation);
},
},
}; };
</script> </script>
<template> <template>
<div></div> <gl-button-group>
<gl-button
v-if="shouldDisplayArtifacts"
icon="download"
:title="$options.ACTIONS_DOWNLOAD_ARTIFACTS"
:href="artifactDownloadPath"
rel="nofollow"
download
data-testid="download-artifacts"
/>
<template v-if="canReadJob">
<gl-button v-if="isActive" icon="cancel" :title="$options.CANCEL" @click="cancelJob()" />
<template v-else-if="isScheduled">
<gl-button icon="planning" disabled data-testid="countdown">
<gl-countdown :end-date-string="scheduledAt" />
</gl-button>
<gl-button
v-gl-modal-directive="$options.playJobModalId"
icon="play"
:title="$options.ACTIONS_START_NOW"
data-testid="play-scheduled"
/>
<gl-modal
:modal-id="$options.playJobModalId"
:title="$options.RUN_JOB_NOW_HEADER_TITLE"
@primary="playJob()"
>
<gl-sprintf :message="$options.PLAY_JOB_CONFIRMATION_MESSAGE">
<template #job_name>{{ job.name }}</template>
</gl-sprintf>
</gl-modal>
<gl-button
icon="time-out"
:title="$options.ACTIONS_UNSCHEDULE"
data-testid="unschedule"
@click="unscheduleJob()"
/>
</template>
<template v-else>
<!--Note: This is the manual job play button -->
<gl-button
v-if="manualJobPlayable"
icon="play"
:title="$options.ACTIONS_PLAY"
data-testid="play"
@click="playJob()"
/>
<gl-button
v-else-if="isRetryable"
icon="repeat"
:title="$options.ACTIONS_RETRY"
:method="currentJobMethod"
data-testid="retry"
@click="retryJob()"
/>
</template>
</template>
</gl-button-group>
</template> </template>
import { s__, __ } from '~/locale';
export const GRAPHQL_PAGE_SIZE = 30; export const GRAPHQL_PAGE_SIZE = 30;
export const initialPaginationState = { export const initialPaginationState = {
...@@ -7,3 +9,24 @@ export const initialPaginationState = { ...@@ -7,3 +9,24 @@ export const initialPaginationState = {
first: GRAPHQL_PAGE_SIZE, first: GRAPHQL_PAGE_SIZE,
last: null, last: null,
}; };
/* Error constants */
export const POST_FAILURE = 'post_failure';
export const DEFAULT = 'default';
/* Job Status Constants */
export const JOB_SCHEDULED = 'SCHEDULED';
/* i18n */
export const ACTIONS_DOWNLOAD_ARTIFACTS = __('Download artifacts');
export const ACTIONS_START_NOW = s__('DelayedJobs|Start now');
export const ACTIONS_UNSCHEDULE = s__('DelayedJobs|Unschedule');
export const ACTIONS_PLAY = __('Play');
export const ACTIONS_RETRY = __('Retry');
export const CANCEL = __('Cancel');
export const GENERIC_ERROR = __('An error occurred while making the request.');
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?');
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
#import "../fragments/job.fragment.graphql"
mutation cancelJob($id: CiBuildID!) {
jobCancel(input: { id: $id }) {
job {
...Job
}
errors
}
}
#import "../fragments/job.fragment.graphql"
mutation playJob($id: CiBuildID!) {
jobPlay(input: { id: $id }) {
job {
...Job
}
errors
}
}
#import "../fragments/job.fragment.graphql"
mutation retryJob($id: CiBuildID!) {
jobRetry(input: { id: $id }) {
job {
...Job
}
errors
}
}
#import "../fragments/job.fragment.graphql"
mutation unscheduleJob($id: CiBuildID!) {
jobUnschedule(input: { id: $id }) {
job {
...Job
}
errors
}
}
...@@ -69,6 +69,7 @@ query getJobs( ...@@ -69,6 +69,7 @@ query getJobs(
stuck stuck
userPermissions { userPermissions {
readBuild readBuild
readJobArtifacts
} }
} }
} }
......
import { GlToast } from '@gitlab/ui';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(VueApollo); Vue.use(VueApollo);
Vue.use(GlToast);
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(), defaultClient: createDefaultClient(),
...@@ -22,6 +25,7 @@ export default (containerId = 'js-jobs-table') => { ...@@ -22,6 +25,7 @@ export default (containerId = 'js-jobs-table') => {
jobStatuses, jobStatuses,
pipelineEditorPath, pipelineEditorPath,
emptyStateSvgPath, emptyStateSvgPath,
admin,
} = containerEl.dataset; } = containerEl.dataset;
return new Vue({ return new Vue({
...@@ -33,6 +37,7 @@ export default (containerId = 'js-jobs-table') => { ...@@ -33,6 +37,7 @@ export default (containerId = 'js-jobs-table') => {
pipelineEditorPath, pipelineEditorPath,
jobStatuses: JSON.parse(jobStatuses), jobStatuses: JSON.parse(jobStatuses),
jobCounts: JSON.parse(jobCounts), jobCounts: JSON.parse(jobCounts),
admin: parseBoolean(admin),
}, },
render(createElement) { render(createElement) {
return createElement(JobsTableApp); return createElement(JobsTableApp);
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui'; import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants'; import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants';
import eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql'; import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue'; import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue'; import JobsTableEmptyState from './jobs_table_empty_state.vue';
...@@ -74,7 +75,16 @@ export default { ...@@ -74,7 +75,16 @@ export default {
return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading; return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading;
}, },
}, },
mounted() {
eventHub.$on('jobActionPerformed', this.handleJobAction);
},
beforeDestroy() {
eventHub.$off('jobActionPerformed', this.handleJobAction);
},
methods: { methods: {
handleJobAction() {
this.$apollo.queries.jobs.refetch({ statuses: this.scope });
},
fetchJobsByStatus(scope) { fetchJobsByStatus(scope) {
this.scope = scope; this.scope = scope;
......
- page_title _("Jobs") - page_title _("Jobs")
- add_page_specific_style 'page_bundles/ci_status' - add_page_specific_style 'page_bundles/ci_status'
- admin = local_assigns.fetch(:admin, false)
- if Feature.enabled?(:jobs_table_vue, @project, default_enabled: :yaml) - if Feature.enabled?(:jobs_table_vue, @project, default_enabled: :yaml)
#js-jobs-table{ data: { full_path: @project.full_path, job_counts: job_counts.to_json, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } } #js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_counts: job_counts.to_json, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } }
- else - else
.top-area .top-area
- build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) } - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
......
...@@ -10776,6 +10776,12 @@ msgstr "" ...@@ -10776,6 +10776,12 @@ msgstr ""
msgid "DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after it's timer finishes." msgid "DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after it's timer finishes."
msgstr "" msgstr ""
msgid "DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after its timer finishes."
msgstr ""
msgid "DelayedJobs|Run the delayed job now?"
msgstr ""
msgid "DelayedJobs|Start now" msgid "DelayedJobs|Start now"
msgstr "" msgstr ""
......
import { GlModal } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue';
import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql';
import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retry.mutation.graphql';
import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql';
import { playableJob, retryableJob, scheduledJob } from '../../../mock_data';
describe('Job actions cell', () => {
let wrapper;
let mutate;
const findRetryButton = () => wrapper.findByTestId('retry');
const findPlayButton = () => wrapper.findByTestId('play');
const findDownloadArtifactsButton = () => wrapper.findByTestId('download-artifacts');
const findCountdownButton = () => wrapper.findByTestId('countdown');
const findPlayScheduledJobButton = () => wrapper.findByTestId('play-scheduled');
const findUnscheduleButton = () => wrapper.findByTestId('unschedule');
const findModal = () => wrapper.findComponent(GlModal);
const MUTATION_SUCCESS = { data: { JobRetryMutation: { jobId: retryableJob.id } } };
const MUTATION_SUCCESS_UNSCHEDULE = {
data: { JobUnscheduleMutation: { jobId: scheduledJob.id } },
};
const MUTATION_SUCCESS_PLAY = { data: { JobPlayMutation: { jobId: playableJob.id } } };
const $toast = {
show: jest.fn(),
};
const createComponent = (jobType, mutationType = MUTATION_SUCCESS, props = {}) => {
mutate = jest.fn().mockResolvedValue(mutationType);
wrapper = shallowMountExtended(ActionsCell, {
propsData: {
job: jobType,
...props,
},
mocks: {
$apollo: {
mutate,
},
$toast,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('does not display an artifacts download button', () => {
createComponent(retryableJob);
expect(findDownloadArtifactsButton().exists()).toBe(false);
});
it.each`
button | action | jobType
${findPlayButton} | ${'play'} | ${playableJob}
${findRetryButton} | ${'retry'} | ${retryableJob}
${findDownloadArtifactsButton} | ${'download artifacts'} | ${playableJob}
`('displays the $action button', ({ button, jobType }) => {
createComponent(jobType);
expect(button().exists()).toBe(true);
});
it.each`
button | mutationResult | action | jobType | mutationFile
${findPlayButton} | ${MUTATION_SUCCESS_PLAY} | ${'play'} | ${playableJob} | ${JobPlayMutation}
${findRetryButton} | ${MUTATION_SUCCESS} | ${'retry'} | ${retryableJob} | ${JobRetryMutation}
`('performs the $action mutation', ({ button, mutationResult, jobType, mutationFile }) => {
createComponent(jobType, mutationResult);
button().vm.$emit('click');
expect(mutate).toHaveBeenCalledWith({
mutation: mutationFile,
variables: {
id: jobType.id,
},
});
});
describe('Scheduled Jobs', () => {
const today = () => new Date('2021-08-31');
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(today);
});
it('displays the countdown, play and unschedule buttons', () => {
createComponent(scheduledJob);
expect(findCountdownButton().exists()).toBe(true);
expect(findPlayScheduledJobButton().exists()).toBe(true);
expect(findUnscheduleButton().exists()).toBe(true);
});
it('unschedules a job', () => {
createComponent(scheduledJob, MUTATION_SUCCESS_UNSCHEDULE);
findUnscheduleButton().vm.$emit('click');
expect(mutate).toHaveBeenCalledWith({
mutation: JobUnscheduleMutation,
variables: {
id: scheduledJob.id,
},
});
});
it('shows the play job confirmation modal', async () => {
createComponent(scheduledJob, MUTATION_SUCCESS);
findPlayScheduledJobButton().vm.$emit('click');
await nextTick();
expect(findModal().exists()).toBe(true);
});
});
});
...@@ -1555,7 +1555,11 @@ export const mockJobsQueryResponse = { ...@@ -1555,7 +1555,11 @@ export const mockJobsQueryResponse = {
cancelable: false, cancelable: false,
active: false, active: false,
stuck: false, stuck: false,
userPermissions: { readBuild: true, __typename: 'JobPermissions' }, userPermissions: {
readBuild: true,
readJobArtifacts: true,
__typename: 'JobPermissions',
},
__typename: 'CiJob', __typename: 'CiJob',
}, },
], ],
...@@ -1573,3 +1577,179 @@ export const mockJobsQueryEmptyResponse = { ...@@ -1573,3 +1577,179 @@ export const mockJobsQueryEmptyResponse = {
}, },
}, },
}; };
export const retryableJob = {
artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' },
allowFailure: false,
status: 'SUCCESS',
scheduledAt: null,
manualJob: false,
triggered: null,
createdByTag: false,
detailedStatus: {
detailsPath: '/root/test-job-artifacts/-/jobs/1981',
group: 'success',
icon: 'status_success',
label: 'passed',
text: 'passed',
tooltip: 'passed',
action: {
buttonTitle: 'Retry this job',
icon: 'retry',
method: 'post',
path: '/root/test-job-artifacts/-/jobs/1981/retry',
title: 'Retry',
__typename: 'StatusAction',
},
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/1981',
refName: 'main',
refPath: '/root/test-job-artifacts/-/commits/main',
tags: [],
shortSha: '75daf01b',
commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/288',
path: '/root/test-job-artifacts/-/pipelines/288',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'UserCore',
},
__typename: 'Pipeline',
},
stage: { name: 'test', __typename: 'CiStage' },
name: 'hello_world',
duration: 7,
finishedAt: '2021-08-30T20:33:56Z',
coverage: null,
retryable: true,
playable: false,
cancelable: false,
active: false,
stuck: false,
userPermissions: { readBuild: true, __typename: 'JobPermissions' },
__typename: 'CiJob',
};
export const playableJob = {
artifacts: {
nodes: [
{
downloadPath: '/root/test-job-artifacts/-/jobs/1982/artifacts/download?file_type=trace',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
allowFailure: false,
status: 'SUCCESS',
scheduledAt: null,
manualJob: true,
triggered: null,
createdByTag: false,
detailedStatus: {
detailsPath: '/root/test-job-artifacts/-/jobs/1982',
group: 'success',
icon: 'status_success',
label: 'manual play action',
text: 'passed',
tooltip: 'passed',
action: {
buttonTitle: 'Trigger this manual action',
icon: 'play',
method: 'post',
path: '/root/test-job-artifacts/-/jobs/1982/play',
title: 'Play',
__typename: 'StatusAction',
},
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/1982',
refName: 'main',
refPath: '/root/test-job-artifacts/-/commits/main',
tags: [],
shortSha: '75daf01b',
commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/288',
path: '/root/test-job-artifacts/-/pipelines/288',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'UserCore',
},
__typename: 'Pipeline',
},
stage: { name: 'test', __typename: 'CiStage' },
name: 'hello_world_delayed',
duration: 6,
finishedAt: '2021-08-30T20:36:12Z',
coverage: null,
retryable: true,
playable: true,
cancelable: false,
active: false,
stuck: false,
userPermissions: { readBuild: true, readJobArtifacts: true, __typename: 'JobPermissions' },
__typename: 'CiJob',
};
export const scheduledJob = {
artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' },
allowFailure: false,
status: 'SCHEDULED',
scheduledAt: '2021-08-31T22:36:05Z',
manualJob: true,
triggered: null,
createdByTag: false,
detailedStatus: {
detailsPath: '/root/test-job-artifacts/-/jobs/1986',
group: 'scheduled',
icon: 'status_scheduled',
label: 'unschedule action',
text: 'delayed',
tooltip: 'delayed manual action (%{remainingTime})',
action: {
buttonTitle: 'Unschedule job',
icon: 'time-out',
method: 'post',
path: '/root/test-job-artifacts/-/jobs/1986/unschedule',
title: 'Unschedule',
__typename: 'StatusAction',
},
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/1986',
refName: 'main',
refPath: '/root/test-job-artifacts/-/commits/main',
tags: [],
shortSha: '75daf01b',
commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/290',
path: '/root/test-job-artifacts/-/pipelines/290',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'UserCore',
},
__typename: 'Pipeline',
},
stage: { name: 'test', __typename: 'CiStage' },
name: 'hello_world_delayed',
duration: null,
finishedAt: null,
coverage: null,
retryable: false,
playable: true,
cancelable: false,
active: false,
stuck: false,
userPermissions: { readBuild: 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