Commit 4331452c authored by Angelo Gulina's avatar Angelo Gulina Committed by David O'Regan

Move job details into own component

Update spec to match FE guidelines
Update spec with more test cases covering functionality
parent e70f863d
<script>
import { isEmpty } from 'lodash';
import { mapActions, mapState } from 'vuex';
import { GlLink, GlButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { GlButton, GlIcon, GlLink } from '@gitlab/ui';
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 TriggerBlock from './trigger_block.vue';
import CommitBlock from './commit_block.vue';
import StagesDropdown from './stages_dropdown.vue';
import JobsContainer from './jobs_container.vue';
import SidebarJobDetailsContainer from './sidebar_job_details_container.vue';
export default {
name: 'JobSidebar',
components: {
ArtifactsBlock,
CommitBlock,
DetailRow,
GlIcon,
TriggerBlock,
StagesDropdown,
JobsContainer,
GlLink,
GlButton,
SidebarJobDetailsContainer,
TooltipOnTruncate,
},
mixins: [timeagoMixin],
props: {
artifactHelpUrl: {
type: String,
......@@ -42,53 +38,12 @@ export default {
},
computed: {
...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() {
let className = 'js-retry-button btn btn-retry';
className +=
this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary';
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() {
return !isEmpty(this.job.artifact);
},
......@@ -96,16 +51,10 @@ export default {
return !isEmpty(this.job.trigger);
},
hasStages() {
return (
(this.job &&
this.job.pipeline &&
this.job.pipeline.stages &&
this.job.pipeline.stages.length > 0) ||
false
);
return this.job?.pipeline?.stages?.length > 0;
},
commit() {
return this.job.pipeline && this.job.pipeline.commit ? this.job.pipeline.commit : {};
return this.job?.pipeline?.commit || {};
},
},
methods: {
......@@ -131,22 +80,22 @@ export default {
data-method="post"
data-qa-selector="retry_button"
rel="nofollow"
>{{ __('Retry') }}</gl-link
>
>{{ __('Retry') }}
</gl-link>
<gl-link
v-if="job.cancel_path"
:href="job.cancel_path"
class="js-cancel-job btn btn-default"
data-method="post"
rel="nofollow"
>{{ __('Cancel') }}</gl-link
>
>{{ __('Cancel') }}
</gl-link>
</div>
<gl-button
:aria-label="__('Toggle Sidebar')"
class="d-md-none gl-ml-2 js-sidebar-build-toggle"
category="tertiary"
class="gl-display-md-none gl-ml-2 js-sidebar-build-toggle"
icon="chevron-double-lg-right"
@click="toggleSidebar"
/>
......@@ -158,77 +107,37 @@ export default {
:href="job.new_issue_path"
class="btn btn-success btn-inverted float-left mr-2"
data-testid="job-new-issue"
>{{ __('New issue') }}</gl-link
>
>{{ __('New issue') }}
</gl-link>
<gl-link
v-if="job.terminal_path"
:href="job.terminal_path"
class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
target="_blank"
>
{{ __('Debug') }} <gl-icon name="external-link" :size="14" />
{{ __('Debug') }}
<gl-icon :size="14" name="external-link" />
</gl-link>
</div>
<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>
<sidebar-job-details-container :runner-help-url="runnerHelpUrl" />
<artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" />
<trigger-block v-if="hasTriggers" :trigger="job.trigger" />
<commit-block
:is-last-block="hasStages"
:commit="commit"
:is-last-block="hasStages"
:merge-request="job.merge_request"
/>
<stages-dropdown
:stages="stages"
v-if="job.pipeline"
:pipeline="job.pipeline"
:selected-stage="selectedStage"
:stages="stages"
@requestSidebarStageDropdown="fetchJobsForStage"
/>
</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>
</aside>
</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';
export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at);
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 =>
!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 sidebarDetailsBlock from '~/jobs/components/sidebar.vue';
import { shallowMount } from '@vue/test-utils';
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 job, { jobsInStage } from '../mock_data';
import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { trimText } from '../../helpers/text_helper';
import { extendedWrapper } from '../../helpers/vue_test_utils_helper';
describe('Sidebar details block', () => {
const SidebarComponent = Vue.extend(sidebarDetailsBlock);
let vm;
let store;
let wrapper;
beforeEach(() => {
const createWrapper = ({ props = {} } = {}) => {
store = createStore();
});
wrapper = extendedWrapper(
shallowMount(Sidebar, {
...props,
store,
}),
);
};
afterEach(() => {
vm.$destroy();
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('when there is no retry path retry', () => {
it('should not render a retry button', () => {
const copy = { ...job };
delete copy.retry_path;
store.dispatch('receiveJobSuccess', copy);
vm = mountComponentWithStore(SidebarComponent, {
store,
});
it('should not render a retry button', async () => {
createWrapper();
const copy = { ...job, retry_path: null };
await store.dispatch('receiveJobSuccess', copy);
expect(vm.$el.querySelector('.js-retry-button')).toBeNull();
expect(wrapper.find('.js-retry-button').exists()).toBe(false);
});
});
describe('without terminal path', () => {
it('does not render terminal link', () => {
store.dispatch('receiveJobSuccess', job);
vm = mountComponentWithStore(SidebarComponent, { store });
it('does not render terminal link', async () => {
createWrapper();
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', () => {
it('renders terminal link', () => {
store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
vm = mountComponentWithStore(SidebarComponent, {
store,
});
it('renders terminal link', async () => {
createWrapper();
await store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull();
expect(wrapper.find('.js-terminal-link').exists()).toBe(true);
});
});
beforeEach(() => {
store.dispatch('receiveJobSuccess', job);
vm = mountComponentWithStore(SidebarComponent, { store });
});
describe('actions', () => {
it('should render link to new issue', () => {
expect(vm.$el.querySelector('[data-testid="job-new-issue"]').getAttribute('href')).toEqual(
job.new_issue_path,
);
beforeEach(() => {
createWrapper();
return store.dispatch('receiveJobSuccess', job);
});
expect(vm.$el.querySelector('[data-testid="job-new-issue"]').textContent.trim()).toEqual(
'New issue',
);
it('should render link to new issue', () => {
expect(wrapper.findByTestId('job-new-issue').attributes('href')).toBe(job.new_issue_path);
expect(wrapper.find('[data-testid="job-new-issue"]').text()).toBe('New issue');
});
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', () => {
expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(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');
expect(wrapper.find('.js-cancel-job').attributes('href')).toBe(job.cancel_path);
});
});
describe('stages dropdown', () => {
beforeEach(() => {
store.dispatch('receiveJobSuccess', job);
createWrapper();
return store.dispatch('receiveJobSuccess', { ...job, stage: 'aStage' });
});
describe('with stages', () => {
beforeEach(() => {
vm = mountComponentWithStore(SidebarComponent, { store });
});
it('renders value provided as selectedStage as selected', () => {
expect(vm.$el.querySelector('.js-selected-stage').textContent.trim()).toEqual(
vm.selectedStage,
);
expect(wrapper.find(StagesDropdown).props('selectedStage')).toBe('aStage');
});
});
describe('without jobs for stages', () => {
beforeEach(() => {
store.dispatch('receiveJobSuccess', job);
vm = mountComponentWithStore(SidebarComponent, { store });
});
beforeEach(() => store.dispatch('receiveJobSuccess', job));
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', () => {
beforeEach(() => {
store.dispatch('receiveJobSuccess', job);
store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
vm = mountComponentWithStore(SidebarComponent, { store });
beforeEach(async () => {
await store.dispatch('receiveJobSuccess', job);
await store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
});
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