Commit 84b5f8a9 authored by David O'Regan's avatar David O'Regan

Merge branch '211339-forward-deployment-warn-users-on-retry' into 'master'

Prepare for "Forward deployment  - warn users on "Retry""

See merge request gitlab-org/gitlab!46328
parents ce9225c0 4331452c
<script> <script>
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { GlLink, GlButton, GlIcon } from '@gitlab/ui'; import { GlButton, GlIcon, GlLink } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import DetailRow from './sidebar_detail_row.vue';
import ArtifactsBlock from './artifacts_block.vue'; import ArtifactsBlock from './artifacts_block.vue';
import TriggerBlock from './trigger_block.vue'; import TriggerBlock from './trigger_block.vue';
import CommitBlock from './commit_block.vue'; import CommitBlock from './commit_block.vue';
import StagesDropdown from './stages_dropdown.vue'; import StagesDropdown from './stages_dropdown.vue';
import JobsContainer from './jobs_container.vue'; import JobsContainer from './jobs_container.vue';
import SidebarJobDetailsContainer from './sidebar_job_details_container.vue';
export default { export default {
name: 'JobSidebar', name: 'JobSidebar',
components: { components: {
ArtifactsBlock, ArtifactsBlock,
CommitBlock, CommitBlock,
DetailRow,
GlIcon, GlIcon,
TriggerBlock, TriggerBlock,
StagesDropdown, StagesDropdown,
JobsContainer, JobsContainer,
GlLink, GlLink,
GlButton, GlButton,
SidebarJobDetailsContainer,
TooltipOnTruncate, TooltipOnTruncate,
}, },
mixins: [timeagoMixin],
props: { props: {
artifactHelpUrl: { artifactHelpUrl: {
type: String, type: String,
...@@ -42,53 +38,12 @@ export default { ...@@ -42,53 +38,12 @@ export default {
}, },
computed: { computed: {
...mapState(['job', 'stages', 'jobs', 'selectedStage']), ...mapState(['job', 'stages', 'jobs', 'selectedStage']),
coverage() {
return `${this.job.coverage}%`;
},
duration() {
return timeIntervalInWords(this.job.duration);
},
queued() {
return timeIntervalInWords(this.job.queued);
},
runnerId() {
return `${this.job.runner.description} (#${this.job.runner.id})`;
},
retryButtonClass() { retryButtonClass() {
let className = 'js-retry-button btn btn-retry'; let className = 'js-retry-button btn btn-retry';
className += className +=
this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary'; this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary';
return className; return className;
}, },
hasTimeout() {
return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null;
},
timeout() {
if (this.job.metadata == null) {
return '';
}
let t = this.job.metadata.timeout_human_readable;
if (this.job.metadata.timeout_source !== '') {
t += sprintf(__(` (from %{timeoutSource})`), {
timeoutSource: this.job.metadata.timeout_source,
});
}
return t;
},
renderBlock() {
return (
this.job.duration ||
this.job.finished_at ||
this.job.erased_at ||
this.job.queued ||
this.hasTimeout ||
this.job.runner ||
this.job.coverage ||
this.job.tags.length
);
},
hasArtifact() { hasArtifact() {
return !isEmpty(this.job.artifact); return !isEmpty(this.job.artifact);
}, },
...@@ -96,16 +51,10 @@ export default { ...@@ -96,16 +51,10 @@ export default {
return !isEmpty(this.job.trigger); return !isEmpty(this.job.trigger);
}, },
hasStages() { hasStages() {
return ( return this.job?.pipeline?.stages?.length > 0;
(this.job &&
this.job.pipeline &&
this.job.pipeline.stages &&
this.job.pipeline.stages.length > 0) ||
false
);
}, },
commit() { commit() {
return this.job.pipeline && this.job.pipeline.commit ? this.job.pipeline.commit : {}; return this.job?.pipeline?.commit || {};
}, },
}, },
methods: { methods: {
...@@ -131,22 +80,22 @@ export default { ...@@ -131,22 +80,22 @@ export default {
data-method="post" data-method="post"
data-qa-selector="retry_button" data-qa-selector="retry_button"
rel="nofollow" rel="nofollow"
>{{ __('Retry') }}</gl-link >{{ __('Retry') }}
> </gl-link>
<gl-link <gl-link
v-if="job.cancel_path" v-if="job.cancel_path"
:href="job.cancel_path" :href="job.cancel_path"
class="js-cancel-job btn btn-default" class="js-cancel-job btn btn-default"
data-method="post" data-method="post"
rel="nofollow" rel="nofollow"
>{{ __('Cancel') }}</gl-link >{{ __('Cancel') }}
> </gl-link>
</div> </div>
<gl-button <gl-button
:aria-label="__('Toggle Sidebar')" :aria-label="__('Toggle Sidebar')"
class="d-md-none gl-ml-2 js-sidebar-build-toggle"
category="tertiary" category="tertiary"
class="gl-display-md-none gl-ml-2 js-sidebar-build-toggle"
icon="chevron-double-lg-right" icon="chevron-double-lg-right"
@click="toggleSidebar" @click="toggleSidebar"
/> />
...@@ -158,77 +107,37 @@ export default { ...@@ -158,77 +107,37 @@ export default {
:href="job.new_issue_path" :href="job.new_issue_path"
class="btn btn-success btn-inverted float-left mr-2" class="btn btn-success btn-inverted float-left mr-2"
data-testid="job-new-issue" data-testid="job-new-issue"
>{{ __('New issue') }}</gl-link >{{ __('New issue') }}
> </gl-link>
<gl-link <gl-link
v-if="job.terminal_path" v-if="job.terminal_path"
:href="job.terminal_path" :href="job.terminal_path"
class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left" class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
target="_blank" target="_blank"
> >
{{ __('Debug') }} <gl-icon name="external-link" :size="14" /> {{ __('Debug') }}
<gl-icon :size="14" name="external-link" />
</gl-link> </gl-link>
</div> </div>
<sidebar-job-details-container :runner-help-url="runnerHelpUrl" />
<div v-if="renderBlock" class="block">
<detail-row
v-if="job.duration"
:value="duration"
class="js-job-duration"
title="Duration"
/>
<detail-row
v-if="job.finished_at"
:value="timeFormatted(job.finished_at)"
class="js-job-finished"
title="Finished"
/>
<detail-row
v-if="job.erased_at"
:value="timeFormatted(job.erased_at)"
class="js-job-erased"
title="Erased"
/>
<detail-row v-if="job.queued" :value="queued" class="js-job-queued" title="Queued" />
<detail-row
v-if="hasTimeout"
:help-url="runnerHelpUrl"
:value="timeout"
class="js-job-timeout"
title="Timeout"
/>
<detail-row v-if="job.runner" :value="runnerId" class="js-job-runner" title="Runner" />
<detail-row
v-if="job.coverage"
:value="coverage"
class="js-job-coverage"
title="Coverage"
/>
<p v-if="job.tags.length" class="build-detail-row js-job-tags">
<span class="font-weight-bold">{{ __('Tags:') }}</span>
<span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{
tag
}}</span>
</p>
</div>
<artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" /> <artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" />
<trigger-block v-if="hasTriggers" :trigger="job.trigger" /> <trigger-block v-if="hasTriggers" :trigger="job.trigger" />
<commit-block <commit-block
:is-last-block="hasStages"
:commit="commit" :commit="commit"
:is-last-block="hasStages"
:merge-request="job.merge_request" :merge-request="job.merge_request"
/> />
<stages-dropdown <stages-dropdown
:stages="stages" v-if="job.pipeline"
:pipeline="job.pipeline" :pipeline="job.pipeline"
:selected-stage="selectedStage" :selected-stage="selectedStage"
:stages="stages"
@requestSidebarStageDropdown="fetchJobsForStage" @requestSidebarStageDropdown="fetchJobsForStage"
/> />
</div> </div>
<jobs-container v-if="jobs.length" :jobs="jobs" :job-id="job.id" /> <jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" />
</div> </div>
</aside> </aside>
</template> </template>
<script>
import { mapState } from 'vuex';
import DetailRow from './sidebar_detail_row.vue';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
export default {
name: 'SidebarJobDetailsContainer',
components: {
DetailRow,
},
mixins: [timeagoMixin],
props: {
runnerHelpUrl: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState(['job']),
coverage() {
return `${this.job.coverage}%`;
},
duration() {
return timeIntervalInWords(this.job.duration);
},
erasedAt() {
return this.timeFormatted(this.job.erased_at);
},
finishedAt() {
return this.timeFormatted(this.job.finished_at);
},
hasTags() {
return this.job?.tags?.length;
},
hasTimeout() {
return this.job?.metadata?.timeout_human_readable ?? false;
},
hasAnyDetail() {
return Boolean(
this.job.duration ||
this.job.finished_at ||
this.job.erased_at ||
this.job.queued ||
this.job.runner ||
this.job.coverage,
);
},
queued() {
return timeIntervalInWords(this.job.queued);
},
runnerId() {
return `${this.job.runner.description} (#${this.job.runner.id})`;
},
shouldRenderBlock() {
return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags);
},
timeout() {
return `${this.job?.metadata?.timeout_human_readable}${this.timeoutSource}`;
},
timeoutSource() {
if (!this.job?.metadata?.timeout_source) {
return '';
}
return sprintf(__(` (from %{timeoutSource})`), {
timeoutSource: this.job.metadata.timeout_source,
});
},
},
};
</script>
<template>
<div v-if="shouldRenderBlock" class="block">
<detail-row v-if="job.duration" :value="duration" title="Duration" />
<detail-row
v-if="job.finished_at"
:value="finishedAt"
data-testid="job-finished"
title="Finished"
/>
<detail-row v-if="job.erased_at" :value="erasedAt" title="Erased" />
<detail-row v-if="job.queued" :value="queued" title="Queued" />
<detail-row
v-if="hasTimeout"
:help-url="runnerHelpUrl"
:value="timeout"
data-testid="job-timeout"
title="Timeout"
/>
<detail-row v-if="job.runner" :value="runnerId" title="Runner" />
<detail-row v-if="job.coverage" :value="coverage" title="Coverage" />
<p v-if="hasTags" class="build-detail-row" data-testid="job-tags">
<span class="font-weight-bold">{{ __('Tags:') }}</span>
<span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{ tag }}</span>
</p>
</div>
</template>
...@@ -4,7 +4,7 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; ...@@ -4,7 +4,7 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at); export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at);
export const hasUnmetPrerequisitesFailure = state => export const hasUnmetPrerequisitesFailure = state =>
state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites'; state?.job?.failure_reason === 'unmet_prerequisites';
export const shouldRenderCalloutMessage = state => export const shouldRenderCalloutMessage = state =>
!isEmpty(state.job.status) && !isEmpty(state.job.callout_message); !isEmpty(state.job.status) && !isEmpty(state.job.callout_message);
......
import { shallowMount } from '@vue/test-utils';
import SidebarJobDetailsContainer from '~/jobs/components/sidebar_job_details_container.vue';
import DetailRow from '~/jobs/components/sidebar_detail_row.vue';
import createStore from '~/jobs/store';
import { extendedWrapper } from '../../helpers/vue_test_utils_helper';
import job from '../mock_data';
describe('Sidebar Job Details Container', () => {
let store;
let wrapper;
const findJobTimeout = () => wrapper.findByTestId('job-timeout');
const findJobTags = () => wrapper.findByTestId('job-tags');
const findAllDetailsRow = () => wrapper.findAll(DetailRow);
const createWrapper = ({ props = {} } = {}) => {
store = createStore();
wrapper = extendedWrapper(
shallowMount(SidebarJobDetailsContainer, {
propsData: props,
store,
stubs: {
DetailRow,
},
}),
);
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('when no details are available', () => {
it('should render an empty container', () => {
createWrapper();
expect(wrapper.isEmpty()).toBe(true);
});
});
describe('when some of the details are available', () => {
beforeEach(createWrapper);
it.each([
['duration', 'Duration: 6 seconds'],
['erased_at', 'Erased: 3 weeks ago'],
['finished_at', 'Finished: 3 weeks ago'],
['queued', 'Queued: 9 seconds'],
['runner', 'Runner: local ci runner (#1)'],
['coverage', 'Coverage: 20%'],
])('uses %s to render job-%s', async (detail, value) => {
await store.dispatch('receiveJobSuccess', { [detail]: job[detail] });
const detailsRow = findAllDetailsRow();
expect(wrapper.isEmpty()).toBe(false);
expect(detailsRow).toHaveLength(1);
expect(detailsRow.at(0).text()).toBe(value);
});
it('only renders tags', async () => {
const { tags } = job;
await store.dispatch('receiveJobSuccess', { tags });
const tagsComponent = findJobTags();
expect(wrapper.isEmpty()).toBe(false);
expect(tagsComponent.text()).toBe('Tags: tag');
});
});
describe('when all the info are available', () => {
it('renders all the details components', async () => {
createWrapper();
await store.dispatch('receiveJobSuccess', job);
expect(findAllDetailsRow()).toHaveLength(7);
});
});
describe('timeout', () => {
const {
metadata: { timeout_human_readable, timeout_source },
} = job;
beforeEach(createWrapper);
it('does not render if metadata is empty', async () => {
const metadata = {};
await store.dispatch('receiveJobSuccess', { metadata });
const detailsRow = findAllDetailsRow();
expect(wrapper.isEmpty()).toBe(true);
expect(detailsRow.exists()).toBe(false);
});
it('uses metadata to render timeout', async () => {
const metadata = { timeout_human_readable };
await store.dispatch('receiveJobSuccess', { metadata });
const detailsRow = findAllDetailsRow();
expect(wrapper.isEmpty()).toBe(false);
expect(detailsRow).toHaveLength(1);
expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s');
});
it('uses metadata to render timeout and the source', async () => {
const metadata = { timeout_human_readable, timeout_source };
await store.dispatch('receiveJobSuccess', { metadata });
const detailsRow = findAllDetailsRow();
expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s (from runner)');
});
it('should not render when no time is provided', async () => {
const metadata = { timeout_source };
await store.dispatch('receiveJobSuccess', { metadata });
expect(findJobTimeout().exists()).toBe(false);
});
it('should pass the help URL', async () => {
const helpUrl = 'fakeUrl';
const props = { runnerHelpUrl: helpUrl };
createWrapper({ props });
await store.dispatch('receiveJobSuccess', { metadata: { timeout_human_readable } });
expect(findJobTimeout().props('helpUrl')).toBe(helpUrl);
});
});
});
import Vue from 'vue'; import { shallowMount } from '@vue/test-utils';
import sidebarDetailsBlock from '~/jobs/components/sidebar.vue'; import Sidebar from '~/jobs/components/sidebar.vue';
import StagesDropdown from '~/jobs/components/stages_dropdown.vue';
import JobsContainer from '~/jobs/components/jobs_container.vue';
import createStore from '~/jobs/store'; import createStore from '~/jobs/store';
import job, { jobsInStage } from '../mock_data'; import job, { jobsInStage } from '../mock_data';
import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; import { extendedWrapper } from '../../helpers/vue_test_utils_helper';
import { trimText } from '../../helpers/text_helper';
describe('Sidebar details block', () => { describe('Sidebar details block', () => {
const SidebarComponent = Vue.extend(sidebarDetailsBlock);
let vm;
let store; let store;
let wrapper;
beforeEach(() => { const createWrapper = ({ props = {} } = {}) => {
store = createStore(); store = createStore();
}); wrapper = extendedWrapper(
shallowMount(Sidebar, {
...props,
store,
}),
);
};
afterEach(() => { afterEach(() => {
vm.$destroy(); if (wrapper) {
wrapper.destroy();
wrapper = null;
}
}); });
describe('when there is no retry path retry', () => { describe('when there is no retry path retry', () => {
it('should not render a retry button', () => { it('should not render a retry button', async () => {
const copy = { ...job }; createWrapper();
delete copy.retry_path; const copy = { ...job, retry_path: null };
await store.dispatch('receiveJobSuccess', copy);
store.dispatch('receiveJobSuccess', copy);
vm = mountComponentWithStore(SidebarComponent, {
store,
});
expect(vm.$el.querySelector('.js-retry-button')).toBeNull(); expect(wrapper.find('.js-retry-button').exists()).toBe(false);
}); });
}); });
describe('without terminal path', () => { describe('without terminal path', () => {
it('does not render terminal link', () => { it('does not render terminal link', async () => {
store.dispatch('receiveJobSuccess', job); createWrapper();
vm = mountComponentWithStore(SidebarComponent, { store }); await store.dispatch('receiveJobSuccess', job);
expect(vm.$el.querySelector('.js-terminal-link')).toBeNull(); expect(wrapper.find('.js-terminal-link').exists()).toBe(false);
}); });
}); });
describe('with terminal path', () => { describe('with terminal path', () => {
it('renders terminal link', () => { it('renders terminal link', async () => {
store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' }); createWrapper();
vm = mountComponentWithStore(SidebarComponent, { await store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
store,
});
expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull(); expect(wrapper.find('.js-terminal-link').exists()).toBe(true);
}); });
}); });
describe('actions', () => {
beforeEach(() => { beforeEach(() => {
store.dispatch('receiveJobSuccess', job); createWrapper();
vm = mountComponentWithStore(SidebarComponent, { store }); return store.dispatch('receiveJobSuccess', job);
}); });
describe('actions', () => {
it('should render link to new issue', () => { it('should render link to new issue', () => {
expect(vm.$el.querySelector('[data-testid="job-new-issue"]').getAttribute('href')).toEqual( expect(wrapper.findByTestId('job-new-issue').attributes('href')).toBe(job.new_issue_path);
job.new_issue_path, expect(wrapper.find('[data-testid="job-new-issue"]').text()).toBe('New issue');
);
expect(vm.$el.querySelector('[data-testid="job-new-issue"]').textContent.trim()).toEqual(
'New issue',
);
}); });
it('should render link to retry job', () => { it('should render link to retry job', () => {
expect(vm.$el.querySelector('.js-retry-button').getAttribute('href')).toEqual(job.retry_path); expect(wrapper.find('.js-retry-button').attributes('href')).toBe(job.retry_path);
}); });
it('should render link to cancel job', () => { it('should render link to cancel job', () => {
expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path); expect(wrapper.find('.js-cancel-job').attributes('href')).toBe(job.cancel_path);
});
});
describe('information', () => {
it('should render job duration', () => {
expect(trimText(vm.$el.querySelector('.js-job-duration').textContent)).toEqual(
'Duration: 6 seconds',
);
});
it('should render erased date', () => {
expect(trimText(vm.$el.querySelector('.js-job-erased').textContent)).toEqual(
'Erased: 3 weeks ago',
);
});
it('should render finished date', () => {
expect(trimText(vm.$el.querySelector('.js-job-finished').textContent)).toEqual(
'Finished: 3 weeks ago',
);
});
it('should render queued date', () => {
expect(trimText(vm.$el.querySelector('.js-job-queued').textContent)).toEqual(
'Queued: 9 seconds',
);
});
it('should render runner ID', () => {
expect(trimText(vm.$el.querySelector('.js-job-runner').textContent)).toEqual(
'Runner: local ci runner (#1)',
);
});
it('should render timeout information', () => {
expect(trimText(vm.$el.querySelector('.js-job-timeout').textContent)).toEqual(
'Timeout: 1m 40s (from runner)',
);
});
it('should render coverage', () => {
expect(trimText(vm.$el.querySelector('.js-job-coverage').textContent)).toEqual(
'Coverage: 20%',
);
});
it('should render tags', () => {
expect(trimText(vm.$el.querySelector('.js-job-tags').textContent)).toEqual('Tags: tag');
}); });
}); });
describe('stages dropdown', () => { describe('stages dropdown', () => {
beforeEach(() => { beforeEach(() => {
store.dispatch('receiveJobSuccess', job); createWrapper();
return store.dispatch('receiveJobSuccess', { ...job, stage: 'aStage' });
}); });
describe('with stages', () => { describe('with stages', () => {
beforeEach(() => {
vm = mountComponentWithStore(SidebarComponent, { store });
});
it('renders value provided as selectedStage as selected', () => { it('renders value provided as selectedStage as selected', () => {
expect(vm.$el.querySelector('.js-selected-stage').textContent.trim()).toEqual( expect(wrapper.find(StagesDropdown).props('selectedStage')).toBe('aStage');
vm.selectedStage,
);
}); });
}); });
describe('without jobs for stages', () => { describe('without jobs for stages', () => {
beforeEach(() => { beforeEach(() => store.dispatch('receiveJobSuccess', job));
store.dispatch('receiveJobSuccess', job);
vm = mountComponentWithStore(SidebarComponent, { store });
});
it('does not render job container', () => { it('does not render job container', () => {
expect(vm.$el.querySelector('.js-jobs-container')).toBeNull(); expect(wrapper.find('.js-jobs-container').exists()).toBe(false);
}); });
}); });
describe('with jobs for stages', () => { describe('with jobs for stages', () => {
beforeEach(() => { beforeEach(async () => {
store.dispatch('receiveJobSuccess', job); await store.dispatch('receiveJobSuccess', job);
store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses); await store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
vm = mountComponentWithStore(SidebarComponent, { store });
}); });
it('renders list of jobs', () => { it('renders list of jobs', () => {
expect(vm.$el.querySelector('.js-jobs-container')).not.toBeNull(); expect(wrapper.find(JobsContainer).exists()).toBe(true);
}); });
}); });
}); });
......
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