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> <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);
}); });
}); });
beforeEach(() => {
store.dispatch('receiveJobSuccess', job);
vm = mountComponentWithStore(SidebarComponent, { store });
});
describe('actions', () => { describe('actions', () => {
it('should render link to new issue', () => { beforeEach(() => {
expect(vm.$el.querySelector('[data-testid="job-new-issue"]').getAttribute('href')).toEqual( createWrapper();
job.new_issue_path, return store.dispatch('receiveJobSuccess', job);
); });
expect(vm.$el.querySelector('[data-testid="job-new-issue"]').textContent.trim()).toEqual( it('should render link to new issue', () => {
'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', () => { 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