Commit c5cdc943 authored by Simon Knox's avatar Simon Knox

Merge branch 'jivanvl-create-actions-cell-jobs-table' into 'master'

Create actions cells component for the jobs table refactor

See merge request gitlab-org/gitlab!60303
parents d449a46e eb6e0e78
<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