diff options
Diffstat (limited to 'app/assets/javascripts/jobs')
13 files changed, 330 insertions, 48 deletions
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index 9d451f94e8a..da72cbeb856 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -2,7 +2,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { __ } from '../../locale'; +import { __ } from '~/locale'; export default { creatingEnvironment: 'creating', diff --git a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue new file mode 100644 index 00000000000..fe7b7428c6e --- /dev/null +++ b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue @@ -0,0 +1,42 @@ +<script> +import { GlFilteredSearch } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import JobStatusToken from './tokens/job_status_token.vue'; + +export default { + tokenTypes: { + status: 'status', + }, + components: { + GlFilteredSearch, + }, + computed: { + tokens() { + return [ + { + type: this.$options.tokenTypes.status, + icon: 'status', + title: s__('Jobs|Status'), + unique: true, + token: JobStatusToken, + operators: OPERATOR_IS_ONLY, + }, + ]; + }, + }, + methods: { + onSubmit(filters) { + this.$emit('filterJobsBySearch', filters); + }, + }, +}; +</script> + +<template> + <gl-filtered-search + :placeholder="s__('Jobs|Filter jobs')" + :available-tokens="tokens" + @submit="onSubmit" + /> +</template> diff --git a/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue b/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue new file mode 100644 index 00000000000..aad86ded80a --- /dev/null +++ b/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue @@ -0,0 +1,122 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + statuses() { + return [ + { + class: 'ci-status-icon-canceled', + icon: 'status_canceled', + text: s__('Job|Canceled'), + value: 'CANCELED', + }, + { + class: 'ci-status-icon-created', + icon: 'status_created', + text: s__('Job|Created'), + value: 'CREATED', + }, + { + class: 'ci-status-icon-failed', + icon: 'status_failed', + text: s__('Job|Failed'), + value: 'FAILED', + }, + { + class: 'ci-status-icon-manual', + icon: 'status_manual', + text: s__('Job|Manual'), + value: 'MANUAL', + }, + { + class: 'ci-status-icon-success', + icon: 'status_success', + text: s__('Job|Passed'), + value: 'SUCCESS', + }, + { + class: 'ci-status-icon-pending', + icon: 'status_pending', + text: s__('Job|Pending'), + value: 'PENDING', + }, + { + class: 'ci-status-icon-preparing', + icon: 'status_preparing', + text: s__('Job|Preparing'), + value: 'PREPARING', + }, + { + class: 'ci-status-icon-running', + icon: 'status_running', + text: s__('Job|Running'), + value: 'RUNNING', + }, + { + class: 'ci-status-icon-scheduled', + icon: 'status_scheduled', + text: s__('Job|Scheduled'), + value: 'SCHEDULED', + }, + { + class: 'ci-status-icon-skipped', + icon: 'status_skipped', + text: s__('Job|Skipped'), + value: 'SKIPPED', + }, + { + class: 'ci-status-icon-waiting-for-resource', + icon: 'status-waiting', + text: s__('Job|Waiting for resource'), + value: 'WAITING_FOR_RESOURCE', + }, + ]; + }, + findActiveStatus() { + return this.statuses.find((status) => status.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <div :class="findActiveStatus.class"> + <gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" /> + </div> + <span>{{ findActiveStatus.text }}</span> + </div> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(status, index) in statuses" + :key="index" + :value="status.value" + > + <div class="gl-display-flex" :class="status.class"> + <gl-icon :name="status.icon" class="gl-mr-3" /> + <span>{{ status.text }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue index 753a15871ab..f16e0287d5d 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -171,6 +171,7 @@ export default { data-testid="cancel-button" icon="cancel" :title="$options.CANCEL" + :aria-label="$options.CANCEL" :disabled="cancelBtnDisabled" @click="cancelJob()" /> @@ -182,6 +183,7 @@ export default { v-gl-modal-directive="$options.playJobModalId" icon="play" :title="$options.ACTIONS_START_NOW" + :aria-label="$options.ACTIONS_START_NOW" data-testid="play-scheduled" /> <gl-modal @@ -196,6 +198,7 @@ export default { <gl-button icon="time-out" :title="$options.ACTIONS_UNSCHEDULE" + :aria-label="$options.ACTIONS_UNSCHEDULE" :disabled="unscheduleBtnDisabled" data-testid="unschedule" @click="unscheduleJob()" @@ -207,6 +210,7 @@ export default { v-if="manualJobPlayable" icon="play" :title="$options.ACTIONS_PLAY" + :aria-label="$options.ACTIONS_PLAY" :disabled="playManualBtnDisabled" data-testid="play" @click="playJob()" @@ -215,6 +219,7 @@ export default { v-else-if="isRetryable" icon="repeat" :title="$options.ACTIONS_RETRY" + :aria-label="$options.ACTIONS_RETRY" :method="currentJobMethod" :disabled="retryBtnDisabled" data-testid="retry" @@ -226,6 +231,7 @@ export default { v-if="shouldDisplayArtifacts" icon="download" :title="$options.ACTIONS_DOWNLOAD_ARTIFACTS" + :aria-label="$options.ACTIONS_DOWNLOAD_ARTIFACTS" :href="artifactDownloadPath" rel="nofollow" download diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue index 19594c4955d..120f01db8f0 100644 --- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { formatDate, getTimeago, durationTimeFormatted } from '~/lib/utils/datetime_utility'; export default { iconSize: 12, @@ -10,7 +10,6 @@ export default { components: { GlIcon, }, - mixins: [timeagoMixin], props: { job: { type: Object, @@ -24,6 +23,15 @@ export default { duration() { return this.job?.duration; }, + timeFormatted() { + return getTimeago().format(this.finishedTime); + }, + tooltipTitle() { + return formatDate(this.finishedTime); + }, + durationFormatted() { + return durationTimeFormatted(this.duration); + }, }, }; </script> @@ -32,18 +40,18 @@ export default { <div> <div v-if="duration" data-testid="job-duration"> <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> - {{ durationTimeFormatted(duration) }} + {{ durationFormatted }} </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)" + :title="tooltipTitle" :datetime="finishedTime" data-placement="top" data-container="body" > - {{ timeFormatted(finishedTime) }} + {{ timeFormatted }} </time> </div> </div> diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js index 951d9324813..853834ed51d 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -4,6 +4,9 @@ import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; /* Error constants */ export const POST_FAILURE = 'post_failure'; export const DEFAULT = 'default'; +export const RAW_TEXT_WARNING = s__( + 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.', +); /* Job Status Constants */ export const JOB_SCHEDULED = 'SCHEDULED'; diff --git a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js index b9946925c95..8bcd7ffd10f 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js +++ b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js @@ -13,16 +13,40 @@ export default { merge(existing = {}, incoming, { args = {} }) { let nodes; + const areNodesEqual = isEqual(existing.nodes, incoming.nodes); + const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses; + const { pageInfo } = incoming; + if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) { - nodes = [...existing.nodes, ...incoming.nodes]; + if (areNodesEqual) { + if (incoming.pageInfo.hasNextPage) { + nodes = [...existing.nodes, ...incoming.nodes]; + } else { + nodes = [...incoming.nodes]; + } + } else { + if (!existing.pageInfo?.hasNextPage) { + nodes = [...incoming.nodes]; + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + nodes = [...existing.nodes, ...incoming.nodes]; + } } else { nodes = [...incoming.nodes]; } return { nodes, - statuses: Array.isArray(args.statuses) ? [...args.statuses] : args.statuses, - pageInfo: incoming.pageInfo, + statuses, + pageInfo, + count: incoming.count, }; }, }, diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index 151e49af87e..f3ca958b3ca 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -3,6 +3,7 @@ query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) { id __typename jobs(after: $after, first: 30, statuses: $statuses) { + count pageInfo { endCursor hasNextPage diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js index 1b9c7cdcfdd..88da1169e01 100644 --- a/app/assets/javascripts/jobs/components/table/index.js +++ b/app/assets/javascripts/jobs/components/table/index.js @@ -27,7 +27,6 @@ export default (containerId = 'js-jobs-table') => { const { fullPath, - jobCounts, jobStatuses, pipelineEditorPath, emptyStateSvgPath, @@ -42,7 +41,6 @@ export default (containerId = 'js-jobs-table') => { fullPath, pipelineEditorPath, jobStatuses: JSON.parse(jobStatuses), - jobCounts: JSON.parse(jobCounts), admin: parseBoolean(admin), }, render(createElement) { diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index 864e322eecd..3ea50dfb7a3 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -1,26 +1,34 @@ <script> import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; +import createFlash from '~/flash'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue'; import eventHub from './event_hub'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import JobsTable from './jobs_table.vue'; import JobsTableEmptyState from './jobs_table_empty_state.vue'; import JobsTableTabs from './jobs_table_tabs.vue'; +import { RAW_TEXT_WARNING } from './constants'; export default { i18n: { errorMsg: __('There was an error fetching the jobs for your project.'), loadingAriaLabel: __('Loading'), }, + filterSearchBoxStyles: + 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-gray-100 gl-border-b', components: { GlAlert, GlSkeletonLoader, + JobsFilteredSearch, JobsTable, JobsTableEmptyState, JobsTableTabs, GlIntersectionObserver, GlLoadingIcon, }, + mixins: [glFeatureFlagMixin()], inject: { fullPath: { default: '', @@ -35,10 +43,11 @@ export default { }; }, update(data) { - const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {}; + const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data.project || {}; return { list, pageInfo, + count, }; }, error() { @@ -54,19 +63,52 @@ export default { hasError: false, isAlertDismissed: false, scope: null, - firstLoad: true, + infiniteScrollingTriggered: false, + filterSearchTriggered: false, + count: 0, }; }, computed: { + loading() { + return this.$apollo.queries.jobs.loading; + }, shouldShowAlert() { return this.hasError && !this.isAlertDismissed; }, + // Show when on All tab with no jobs + // Show only when not loading and filtered search has not been triggered + // So we don't show empty state when results are empty on a filtered search showEmptyState() { - return this.jobs.list.length === 0 && !this.scope; + return ( + this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered + ); }, hasNextPage() { return this.jobs?.pageInfo?.hasNextPage; }, + showLoadingSpinner() { + return this.loading && this.infiniteScrollingTriggered; + }, + showSkeletonLoader() { + return this.loading && !this.showLoadingSpinner; + }, + showFilteredSearch() { + return this.glFeatures?.jobsTableVueSearch && !this.scope; + }, + jobsCount() { + return this.jobs.count; + }, + }, + watch: { + // this watcher ensures that the count on the all tab + // is not updated when switching to the finished tab + jobsCount(newCount, oldCount) { + if (this.scope) { + this.count = oldCount; + } else { + this.count = newCount; + } + }, }, mounted() { eventHub.$on('jobActionPerformed', this.handleJobAction); @@ -79,16 +121,38 @@ export default { this.$apollo.queries.jobs.refetch({ statuses: this.scope }); }, fetchJobsByStatus(scope) { - this.firstLoad = true; + this.infiniteScrollingTriggered = false; this.scope = scope; this.$apollo.queries.jobs.refetch({ statuses: scope }); }, + filterJobsBySearch(filters) { + this.infiniteScrollingTriggered = false; + this.filterSearchTriggered = true; + + // Eventually there will be more tokens available + // this code is written to scale for those tokens + filters.forEach((filter) => { + // Raw text input in filtered search does not have a type + // when a user enters raw text we alert them that it is + // not supported and we do not make an additional API call + if (!filter.type) { + createFlash({ + message: RAW_TEXT_WARNING, + type: 'warning', + }); + } + + if (filter.type === 'status') { + this.$apollo.queries.jobs.refetch({ statuses: filter.value.data }); + } + }); + }, fetchMoreJobs() { - this.firstLoad = false; + if (!this.loading) { + this.infiniteScrollingTriggered = true; - if (!this.$apollo.queries.jobs.loading) { this.$apollo.queries.jobs.fetchMore({ variables: { fullPath: this.fullPath, @@ -113,9 +177,19 @@ export default { {{ $options.i18n.errorMsg }} </gl-alert> - <jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" /> + <jobs-table-tabs + :all-jobs-count="count" + :loading="loading" + @fetchJobsByStatus="fetchJobsByStatus" + /> + + <jobs-filtered-search + v-if="showFilteredSearch" + :class="$options.filterSearchBoxStyles" + @filterJobsBySearch="filterJobsBySearch" + /> - <div v-if="$apollo.loading && firstLoad" class="gl-mt-5"> + <div v-if="showSkeletonLoader" class="gl-mt-5"> <gl-skeleton-loader :width="1248" :height="73"> <circle cx="748.031" cy="37.7193" r="15.0307" /> <circle cx="787.241" cy="37.7193" r="15.0307" /> @@ -138,7 +212,7 @@ export default { <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs"> <gl-loading-icon - v-if="$apollo.loading" + v-if="showLoadingSpinner" size="md" :aria-label="$options.i18n.loadingAriaLabel" /> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue index 26791e4284d..0a25dc5bea5 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue @@ -1,56 +1,56 @@ <script> -import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; export default { components: { GlBadge, GlTab, GlTabs, + GlLoadingIcon, }, inject: { - jobCounts: { - default: {}, - }, jobStatuses: { default: {}, }, }, + props: { + allJobsCount: { + type: Number, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + }, computed: { tabs() { return [ { - text: __('All'), - count: this.jobCounts.all, + text: s__('Jobs|All'), + count: this.allJobsCount, scope: null, testId: 'jobs-all-tab', + showBadge: true, }, { - text: __('Pending'), - count: this.jobCounts.pending, - scope: this.jobStatuses.pending, - testId: 'jobs-pending-tab', - }, - { - text: __('Running'), - count: this.jobCounts.running, - scope: this.jobStatuses.running, - testId: 'jobs-running-tab', - }, - { - text: __('Finished'), - count: this.jobCounts.finished, + text: s__('Jobs|Finished'), scope: [this.jobStatuses.success, this.jobStatuses.failed, this.jobStatuses.canceled], testId: 'jobs-finished-tab', + showBadge: false, }, ]; }, + showLoadingIcon() { + return this.loading && !this.allJobsCount; + }, }, }; </script> <template> - <gl-tabs content-class="gl-pb-0"> + <gl-tabs content-class="gl-py-0"> <gl-tab v-for="tab in tabs" :key="tab.text" @@ -59,7 +59,11 @@ export default { > <template #title> <span>{{ tab.text }}</span> - <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> + <gl-loading-icon v-if="showLoadingIcon && tab.showBadge" class="gl-ml-2" /> + + <gl-badge v-else-if="tab.showBadge" size="sm" class="gl-tab-counter-badge"> + {{ tab.count }} + </gl-badge> </template> </gl-tab> </gl-tabs> diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index b1ddede8fe8..1afc1c9a595 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlTable } from '@gitlab/ui'; +import { GlButton, GlTableLite } from '@gitlab/ui'; import { __ } from '~/locale'; const DEFAULT_TD_CLASSES = 'gl-w-half gl-font-sm! gl-border-gray-200!'; @@ -25,7 +25,7 @@ export default { ], components: { GlButton, - GlTable, + GlTableLite, }, props: { trigger: { @@ -84,7 +84,7 @@ export default { > </p> - <gl-table :items="trigger.variables" :fields="$options.fields" small bordered fixed> + <gl-table-lite :items="trigger.variables" :fields="$options.fields" small bordered fixed> <template #cell(key)="{ item }"> <span class="gl-overflow-break-word">{{ item.key }}</span> </template> @@ -92,7 +92,7 @@ export default { <template #cell(value)="data"> <span class="gl-overflow-break-word">{{ getDisplayValue(data.value) }}</span> </template> - </gl-table> + </gl-table-lite> </template> </div> </template> diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 8bca448ee11..7dfe24afa23 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -1,4 +1,4 @@ -import { parseBoolean } from '../../lib/utils/common_utils'; +import { parseBoolean } from '~/lib/utils/common_utils'; /** * Adds the line number property |