diff --git a/app/assets/javascripts/jobs/components/job/empty_state.vue b/app/assets/javascripts/jobs/components/job/empty_state.vue index 65b9600e6646b93e18179f97a945b31faf6da5d2..053d5a4e740bd18878874487eb34064ee93d222f 100644 --- a/app/assets/javascripts/jobs/components/job/empty_state.vue +++ b/app/assets/javascripts/jobs/components/job/empty_state.vue @@ -20,6 +20,14 @@ export default { type: String, required: true, }, + isRetryable: { + type: Boolean, + required: false, + }, + jobId: { + type: Number, + required: true, + }, title: { type: String, required: true, @@ -54,8 +62,8 @@ export default { }, }, computed: { - isGraphQL() { - return this.glFeatures?.graphqlJobApp; + showGraphQLManualVariablesForm() { + return this.glFeatures?.graphqlJobApp && this.isRetryable; }, shouldRenderManualVariables() { return this.playable && !this.scheduled; @@ -77,14 +85,18 @@ export default { <p v-if="content" data-testid="job-empty-state-content">{{ content }}</p> </div> - <template v-if="isGraphQL"> - <manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> + <template v-if="showGraphQLManualVariablesForm"> + <manual-variables-form + v-if="shouldRenderManualVariables" + :job-id="jobId" + @hideManualVariablesForm="$emit('hideManualVariablesForm')" + /> </template> <template v-else> <legacy-manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> </template> - <div class="text-content"> - <div v-if="action && !shouldRenderManualVariables" class="text-center"> + <div v-if="action && !shouldRenderManualVariables" class="text-content"> + <div class="text-center"> <gl-link :href="action.path" :data-method="action.method" diff --git a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2b79892a07251271bbefec7e5584ceba2c06677b --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql @@ -0,0 +1,16 @@ +mutation retryJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { + jobRetry(input: { id: $id, variables: $variables }) { + job { + id + manualVariables { + nodes { + id + key + value + } + } + webPath + } + errors + } +} diff --git a/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..aaf1dec8e0fd7c493c9afb5c4049a19251f74aa0 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql @@ -0,0 +1,17 @@ +query getJob($fullPath: ID!, $id: JobID!) { + project(fullPath: $fullPath) { + id + job(id: $id) { + id + manualJob + manualVariables { + nodes { + id + key + value + } + } + name + } + } +} diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue index e5fbf77be1e6aeee0819f11005ed8d9e16cac66e..c6d900ef13e265b750057b45b68f48dace26bfb8 100644 --- a/app/assets/javascripts/jobs/components/job/job_app.vue +++ b/app/assets/javascripts/jobs/components/job/job_app.vue @@ -72,6 +72,7 @@ export default { data() { return { searchResults: [], + showUpdateVariablesState: false, }; }, computed: { @@ -122,6 +123,10 @@ export default { return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure; }, + isJobRetryable() { + return Boolean(this.job.retry_path); + }, + itemName() { return sprintf(__('Job %{jobName}'), { jobName: this.job.name }); }, @@ -169,10 +174,16 @@ export default { 'toggleScrollButtons', 'toggleScrollAnimation', ]), + onHideManualVariablesForm() { + this.showUpdateVariablesState = false; + }, onResize() { this.updateSidebar(); this.updateScroll(); }, + onUpdateVariables() { + this.showUpdateVariablesState = true; + }, updateSidebar() { const breakpoint = bp.getBreakpointSize(); if (breakpoint === 'xs' || breakpoint === 'sm') { @@ -272,14 +283,12 @@ export default { </div> <!-- job log --> <div - v-if="hasJobLog" + v-if="hasJobLog && !showUpdateVariablesState" class="build-log-container gl-relative" :class="{ 'gl-mt-3': !job.archived }" > <log-top-bar :class="{ - 'sidebar-expanded': isSidebarOpen, - 'sidebar-collapsed': !isSidebarOpen, 'has-archived-block': job.archived, }" :size="jobLogSize" @@ -300,14 +309,17 @@ export default { <!-- empty state --> <empty-state - v-if="!hasJobLog" + v-if="!hasJobLog || showUpdateVariablesState" :illustration-path="emptyStateIllustration.image" :illustration-size-class="emptyStateIllustration.size" + :is-retryable="isJobRetryable" + :job-id="job.id" :title="emptyStateTitle" :content="emptyStateIllustration.content" :action="emptyStateAction" :playable="job.playable" :scheduled="job.scheduled" + @hideManualVariablesForm="onHideManualVariablesForm()" /> <!-- EO empty state --> @@ -321,9 +333,9 @@ export default { 'right-sidebar-expanded': isSidebarOpen, 'right-sidebar-collapsed': !isSidebarOpen, }" - :erase-path="job.erase_path" :artifact-help-url="artifactHelpUrl" data-testid="job-sidebar" + @updateVariables="onUpdateVariables()" /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue index 1898e02c94e705a3925f9054f88175fc5162af4c..2b6b6f8e59e5373fc7c147101c0ef83e6057c504 100644 --- a/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue @@ -6,6 +6,7 @@ import { GlButton, GlLink, GlSprintf, + GlTooltipDirective, } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { mapActions } from 'vuex'; @@ -13,7 +14,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; export default { - name: 'ManualVariablesForm', + name: 'LegacyManualVariablesForm', components: { GlFormInputGroup, GlInputGroupText, @@ -22,6 +23,9 @@ export default { GlLink, GlSprintf, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { action: { type: Object, @@ -42,6 +46,7 @@ export default { value: 'value', }, i18n: { + clearInputs: s__('CiVariables|Clear inputs'), header: s__('CiVariables|Variables'), keyLabel: s__('CiVariables|Key'), valueLabel: s__('CiVariables|Value'), @@ -152,11 +157,13 @@ export default { <gl-button v-if="canRemove(index)" + v-gl-tooltip + :aria-label="$options.i18n.clearInputs" + :title="$options.i18n.clearInputs" class="gl-flex-grow-0 gl-flex-basis-0" category="tertiary" variant="danger" icon="clear" - :aria-label="__('Delete variable')" data-testid="delete-variable-btn" @click="deleteVariable(variable.id)" /> diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue index 2f97301979ca507ab9033b57e87218cb4c9e5f16..e8edc7fc56f78d97e67622584dc1a1b5cb358e40 100644 --- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue @@ -5,15 +5,23 @@ import { GlFormInput, GlButton, GlLink, + GlLoadingIcon, GlSprintf, + GlTooltipDirective, } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import { mapActions } from 'vuex'; +import { cloneDeep, uniqueId } from 'lodash'; +import { fetchPolicies } from '~/lib/graphql'; +import { createAlert } from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { JOB_GRAPHQL_ERRORS, GRAPHQL_ID_TYPES } from '~/jobs/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; +import GetJob from './graphql/queries/get_job.query.graphql'; +import retryJobWithVariablesMutation from './graphql/mutations/job_retry_with_variables.mutation.graphql'; // This component is a port of ~/jobs/components/job/legacy_manual_variables_form.vue -// It is meant to fetch the job information via GraphQL instead of REST API. +// It is meant to fetch/update the job information via GraphQL instead of REST API. export default { name: 'ManualVariablesForm', @@ -23,63 +31,73 @@ export default { GlFormInput, GlButton, GlLink, + GlLoadingIcon, GlSprintf, }, - props: { - action: { - type: Object, - required: false, - default: null, - validator(value) { - return ( - value === null || - (Object.prototype.hasOwnProperty.call(value, 'path') && - Object.prototype.hasOwnProperty.call(value, 'method') && - Object.prototype.hasOwnProperty.call(value, 'button_title')) - ); + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['projectPath'], + apollo: { + variables: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + }; + }, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + update(data) { + const jobVariables = cloneDeep(data?.project?.job?.manualVariables?.nodes); + return [...jobVariables.reverse(), ...this.variables]; + }, + error() { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); }, }, }, + props: { + jobId: { + type: Number, + required: true, + }, + }, inputTypes: { key: 'key', value: 'value', }, i18n: { + clearInputs: s__('CiVariables|Clear inputs'), + formHelpText: s__( + 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', + ), header: s__('CiVariables|Variables'), keyLabel: s__('CiVariables|Key'), - valueLabel: s__('CiVariables|Value'), keyPlaceholder: s__('CiVariables|Input variable key'), + runAgainButtonText: s__('CiVariables|Run job again'), + valueLabel: s__('CiVariables|Value'), valuePlaceholder: s__('CiVariables|Input variable value'), - formHelpText: s__( - 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', - ), }, data() { return { + job: {}, variables: [ { - key: '', - secretValue: '', id: uniqueId(), + key: '', + value: '', }, ], - triggerBtnDisabled: false, + runAgainBtnDisabled: false, }; }, computed: { variableSettings() { return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); }, - preparedVariables() { - // we need to ensure no empty variables are passed to the API - // and secretValue should be snake_case when passed to the API - return this.variables - .filter((variable) => variable.key !== '') - .map(({ key, secretValue }) => ({ key, secret_value: secretValue })); - }, }, methods: { - ...mapActions(['triggerManualJob']), addEmptyVariable() { const lastVar = this.variables[this.variables.length - 1]; @@ -88,9 +106,9 @@ export default { } this.variables.push({ - key: '', - secret_value: '', id: uniqueId(), + key: '', + value: '', }); }, canRemove(index) { @@ -105,16 +123,45 @@ export default { inputRef(type, id) { return `${this.$options.inputTypes[type]}-${id}`; }, - trigger() { - this.triggerBtnDisabled = true; + navigateToRetriedJob(retryPath) { + redirectTo(retryPath); + }, + async retryJob() { + try { + // filtering out 'id' along with empty variables to send only key, value in the mutation. + // This will be removed in: https://gitlab.com/gitlab-org/gitlab/-/issues/377268 + const preparedVariables = this.variables + .filter((variable) => variable.key !== '') + .map(({ key, value }) => ({ key, value })); - this.triggerManualJob(this.preparedVariables); + const { data } = await this.$apollo.mutate({ + mutation: retryJobWithVariablesMutation, + variables: { + id: convertToGraphQLId(GRAPHQL_ID_TYPES.ciBuild, this.jobId), + // we need to ensure no empty variables are passed to the API + variables: preparedVariables, + }, + }); + if (data.jobRetry?.errors?.length) { + createAlert({ message: data.jobRetry.errors[0] }); + } else { + this.navigateToRetriedJob(data.jobRetry?.job?.webPath); + } + } catch (error) { + createAlert({ message: JOB_GRAPHQL_ERRORS.retryMutationErrorText }); + } + }, + runAgain() { + this.runAgainBtnDisabled = true; + + this.retryJob(); }, }, }; </script> <template> - <div class="row gl-justify-content-center"> + <gl-loading-icon v-if="$apollo.queries.variables.loading" class="gl-mt-9" size="lg" /> + <div v-else class="row gl-justify-content-center"> <div class="col-10" data-testid="manual-vars-form"> <label>{{ $options.i18n.header }}</label> @@ -147,7 +194,7 @@ export default { </template> <gl-form-input :ref="inputRef('value', variable.id)" - v-model="variable.secretValue" + v-model="variable.value" :placeholder="$options.i18n.valuePlaceholder" data-testid="ci-variable-value" /> @@ -155,11 +202,13 @@ export default { <gl-button v-if="canRemove(index)" + v-gl-tooltip + :aria-label="$options.i18n.clearInputs" + :title="$options.i18n.clearInputs" class="gl-flex-grow-0 gl-flex-basis-0" category="tertiary" variant="danger" icon="clear" - :aria-label="__('Delete variable')" data-testid="delete-variable-btn" @click="deleteVariable(variable.id)" /> @@ -178,16 +227,23 @@ export default { </gl-sprintf> </div> <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-button + class="gl-mt-5" + :aria-label="__('Cancel')" + data-testid="cancel-btn" + @click="$emit('hideManualVariablesForm')" + >{{ __('Cancel') }}</gl-button + > <gl-button class="gl-mt-5" variant="confirm" category="primary" - :aria-label="__('Trigger manual job')" - :disabled="triggerBtnDisabled" - data-testid="trigger-manual-job-btn" - @click="trigger" + :aria-label="__('Run manual job again')" + :disabled="runAgainBtnDisabled" + data-testid="run-manual-job-btn" + @click="runAgain" > - {{ action.button_title }} + {{ $options.i18n.runAgainButtonText }} </gl-button> </div> </div> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue index dd620977f0c80d865e195b3b099cce1869d306f3..65175df555aadbacade53a560de4a86cff648414 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue @@ -1,19 +1,23 @@ <script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { JOB_SIDEBAR_COPY } from '~/jobs/constants'; export default { name: 'JobSidebarRetryButton', i18n: { - retryLabel: JOB_SIDEBAR_COPY.retry, + ...JOB_SIDEBAR_COPY, }, components: { GlButton, + GlDropdown, + GlDropdownItem, }, directives: { GlModal: GlModalDirective, }, + mixins: [glFeatureFlagsMixin()], props: { modalId: { type: String, @@ -23,9 +27,16 @@ export default { type: String, required: true, }, + isManualJob: { + type: Boolean, + required: true, + }, }, computed: { ...mapGetters(['hasForwardDeploymentFailure']), + showRetryDropdown() { + return this.glFeatures?.graphqlJobApp && this.isManualJob; + }, }, }; </script> @@ -33,17 +44,30 @@ export default { <gl-button v-if="hasForwardDeploymentFailure" v-gl-modal="modalId" - :aria-label="$options.i18n.retryLabel" + :aria-label="$options.i18n.retryJobLabel" category="primary" variant="confirm" icon="retry" data-testid="retry-job-button" /> - + <gl-dropdown + v-else-if="showRetryDropdown" + icon="retry" + category="primary" + :right="true" + variant="confirm" + > + <gl-dropdown-item :href="href" data-method="post"> + {{ $options.i18n.runAgainJobButtonLabel }} + </gl-dropdown-item> + <gl-dropdown-item @click="$emit('updateVariablesClicked')"> + {{ $options.i18n.updateVariables }} + </gl-dropdown-item> + </gl-dropdown> <gl-button v-else :href="href" - :aria-label="$options.i18n.retryLabel" + :aria-label="$options.i18n.retryJobLabel" category="primary" variant="confirm" icon="retry" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/legacy_job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_job_sidebar_retry_button.vue new file mode 100644 index 0000000000000000000000000000000000000000..8a821b69f8ce4432ca3309bc4627067e8477f818 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/legacy_job_sidebar_retry_button.vue @@ -0,0 +1,53 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; +import { JOB_SIDEBAR_COPY } from '~/jobs/constants'; + +export default { + name: 'LegacyJobSidebarRetryButton', + i18n: { + retryLabel: JOB_SIDEBAR_COPY.retryJobLabel, + }, + components: { + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + modalId: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters(['hasForwardDeploymentFailure']), + }, +}; +</script> +<template> + <gl-button + v-if="hasForwardDeploymentFailure" + v-gl-modal="modalId" + :aria-label="$options.i18n.retryLabel" + category="primary" + variant="confirm" + icon="retry" + data-testid="retry-job-button" + /> + + <gl-button + v-else + :href="href" + :aria-label="$options.i18n.retryLabel" + category="primary" + variant="confirm" + icon="retry" + data-method="post" + data-testid="retry-job-link" + /> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue index 64b497c3550ab35724ecaea31d0b6d7bd3bfdbda..5bbb831a293822ccbed34371e835c3066be4009f 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue @@ -2,7 +2,7 @@ import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId, PASSED_STATUS } from '~/jobs/constants'; import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; export default { @@ -25,20 +25,15 @@ export default { required: true, default: () => ({}), }, - erasePath: { - type: String, - required: false, - default: null, - }, }, computed: { retryButtonCategory() { return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; }, buttonTitle() { - return this.job.status && this.job.status.text === 'passed' + return this.job.status && this.job.status.text === PASSED_STATUS ? this.$options.i18n.runAgainJobButtonLabel - : this.$options.i18n.retryJobButtonLabel; + : this.$options.i18n.retryJobLabel; }, }, methods: { @@ -50,17 +45,15 @@ export default { <template> <div class="gl-py-5 gl-display-flex gl-align-items-center"> <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> - {{ job.name }} - </h4> + ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">{{ job.name }}</h4> </tooltip-on-truncate> <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> <gl-button - v-if="erasePath" + v-if="job.erase_path" v-gl-tooltip.left :title="$options.i18n.eraseLogButtonLabel" :aria-label="$options.i18n.eraseLogButtonLabel" - :href="erasePath" + :href="job.erase_path" :data-confirm="$options.i18n.eraseLogConfirmText" class="gl-mr-2" data-testid="job-log-erase-link" @@ -76,6 +69,7 @@ export default { :category="retryButtonCategory" :href="job.retry_path" :modal-id="$options.forwardDeploymentFailureModalId" + :is-manual-job="false" variant="confirm" data-qa-selector="retry_button" data-testid="retry-button" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue index aac6a0ad6d392058e01864018f27fb00d70e1f81..02c3d60557ba23a78ed4b29e906f5757490da997 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue @@ -2,13 +2,13 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ArtifactsBlock from './artifacts_block.vue'; import CommitBlock from './commit_block.vue'; import JobsContainer from './jobs_container.vue'; import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; -import ArtifactsBlock from './artifacts_block.vue'; import LegacySidebarHeader from './legacy_sidebar_header.vue'; import SidebarHeader from './sidebar_header.vue'; import StagesDropdown from './stages_dropdown.vue'; @@ -41,11 +41,6 @@ export default { required: false, default: '', }, - erasePath: { - type: String, - required: false, - default: null, - }, }, computed: { ...mapGetters(['hasForwardDeploymentFailure']), @@ -89,8 +84,13 @@ export default { <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> <div class="blocks-container"> - <sidebar-header v-if="isGraphQL" :erase-path="erasePath" :job="job" /> - <legacy-sidebar-header v-else :erase-path="erasePath" :job="job" /> + <sidebar-header + v-if="isGraphQL" + :rest-job="job" + :job-id="job.id" + @updateVariables="$emit('updateVariables')" + /> + <legacy-sidebar-header v-else :job="job" /> <div v-if="job.terminal_path || job.new_issue_path" class="gl-py-5" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue index 523710598bf058ccb9f9e7f4621512c923d00d58..c124f52ae794304b74264f7d4154dccc5f6468bb 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue @@ -1,8 +1,17 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import { createAlert } from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import { + JOB_GRAPHQL_ERRORS, + GRAPHQL_ID_TYPES, + JOB_SIDEBAR_COPY, + forwardDeploymentFailureModalId, + PASSED_STATUS, +} from '~/jobs/constants'; +import GetJob from '../graphql/queries/get_job.query.graphql'; import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; // This component is a port of ~/jobs/components/job/sidebar/legacy_sidebar_header.vue @@ -22,21 +31,58 @@ export default { JobSidebarRetryButton, TooltipOnTruncate, }, - props: { + inject: ['projectPath'], + apollo: { job: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + }; + }, + update(data) { + const { name, manualJob } = data?.project?.job || {}; + return { + name, + manualJob, + }; + }, + error() { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); + }, + }, + }, + props: { + jobId: { + type: Number, + required: true, + }, + restJob: { type: Object, required: true, default: () => ({}), }, - erasePath: { - type: String, - required: false, - default: null, - }, + }, + data() { + return { + job: {}, + }; }, computed: { + buttonTitle() { + return this.restJob.status?.text === PASSED_STATUS + ? this.$options.i18n.runAgainJobButtonLabel + : this.$options.i18n.retryJobLabel; + }, + canShowJobRetryButton() { + return this.restJob.retry_path && !this.$apollo.queries.job.loading; + }, + isManualJob() { + return this.job?.manualJob; + }, retryButtonCategory() { - return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; + return this.restJob.status && this.restJob.recoverable ? 'primary' : 'secondary'; }, }, methods: { @@ -48,17 +94,15 @@ export default { <template> <div class="gl-py-5 gl-display-flex gl-align-items-center"> <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> - {{ job.name }} - </h4> + ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate" data-testid="job-name">{{ job.name }}</h4> </tooltip-on-truncate> <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> <gl-button - v-if="erasePath" + v-if="restJob.erase_path" v-gl-tooltip.left :title="$options.i18n.eraseLogButtonLabel" :aria-label="$options.i18n.eraseLogButtonLabel" - :href="erasePath" + :href="restJob.erase_path" :data-confirm="$options.i18n.eraseLogConfirmText" class="gl-mr-2" data-testid="job-log-erase-link" @@ -67,23 +111,25 @@ export default { icon="remove" /> <job-sidebar-retry-button - v-if="job.retry_path" + v-if="canShowJobRetryButton" v-gl-tooltip.left - :title="$options.i18n.retryJobButtonLabel" - :aria-label="$options.i18n.retryJobButtonLabel" + :title="buttonTitle" + :aria-label="buttonTitle" + :is-manual-job="isManualJob" :category="retryButtonCategory" - :href="job.retry_path" + :href="restJob.retry_path" :modal-id="$options.forwardDeploymentFailureModalId" variant="confirm" data-qa-selector="retry_button" data-testid="retry-button" + @updateVariablesClicked="$emit('updateVariables')" /> <gl-button - v-if="job.cancel_path" + v-if="restJob.cancel_path" v-gl-tooltip.left :title="$options.i18n.cancelJobButtonLabel" :aria-label="$options.i18n.cancelJobButtonLabel" - :href="job.cancel_path" + :href="restJob.cancel_path" variant="danger" icon="cancel" data-method="post" diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js index e9475994e8bec22e02ea0f6f341c587f1c26b0f8..405aea111812ab01c80c478f1a143f96e460458f 100644 --- a/app/assets/javascripts/jobs/constants.js +++ b/app/assets/javascripts/jobs/constants.js @@ -5,6 +5,11 @@ const moreInfo = __('More information'); export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; +export const GRAPHQL_ID_TYPES = { + commitStatus: 'CommitStatus', + ciBuild: 'Ci::Build', +}; + export const JOB_SIDEBAR_COPY = { cancel, cancelJobButtonLabel: s__('Job|Cancel'), @@ -12,10 +17,15 @@ export const JOB_SIDEBAR_COPY = { eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), newIssue: __('New issue'), - retry: __('Retry'), - retryJobButtonLabel: s__('Job|Retry'), + retryJobLabel: s__('Job|Retry'), toggleSidebar: __('Toggle Sidebar'), runAgainJobButtonLabel: s__('Job|Run again'), + updateVariables: s__('Job|Update CI/CD variables'), +}; + +export const JOB_GRAPHQL_ERRORS = { + retryMutationErrorText: __('There was an error running the job. Please try again.'), + jobQueryErrorText: __('There was an error fetching the job.'), }; export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { @@ -31,3 +41,4 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { }; export const SUCCESS_STATUS = 'SUCCESS'; +export const PASSED_STATUS = 'passed'; diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 9dd47f4046c72a3d45e8a2d93232862b5eb93a87..44bb1ffb1bcdc658738f237211bc4eaaa5cbed92 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -1,10 +1,17 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import JobApp from './components/job/job_app.vue'; import createStore from './store'; +Vue.use(VueApollo); Vue.use(GlToast); +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + const initializeJobPage = (element) => { const store = createStore(); @@ -26,11 +33,13 @@ const initializeJobPage = (element) => { return new Vue({ el: element, + apolloProvider, store, components: { JobApp, }, provide: { + projectPath, retryOutdatedJobDocsUrl, }, render(createElement) { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5f704c92047b4bc87faf0c5ea7bbda23a6703fa7..5d9bb3d40aaa3634970de9bb3ac483df89bca91f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8499,6 +8499,9 @@ msgstr "" msgid "CiVariables|Cannot use Masked Variable with current value" msgstr "" +msgid "CiVariables|Clear inputs" +msgstr "" + msgid "CiVariables|Environments" msgstr "" @@ -8526,6 +8529,9 @@ msgstr "" msgid "CiVariables|Remove variable row" msgstr "" +msgid "CiVariables|Run job again" +msgstr "" + msgid "CiVariables|Scope" msgstr "" @@ -23615,6 +23621,9 @@ msgstr "" msgid "Job|This job is stuck because you don't have any active runners that can run this job." msgstr "" +msgid "Job|Update CI/CD variables" +msgstr "" + msgid "Job|Waiting for resource" msgstr "" @@ -35098,6 +35107,9 @@ msgstr "" msgid "Run housekeeping tasks to automatically optimize Git repositories. Disabling this option will cause performance to degenerate over time." msgstr "" +msgid "Run manual job again" +msgstr "" + msgid "Run manual or delayed jobs" msgstr "" @@ -41458,6 +41470,9 @@ msgstr "" msgid "There was an error fetching the environments information." msgstr "" +msgid "There was an error fetching the job." +msgstr "" + msgid "There was an error fetching the jobs for your project." msgstr "" @@ -41497,6 +41512,9 @@ msgstr "" msgid "There was an error retrieving the Jira users." msgstr "" +msgid "There was an error running the job. Please try again." +msgstr "" + msgid "There was an error saving your changes." msgstr "" diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index 96a8168e708e34c3b35e1d6584e81e59831b6529..4f7b7b5b98f92b909e55d40405401fcd19457303 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -215,10 +215,6 @@ visit project_job_path(project, job) end - it 'shows retry button' do - expect(page).to have_link('Retry') - end - context 'if job passed' do it 'does not show New issue button' do expect(page).not_to have_link('New issue') diff --git a/spec/frontend/jobs/components/job/empty_state_spec.js b/spec/frontend/jobs/components/job/empty_state_spec.js index 299b607ad7822b16c4620202b97203a4746882d4..e1b9aa743e0eb8b2f39424bb888faae9a1cb15b6 100644 --- a/spec/frontend/jobs/components/job/empty_state_spec.js +++ b/spec/frontend/jobs/components/job/empty_state_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import EmptyState from '~/jobs/components/job/empty_state.vue'; +import { mockId } from './mock_data'; describe('Empty State', () => { let wrapper; @@ -7,6 +8,7 @@ describe('Empty State', () => { const defaultProps = { illustrationPath: 'illustrations/pending_job_empty.svg', illustrationSizeClass: 'svg-430', + jobId: mockId, title: 'This job has not started yet', playable: false, }; diff --git a/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js index 18d5f35bde47fd2c59ea293753e90035a3b917b2..b04a5e07ea5ae2d4f903bc08bb3526e6b00e2a01 100644 --- a/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js +++ b/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js @@ -16,6 +16,7 @@ describe('Job Sidebar Retry Button', () => { wrapper = shallowMountExtended(JobsSidebarRetryButton, { propsData: { href: job.retry_path, + isManualJob: true, modalId: 'modal-id', ...props, }, diff --git a/spec/frontend/jobs/components/job/legacy_sidebar_header_spec.js b/spec/frontend/jobs/components/job/legacy_sidebar_header_spec.js index 95eb10118ee65c15ef8be589791c86e25fbdf4c9..8fbb418232b82be80d02a9e7a780c034a36b566e 100644 --- a/spec/frontend/jobs/components/job/legacy_sidebar_header_spec.js +++ b/spec/frontend/jobs/components/job/legacy_sidebar_header_spec.js @@ -32,12 +32,8 @@ describe('Legacy Sidebar Header', () => { }); describe('when job log is erasable', () => { - const path = '/root/ci-project/-/jobs/1447/erase'; - beforeEach(() => { - createWrapper({ - erasePath: path, - }); + createWrapper(); }); it('renders erase job link', () => { @@ -45,13 +41,13 @@ describe('Legacy Sidebar Header', () => { }); it('erase job link has correct path', () => { - expect(findEraseLink().attributes('href')).toBe(path); + expect(findEraseLink().attributes('href')).toBe(job.erase_path); }); }); describe('when job log is not erasable', () => { beforeEach(() => { - createWrapper(); + createWrapper({ job: { ...job, erase_path: null } }); }); it('does not render erase button', () => { @@ -77,8 +73,7 @@ describe('Legacy Sidebar Header', () => { describe('when there is no retry path', () => { it('should not render a retry button', async () => { - const copy = { ...job, retry_path: null }; - createWrapper({ job: copy }); + createWrapper({ job: { ...job, retry_path: null } }); expect(findRetryButton().exists()).toBe(false); }); @@ -100,9 +95,7 @@ describe('Legacy Sidebar Header', () => { it('should have a different label when the job status is failed', () => { createWrapper({ job: { ...job, status: failedJobStatus } }); - expect(findRetryButton().attributes('title')).toBe( - LegacySidebarHeader.i18n.retryJobButtonLabel, - ); + expect(findRetryButton().attributes('title')).toBe(LegacySidebarHeader.i18n.retryJobLabel); }); }); }); diff --git a/spec/frontend/jobs/components/job/manual_variables_form_spec.js b/spec/frontend/jobs/components/job/manual_variables_form_spec.js index 5806f9f75f9c815eb5ed5d13a236dc0b355aede0..4384b2f4d7f77b0bebcfd1dfad0e0ddd504b9f2f 100644 --- a/spec/frontend/jobs/components/job/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/job/manual_variables_form_spec.js @@ -1,46 +1,70 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { GRAPHQL_ID_TYPES } from '~/jobs/constants'; +import waitForPromises from 'helpers/wait_for_promises'; import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; - -Vue.use(Vuex); +import getJobQuery from '~/jobs/components/job/graphql/queries/get_job.query.graphql'; +import retryJobMutation from '~/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql'; +import { + mockFullPath, + mockId, + mockJobResponse, + mockJobWithVariablesResponse, + mockJobMutationData, +} from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const defaultProvide = { + projectPath: mockFullPath, +}; describe('Manual Variables Form', () => { let wrapper; - let store; - - const requiredProps = { - action: { - path: '/play', - method: 'post', - button_title: 'Trigger this manual action', - }, + let mockApollo; + let getJobQueryResponse; + + const createComponent = ({ options = {}, props = {} } = {}) => { + wrapper = mountExtended(ManualVariablesForm, { + propsData: { + ...props, + jobId: mockId, + }, + provide: { + ...defaultProvide, + }, + ...options, + }); }; - const createComponent = (props = {}) => { - store = new Vuex.Store({ - actions: { - triggerManualJob: jest.fn(), - }, + const createComponentWithApollo = async ({ props = {} } = {}) => { + const requestHandlers = [[getJobQuery, getJobQueryResponse]]; + + mockApollo = createMockApollo(requestHandlers); + + const options = { + localVue, + apolloProvider: mockApollo, + }; + + createComponent({ + props, + options, }); - wrapper = extendedWrapper( - mount(ManualVariablesForm, { - propsData: { ...requiredProps, ...props }, - store, - stubs: { - GlSprintf, - }, - }), - ); + return waitForPromises(); }; const findHelpText = () => wrapper.findComponent(GlSprintf); const findHelpLink = () => wrapper.findComponent(GlLink); - - const findTriggerBtn = () => wrapper.findByTestId('trigger-manual-job-btn'); + const findCancelBtn = () => wrapper.findByTestId('cancel-btn'); + const findRerunBtn = () => wrapper.findByTestId('run-manual-job-btn'); const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn'); const findAllDeleteVarBtns = () => wrapper.findAllByTestId('delete-variable-btn'); const findDeleteVarBtnPlaceholder = () => wrapper.findByTestId('delete-variable-btn-placeholder'); @@ -62,95 +86,134 @@ describe('Manual Variables Form', () => { }; beforeEach(() => { - createComponent(); + getJobQueryResponse = jest.fn(); }); afterEach(() => { wrapper.destroy(); }); - it('creates a new variable when user enters a new key value', async () => { - expect(findAllVariables()).toHaveLength(1); + describe('when page renders', () => { + beforeEach(async () => { + getJobQueryResponse.mockResolvedValue(mockJobResponse); + await createComponentWithApollo(); + }); + + it('renders help text with provided link', () => { + expect(findHelpText().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe( + '/help/ci/variables/index#add-a-cicd-variable-to-a-project', + ); + }); + + it('renders buttons', () => { + expect(findCancelBtn().exists()).toBe(true); + expect(findRerunBtn().exists()).toBe(true); + }); + }); + + describe('when job has variables', () => { + beforeEach(async () => { + getJobQueryResponse.mockResolvedValue(mockJobWithVariablesResponse); + await createComponentWithApollo(); + }); - await setCiVariableKey(); + it('sets manual job variables', () => { + const queryKey = mockJobWithVariablesResponse.data.project.job.manualVariables.nodes[0].key; + const queryValue = + mockJobWithVariablesResponse.data.project.job.manualVariables.nodes[0].value; - expect(findAllVariables()).toHaveLength(2); + expect(findCiVariableKey().element.value).toBe(queryKey); + expect(findCiVariableValue().element.value).toBe(queryValue); + }); }); - it('does not create extra empty variables', async () => { - expect(findAllVariables()).toHaveLength(1); + describe('when mutation fires', () => { + beforeEach(async () => { + await createComponentWithApollo(); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockJobMutationData); + }); - await setCiVariableKey(); + it('passes variables in correct format', async () => { + await setCiVariableKey(); - expect(findAllVariables()).toHaveLength(2); + await findCiVariableValue().setValue('new value'); - await setCiVariableKey(); + await findRerunBtn().vm.$emit('click'); - expect(findAllVariables()).toHaveLength(2); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: retryJobMutation, + variables: { + id: convertToGraphQLId(GRAPHQL_ID_TYPES.ciBuild, mockId), + variables: [ + { + key: 'new key', + value: 'new value', + }, + ], + }, + }); + }); }); - it('removes the correct variable row', async () => { - const variableKeyNameOne = 'key-one'; - const variableKeyNameThree = 'key-three'; + describe('updating variables in UI', () => { + beforeEach(async () => { + getJobQueryResponse.mockResolvedValue(mockJobResponse); + await createComponentWithApollo(); + }); - await setCiVariableKeyByPosition(0, variableKeyNameOne); + it('creates a new variable when user enters a new key value', async () => { + expect(findAllVariables()).toHaveLength(1); - await setCiVariableKeyByPosition(1, 'key-two'); + await setCiVariableKey(); - await setCiVariableKeyByPosition(2, variableKeyNameThree); + expect(findAllVariables()).toHaveLength(2); + }); - expect(findAllVariables()).toHaveLength(4); + it('does not create extra empty variables', async () => { + expect(findAllVariables()).toHaveLength(1); - await findAllDeleteVarBtns().at(1).trigger('click'); + await setCiVariableKey(); - expect(findAllVariables()).toHaveLength(3); + expect(findAllVariables()).toHaveLength(2); - expect(findAllCiVariableKeys().at(0).element.value).toBe(variableKeyNameOne); - expect(findAllCiVariableKeys().at(1).element.value).toBe(variableKeyNameThree); - expect(findAllCiVariableKeys().at(2).element.value).toBe(''); - }); + await setCiVariableKey(); - it('trigger button is disabled after trigger action', async () => { - expect(findTriggerBtn().props('disabled')).toBe(false); + expect(findAllVariables()).toHaveLength(2); + }); - await findTriggerBtn().trigger('click'); + it('removes the correct variable row', async () => { + const variableKeyNameOne = 'key-one'; + const variableKeyNameThree = 'key-three'; - expect(findTriggerBtn().props('disabled')).toBe(true); - }); + await setCiVariableKeyByPosition(0, variableKeyNameOne); - it('delete variable button should only show when there is more than one variable', async () => { - expect(findDeleteVarBtn().exists()).toBe(false); + await setCiVariableKeyByPosition(1, 'key-two'); - await setCiVariableKey(); + await setCiVariableKeyByPosition(2, variableKeyNameThree); - expect(findDeleteVarBtn().exists()).toBe(true); - }); + expect(findAllVariables()).toHaveLength(4); - it('delete variable button placeholder should only exist when a user cannot remove', async () => { - expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); - }); + await findAllDeleteVarBtns().at(1).trigger('click'); - it('renders help text with provided link', () => { - expect(findHelpText().exists()).toBe(true); - expect(findHelpLink().attributes('href')).toBe( - '/help/ci/variables/index#add-a-cicd-variable-to-a-project', - ); - }); + expect(findAllVariables()).toHaveLength(3); - it('passes variables in correct format', async () => { - jest.spyOn(store, 'dispatch'); + expect(findAllCiVariableKeys().at(0).element.value).toBe(variableKeyNameOne); + expect(findAllCiVariableKeys().at(1).element.value).toBe(variableKeyNameThree); + expect(findAllCiVariableKeys().at(2).element.value).toBe(''); + }); - await setCiVariableKey(); + it('delete variable button should only show when there is more than one variable', async () => { + expect(findDeleteVarBtn().exists()).toBe(false); - await findCiVariableValue().setValue('new value'); + await setCiVariableKey(); - await findTriggerBtn().trigger('click'); + expect(findDeleteVarBtn().exists()).toBe(true); + }); - expect(store.dispatch).toHaveBeenCalledWith('triggerManualJob', [ - { - key: 'new key', - secret_value: 'new value', - }, - ]); + it('delete variable button placeholder should only exist when a user cannot remove', async () => { + expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/jobs/components/job/mock_data.js b/spec/frontend/jobs/components/job/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..9596e859475415fe933143475652adabb14a5948 --- /dev/null +++ b/spec/frontend/jobs/components/job/mock_data.js @@ -0,0 +1,76 @@ +export const mockFullPath = 'Commit451/lab-coat'; +export const mockId = 401; + +export const mockJobResponse = { + data: { + project: { + id: 'gid://gitlab/Project/4', + job: { + id: 'gid://gitlab/Ci::Build/401', + manualJob: true, + manualVariables: { + nodes: [], + __typename: 'CiManualVariableConnection', + }, + name: 'manual_job', + retryable: true, + status: 'SUCCESS', + __typename: 'CiJob', + }, + __typename: 'Project', + }, + }, +}; + +export const mockJobWithVariablesResponse = { + data: { + project: { + id: 'gid://gitlab/Project/4', + job: { + id: 'gid://gitlab/Ci::Build/401', + manualJob: true, + manualVariables: { + nodes: [ + { + id: 'gid://gitlab/Ci::JobVariable/150', + key: 'new key', + value: 'new value', + __typename: 'CiManualVariable', + }, + ], + __typename: 'CiManualVariableConnection', + }, + name: 'manual_job', + retryable: true, + status: 'SUCCESS', + __typename: 'CiJob', + }, + __typename: 'Project', + }, + }, +}; + +export const mockJobMutationData = { + data: { + jobRetry: { + job: { + id: 'gid://gitlab/Ci::Build/401', + manualVariables: { + nodes: [ + { + id: 'gid://gitlab/Ci::JobVariable/151', + key: 'new key', + value: 'new value', + __typename: 'CiManualVariable', + }, + ], + __typename: 'CiManualVariableConnection', + }, + webPath: '/Commit451/lab-coat/-/jobs/401', + __typename: 'CiJob', + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; diff --git a/spec/frontend/jobs/components/job/sidebar_header_spec.js b/spec/frontend/jobs/components/job/sidebar_header_spec.js index cb32ca9d3dcfc7b0b0a0e68be4e50f521999f710..422e2f6207cd2e95f138654f1e0153cda889c14a 100644 --- a/spec/frontend/jobs/components/job/sidebar_header_spec.js +++ b/spec/frontend/jobs/components/job/sidebar_header_spec.js @@ -1,91 +1,101 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import SidebarHeader from '~/jobs/components/job/sidebar/sidebar_header.vue'; import JobRetryButton from '~/jobs/components/job/sidebar/job_sidebar_retry_button.vue'; -import LegacySidebarHeader from '~/jobs/components/job/sidebar/legacy_sidebar_header.vue'; -import createStore from '~/jobs/store'; -import job from '../../mock_data'; +import getJobQuery from '~/jobs/components/job/graphql/queries/get_job.query.graphql'; +import { mockFullPath, mockId, mockJobResponse } from './mock_data'; -describe('Legacy Sidebar Header', () => { - let store; - let wrapper; +const localVue = createLocalVue(); +localVue.use(VueApollo); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findRetryButton = () => wrapper.findComponent(JobRetryButton); - const findEraseLink = () => wrapper.findByTestId('job-log-erase-link'); - - const createWrapper = (props) => { - store = createStore(); - - wrapper = extendedWrapper( - shallowMount(LegacySidebarHeader, { - propsData: { - job, - ...props, - }, - store, - }), - ); +const defaultProvide = { + projectPath: mockFullPath, +}; + +describe('Sidebar Header', () => { + let wrapper; + let mockApollo; + let getJobQueryResponse; + + const createComponent = ({ options = {}, props = {}, restJob = {} } = {}) => { + wrapper = shallowMountExtended(SidebarHeader, { + propsData: { + ...props, + jobId: mockId, + restJob, + }, + provide: { + ...defaultProvide, + }, + ...options, + }); }; - afterEach(() => { - wrapper.destroy(); - }); + const createComponentWithApollo = async ({ props = {}, restJob = {} } = {}) => { + const requestHandlers = [[getJobQuery, getJobQueryResponse]]; - describe('when job log is erasable', () => { - const path = '/root/ci-project/-/jobs/1447/erase'; + mockApollo = createMockApollo(requestHandlers); - beforeEach(() => { - createWrapper({ - erasePath: path, - }); - }); + const options = { + localVue, + apolloProvider: mockApollo, + }; - it('renders erase job link', () => { - expect(findEraseLink().exists()).toBe(true); + createComponent({ + props, + restJob, + options, }); - it('erase job link has correct path', () => { - expect(findEraseLink().attributes('href')).toBe(path); - }); - }); + return waitForPromises(); + }; - describe('when job log is not erasable', () => { - beforeEach(() => { - createWrapper(); - }); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findEraseButton = () => wrapper.findByTestId('job-log-erase-link'); + const findJobName = () => wrapper.findByTestId('job-name'); + const findRetryButton = () => wrapper.findComponent(JobRetryButton); - it('does not render erase button', () => { - expect(findEraseLink().exists()).toBe(false); - }); + beforeEach(async () => { + getJobQueryResponse = jest.fn(); }); - describe('when the job is retryable', () => { - beforeEach(() => { - createWrapper(); - }); + afterEach(() => { + wrapper.destroy(); + }); - it('should render the retry button', () => { - expect(findRetryButton().props('href')).toBe(job.retry_path); + describe('when rendering contents', () => { + beforeEach(async () => { + getJobQueryResponse.mockResolvedValue(mockJobResponse); }); - }); - describe('when there is no retry path', () => { - it('should not render a retry button', async () => { - const copy = { ...job, retry_path: null }; - createWrapper({ job: copy }); + it('renders the correct job name', async () => { + await createComponentWithApollo(); + expect(findJobName().text()).toBe(mockJobResponse.data.project.job.name); + }); + it('does not render buttons with no paths', async () => { + await createComponentWithApollo(); + expect(findCancelButton().exists()).toBe(false); + expect(findEraseButton().exists()).toBe(false); expect(findRetryButton().exists()).toBe(false); }); - }); - describe('when the job is cancelable', () => { - beforeEach(() => { - createWrapper(); + it('renders a retry button with a path', async () => { + await createComponentWithApollo({ restJob: { retry_path: 'retry/path' } }); + expect(findRetryButton().exists()).toBe(true); + }); + + it('renders a cancel button with a path', async () => { + await createComponentWithApollo({ restJob: { cancel_path: 'cancel/path' } }); + expect(findCancelButton().exists()).toBe(true); }); - it('should render link to cancel job', () => { - expect(findCancelButton().props('icon')).toBe('cancel'); - expect(findCancelButton().attributes('href')).toBe(job.cancel_path); + it('renders an erase button with a path', async () => { + await createComponentWithApollo({ restJob: { erase_path: 'erase/path' } }); + expect(findEraseButton().exists()).toBe(true); }); }); });