Commit b89e9b6f authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'pb-build-out-new-jobs-table-vue' into 'master'

Build out jobs table cells

See merge request gitlab-org/gitlab!59774
parents 38b5d1cd 60856af6
<script>
export default {
props: {
job: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div></div>
</template>
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
iconSize: 12,
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
},
mixins: [timeagoMixin],
props: {
job: {
type: Object,
required: true,
},
},
computed: {
finishedTime() {
return this.job?.finishedAt;
},
duration() {
return this.job?.duration;
},
},
};
</script>
<template>
<div>
<div v-if="duration" data-testid="job-duration">
<gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" />
{{ durationTimeFormatted(duration) }}
</div>
<div v-if="finishedTime" data-testid="job-finished-time">
<gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" />
<time
v-gl-tooltip
:title="tooltipTitle(finishedTime)"
data-placement="top"
data-container="body"
>
{{ timeFormatted(finishedTime) }}
</time>
</div>
</div>
</template>
<script>
import { GlBadge, GlIcon, GlLink } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { SUCCESS_STATUS } from '../../../constants';
export default {
iconSize: 12,
badgeSize: 'sm',
components: {
GlBadge,
GlIcon,
GlLink,
},
props: {
job: {
type: Object,
required: true,
},
},
computed: {
jobId() {
const id = getIdFromGraphQLId(this.job.id);
return `#${id}`;
},
jobPath() {
return this.job.detailedStatus?.detailsPath;
},
jobRef() {
return this.job?.refName;
},
jobRefPath() {
return this.job?.refPath;
},
jobTags() {
return this.job.tags;
},
createdByTag() {
return this.job.createdByTag;
},
triggered() {
return this.job.triggered;
},
isManualJob() {
return this.job.manualJob;
},
successfulJob() {
return this.job.status === SUCCESS_STATUS;
},
showAllowedToFailBadge() {
return this.job.allowFailure && !this.successfulJob;
},
isScheduledJob() {
return Boolean(this.job.scheduledAt);
},
},
};
</script>
<template>
<div>
<div class="gl-text-truncate">
<gl-link class="gl-text-gray-500!" :href="jobPath" data-testid="job-id">{{ jobId }}</gl-link>
<div class="gl-display-flex gl-align-items-center">
<div v-if="jobRef" class="gl-max-w-15 gl-text-truncate">
<gl-icon
v-if="createdByTag"
name="label"
:size="$options.iconSize"
data-testid="label-icon"
/>
<gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" />
<gl-link
class="gl-font-weight-bold gl-text-gray-500!"
:href="job.refPath"
data-testid="job-ref"
>{{ job.refName }}</gl-link
>
</div>
<span v-else>{{ __('none') }}</span>
<gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" />
<gl-link :href="job.commitPath" data-testid="job-sha">{{ job.shortSha }}</gl-link>
</div>
</div>
<div>
<gl-badge
v-for="tag in jobTags"
:key="tag"
variant="info"
:size="$options.badgeSize"
data-testid="job-tag-badge"
>
{{ tag }}
</gl-badge>
<gl-badge
v-if="triggered"
variant="info"
:size="$options.badgeSize"
data-testid="triggered-job-badge"
>{{ s__('Job|triggered') }}
</gl-badge>
<gl-badge
v-if="showAllowedToFailBadge"
variant="warning"
:size="$options.badgeSize"
data-testid="fail-job-badge"
>{{ s__('Job|allowed to fail') }}
</gl-badge>
<gl-badge
v-if="isScheduledJob"
variant="info"
:size="$options.badgeSize"
data-testid="delayed-job-badge"
>{{ s__('Job|delayed') }}
</gl-badge>
<gl-badge
v-if="isManualJob"
variant="info"
:size="$options.badgeSize"
data-testid="manual-job-badge"
>
{{ s__('Job|manual') }}
</gl-badge>
</div>
</div>
</template>
<script>
import { GlAvatar, GlLink } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export default {
components: {
GlAvatar,
GlLink,
},
props: {
job: {
type: Object,
required: true,
},
},
computed: {
pipelineId() {
const id = getIdFromGraphQLId(this.job.pipeline.id);
return `#${id}`;
},
pipelinePath() {
return this.job.pipeline?.path;
},
pipelineUserAvatar() {
return this.job.pipeline?.user?.avatarUrl;
},
userPath() {
return this.job.pipeline?.user?.webPath;
},
showAvatar() {
return this.job.pipeline?.user;
},
},
};
</script>
<template>
<div class="gl-text-truncate">
<gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id">
{{ pipelineId }}
</gl-link>
<div>
<span>{{ __('created by') }}</span>
<gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link">
<gl-avatar :src="pipelineUserAvatar" :size="16" />
</gl-link>
<span v-else>{{ __('API') }}</span>
</div>
</div>
</template>
......@@ -8,7 +8,20 @@ query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) {
startCursor
}
nodes {
artifacts {
nodes {
downloadPath
}
}
allowFailure
status
scheduledAt
manualJob
triggered
createdByTag
detailedStatus {
detailsPath
group
icon
label
text
......
<script>
import { GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import ActionsCell from './cells/actions_cell.vue';
import DurationCell from './cells/duration_cell.vue';
import JobCell from './cells/job_cell.vue';
import PipelineCell from './cells/pipeline_cell.vue';
const defaultTableClasses = {
tdClass: 'gl-p-5!',
......@@ -13,45 +18,58 @@ export default {
key: 'status',
label: __('Status'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'job',
label: __('Job'),
...defaultTableClasses,
columnClass: 'gl-w-20p',
},
{
key: 'pipeline',
label: __('Pipeline'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'stage',
label: __('Stage'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'name',
label: __('Name'),
...defaultTableClasses,
columnClass: 'gl-w-15p',
},
{
key: 'duration',
label: __('Duration'),
...defaultTableClasses,
columnClass: 'gl-w-15p',
},
{
key: 'coverage',
label: __('Coverage'),
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
{
key: 'actions',
label: '',
...defaultTableClasses,
columnClass: 'gl-w-10p',
},
],
components: {
ActionsCell,
CiBadge,
DurationCell,
GlTable,
JobCell,
PipelineCell,
},
props: {
jobs: {
......@@ -59,9 +77,62 @@ export default {
required: true,
},
},
methods: {
formatCoverage(coverage) {
return coverage ? `${coverage}%` : '';
},
},
};
</script>
<template>
<gl-table :items="jobs" :fields="$options.fields" />
<gl-table
:items="jobs"
:fields="$options.fields"
:tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }"
stacked="lg"
fixed
>
<template #table-colgroup="{ fields }">
<col v-for="field in fields" :key="field.key" :class="field.columnClass" />
</template>
<template #cell(status)="{ item }">
<ci-badge :status="item.detailedStatus" />
</template>
<template #cell(job)="{ item }">
<job-cell :job="item" />
</template>
<template #cell(pipeline)="{ item }">
<pipeline-cell :job="item" />
</template>
<template #cell(stage)="{ item }">
<div class="gl-text-truncate">
<span data-testid="job-stage-name">{{ item.stage.name }}</span>
</div>
</template>
<template #cell(name)="{ item }">
<div class="gl-text-truncate">
<span data-testid="job-name">{{ item.name }}</span>
</div>
</template>
<template #cell(duration)="{ item }">
<duration-cell :job="item" />
</template>
<template #cell(coverage)="{ item }">
<span v-if="item.coverage" data-testid="job-coverage">{{
formatCoverage(item.coverage)
}}</span>
</template>
<template #cell(actions)="{ item }">
<actions-cell :job="item" />
</template>
</gl-table>
</template>
......@@ -50,7 +50,7 @@ export default {
</script>
<template>
<gl-tabs>
<gl-tabs content-class="gl-pb-0">
<gl-tab
v-for="tab in tabs"
:key="tab.text"
......
......@@ -22,3 +22,5 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
primaryText: __('Retry job'),
title: s__('Jobs|Are you sure you want to retry this job?'),
};
export const SUCCESS_STATUS = 'SUCCESS';
......@@ -14,5 +14,25 @@ export default {
tooltipTitle(time) {
return formatDate(time);
},
durationTimeFormatted(duration) {
const date = new Date(duration * 1000);
let hh = date.getUTCHours();
let mm = date.getUTCMinutes();
let ss = date.getSeconds();
if (hh < 10) {
hh = `0${hh}`;
}
if (mm < 10) {
mm = `0${mm}`;
}
if (ss < 10) {
ss = `0${ss}`;
}
return `${hh}:${mm}:${ss}`;
},
},
};
......@@ -1487,6 +1487,9 @@ msgstr ""
msgid "ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'"
msgstr ""
msgid "API"
msgstr ""
msgid "API Fuzzing"
msgstr ""
......@@ -18543,12 +18546,24 @@ msgstr ""
msgid "Job|This job is stuck because you don't have any active runners that can run this job."
msgstr ""
msgid "Job|allowed to fail"
msgstr ""
msgid "Job|delayed"
msgstr ""
msgid "Job|for"
msgstr ""
msgid "Job|into"
msgstr ""
msgid "Job|manual"
msgstr ""
msgid "Job|triggered"
msgstr ""
msgid "Job|with"
msgstr ""
......@@ -37584,6 +37599,9 @@ msgstr ""
msgid "created %{timeAgo}"
msgstr ""
msgid "created by"
msgstr ""
msgid "data"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DurationCell from '~/jobs/components/table/cells/duration_cell.vue';
describe('Duration Cell', () => {
let wrapper;
const findJobDuration = () => wrapper.findByTestId('job-duration');
const findJobFinishedTime = () => wrapper.findByTestId('job-finished-time');
const findDurationIcon = () => wrapper.findByTestId('duration-icon');
const findFinishedTimeIcon = () => wrapper.findByTestId('finished-time-icon');
const createComponent = (props) => {
wrapper = extendedWrapper(
shallowMount(DurationCell, {
propsData: {
job: {
...props,
},
},
}),
);
};
afterEach(() => {
wrapper.destroy();
});
it('does not display duration or finished time when no properties are present', () => {
createComponent();
expect(findJobDuration().exists()).toBe(false);
expect(findJobFinishedTime().exists()).toBe(false);
});
it('displays duration and finished time when both properties are present', () => {
const props = {
duration: 7,
finishedAt: '2021-04-26T13:37:52Z',
};
createComponent(props);
expect(findJobDuration().exists()).toBe(true);
expect(findJobFinishedTime().exists()).toBe(true);
});
it('displays only the duration of the job when the duration property is present', () => {
const props = {
duration: 7,
};
createComponent(props);
expect(findJobDuration().exists()).toBe(true);
expect(findJobFinishedTime().exists()).toBe(false);
});
it('displays only the finished time of the job when the finshedAt property is present', () => {
const props = {
finishedAt: '2021-04-26T13:37:52Z',
};
createComponent(props);
expect(findJobFinishedTime().exists()).toBe(true);
expect(findJobDuration().exists()).toBe(false);
});
it('displays icons for finished time and duration', () => {
const props = {
duration: 7,
finishedAt: '2021-04-26T13:37:52Z',
};
createComponent(props);
expect(findFinishedTimeIcon().props('name')).toBe('calendar');
expect(findDurationIcon().props('name')).toBe('timer');
});
});
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import JobCell from '~/jobs/components/table/cells/job_cell.vue';
import { mockJobsInTable } from '../../../mock_data';
const mockJob = mockJobsInTable[0];
const mockJobCreatedByTag = mockJobsInTable[1];
describe('Job Cell', () => {
let wrapper;
const findJobId = () => wrapper.findByTestId('job-id');
const findJobRef = () => wrapper.findByTestId('job-ref');
const findJobSha = () => wrapper.findByTestId('job-sha');
const findLabelIcon = () => wrapper.findByTestId('label-icon');
const findForkIcon = () => wrapper.findByTestId('fork-icon');
const findAllTagBadges = () => wrapper.findAllByTestId('job-tag-badge');
const findBadgeById = (id) => wrapper.findByTestId(id);
const createComponent = (jobData = mockJob) => {
wrapper = extendedWrapper(
shallowMount(JobCell, {
propsData: {
job: jobData,
},
}),
);
};
afterEach(() => {
wrapper.destroy();
});
describe('Job Id', () => {
beforeEach(() => {
createComponent();
});
it('displays the job id and links to the job', () => {
const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}`;
expect(findJobId().text()).toBe(expectedJobId);
expect(findJobId().attributes('href')).toBe(mockJob.detailedStatus.detailsPath);
});
});
describe('Ref of the job', () => {
it('displays the ref name and links to the ref', () => {
createComponent();
expect(findJobRef().text()).toBe(mockJob.refName);
expect(findJobRef().attributes('href')).toBe(mockJob.refPath);
});
it('displays fork icon when job is not created by tag', () => {
createComponent();
expect(findForkIcon().exists()).toBe(true);
expect(findLabelIcon().exists()).toBe(false);
});
it('displays label icon when job is created by a tag', () => {
createComponent(mockJobCreatedByTag);
expect(findLabelIcon().exists()).toBe(true);
expect(findForkIcon().exists()).toBe(false);
});
});
describe('Commit of the job', () => {
beforeEach(() => {
createComponent();
});
it('displays the sha and links to the commit', () => {
expect(findJobSha().text()).toBe(mockJob.shortSha);
expect(findJobSha().attributes('href')).toBe(mockJob.commitPath);
});
});
describe('Job badges', () => {
it('displays tags of the job', () => {
const mockJobWithTags = {
tags: ['tag-1', 'tag-2', 'tag-3'],
};
createComponent(mockJobWithTags);
expect(findAllTagBadges()).toHaveLength(mockJobWithTags.tags.length);
});
it.each`
testId | text
${'manual-job-badge'} | ${'manual'}
${'triggered-job-badge'} | ${'triggered'}
${'fail-job-badge'} | ${'allowed to fail'}
${'delayed-job-badge'} | ${'delayed'}
`('displays the static $text badge', ({ testId, text }) => {
createComponent({
manualJob: true,
triggered: true,
allowFailure: true,
scheduledAt: '2021-03-09T14:58:50+00:00',
});
expect(findBadgeById(testId).exists()).toBe(true);
expect(findBadgeById(testId).text()).toBe(text);
});
});
});
import { GlAvatar } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import PipelineCell from '~/jobs/components/table/cells/pipeline_cell.vue';
const mockJobWithoutUser = {
id: 'gid://gitlab/Ci::Build/2264',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/460',
path: '/root/ci-project/-/pipelines/460',
},
};
const mockJobWithUser = {
id: 'gid://gitlab/Ci::Build/2264',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/460',
path: '/root/ci-project/-/pipelines/460',
user: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
webPath: '/root',
},
},
};
describe('Pipeline Cell', () => {
let wrapper;
const findPipelineId = () => wrapper.findByTestId('pipeline-id');
const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link');
const findUserAvatar = () => wrapper.findComponent(GlAvatar);
const createComponent = (props = mockJobWithUser) => {
wrapper = extendedWrapper(
shallowMount(PipelineCell, {
propsData: {
job: props,
},
}),
);
};
afterEach(() => {
wrapper.destroy();
});
describe('Pipeline Id', () => {
beforeEach(() => {
createComponent();
});
it('displays the pipeline id and links to the pipeline', () => {
const expectedPipelineId = `#${getIdFromGraphQLId(mockJobWithUser.pipeline.id)}`;
expect(findPipelineId().text()).toBe(expectedPipelineId);
expect(findPipelineId().attributes('href')).toBe(mockJobWithUser.pipeline.path);
});
});
describe('Pipeline created by', () => {
const apiWrapperText = 'API';
it('shows and links to the pipeline user', () => {
createComponent();
expect(findPipelineUserLink().exists()).toBe(true);
expect(findPipelineUserLink().attributes('href')).toBe(mockJobWithUser.pipeline.user.webPath);
expect(findUserAvatar().attributes('src')).toBe(mockJobWithUser.pipeline.user.avatarUrl);
expect(wrapper.text()).not.toContain(apiWrapperText);
});
it('shows pipeline was created by the API', () => {
createComponent(mockJobWithoutUser);
expect(findPipelineUserLink().exists()).toBe(false);
expect(findUserAvatar().exists()).toBe(false);
expect(wrapper.text()).toContain(apiWrapperText);
});
});
});
import { GlTable } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import { mockJobsInTable } from '../../mock_data';
describe('Jobs Table', () => {
let wrapper;
const findTable = () => wrapper.findComponent(GlTable);
const findStatusBadge = () => wrapper.findComponent(CiBadge);
const findTableRows = () => wrapper.findAllByTestId('jobs-table-row');
const findJobStage = () => wrapper.findByTestId('job-stage-name');
const findJobName = () => wrapper.findByTestId('job-name');
const findAllCoverageJobs = () => wrapper.findAllByTestId('job-coverage');
const createComponent = (props = {}) => {
wrapper = shallowMount(JobsTable, {
propsData: {
jobs: mockJobsInTable,
...props,
},
});
wrapper = extendedWrapper(
mount(JobsTable, {
propsData: {
jobs: mockJobsInTable,
...props,
},
}),
);
};
beforeEach(() => {
......@@ -25,7 +34,31 @@ describe('Jobs Table', () => {
wrapper.destroy();
});
it('displays a table', () => {
it('displays the jobs table', () => {
expect(findTable().exists()).toBe(true);
});
it('displays correct number of job rows', () => {
expect(findTableRows()).toHaveLength(mockJobsInTable.length);
});
it('displays job status', () => {
expect(findStatusBadge().exists()).toBe(true);
});
it('displays the job stage and name', () => {
const firstJob = mockJobsInTable[0];
expect(findJobStage().text()).toBe(firstJob.stage.name);
expect(findJobName().text()).toBe(firstJob.name);
});
it('displays the coverage for only jobs that have coverage', () => {
const jobsThatHaveCoverage = mockJobsInTable.filter((job) => job.coverage !== null);
jobsThatHaveCoverage.forEach((job, index) => {
expect(findAllCoverageJobs().at(index).text()).toBe(`${job.coverage}%`);
});
expect(findAllCoverageJobs()).toHaveLength(jobsThatHaveCoverage.length);
});
});
......@@ -1292,6 +1292,7 @@ export const mockJobsInTable = [
title: 'Play',
__typename: 'StatusAction',
},
detailsPath: '/root/ci-project/-/jobs/2004',
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/2004',
......@@ -1316,6 +1317,7 @@ export const mockJobsInTable = [
duration: null,
finishedAt: null,
coverage: null,
createdByTag: false,
retryable: false,
playable: true,
cancelable: false,
......@@ -1353,6 +1355,7 @@ export const mockJobsInTable = [
duration: null,
finishedAt: null,
coverage: null,
createdByTag: true,
retryable: false,
playable: false,
cancelable: false,
......@@ -1396,7 +1399,8 @@ export const mockJobsInTable = [
name: 'artifact_job',
duration: 2,
finishedAt: '2021-04-01T17:36:18Z',
coverage: null,
coverage: 82.71,
createdByTag: false,
retryable: true,
playable: false,
cancelable: false,
......
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