Commit ad6b35cc authored by Payton Burdette's avatar Payton Burdette Committed by Jose Ivan Vargas

Build jobs filtered search

Add jobs filtered search feature
to the jobs page.
parent 8aaf26ce
<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>
<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>
......@@ -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';
......
<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: '',
......@@ -54,19 +62,37 @@ export default {
hasError: false,
isAlertDismissed: false,
scope: null,
firstLoad: true,
infiniteScrollingTriggered: false,
filterSearchTriggered: false,
};
},
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;
},
},
mounted() {
eventHub.$on('jobActionPerformed', this.handleJobAction);
......@@ -79,16 +105,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,
......@@ -115,7 +163,13 @@ export default {
<jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" />
<div v-if="$apollo.loading && firstLoad" class="gl-mt-5">
<jobs-filtered-search
v-if="showFilteredSearch"
:class="$options.filterSearchBoxStyles"
@filterJobsBySearch="filterJobsBySearch"
/>
<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 +192,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"
/>
......
......@@ -50,7 +50,7 @@ export default {
</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"
......
......@@ -18,6 +18,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action :push_jobs_table_vue, only: [:index]
before_action :push_jobs_table_vue_search, only: [:index]
before_action do
push_frontend_feature_flag(:infinitely_collapsible_sections, @project, default_enabled: :yaml)
......@@ -269,4 +270,8 @@ class Projects::JobsController < Projects::ApplicationController
def push_jobs_table_vue
push_frontend_feature_flag(:jobs_table_vue, @project, default_enabled: :yaml)
end
def push_jobs_table_vue_search
push_frontend_feature_flag(:jobs_table_vue_search, @project, default_enabled: :yaml)
end
end
---
name: jobs_table_vue_search
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82539
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/356007
milestone: '14.10'
type: development
group: group::pipeline execution
default_enabled: false
......@@ -21372,6 +21372,9 @@ msgstr ""
msgid "Jobs|Create CI/CD configuration file"
msgstr ""
msgid "Jobs|Filter jobs"
msgstr ""
msgid "Jobs|Job is stuck. Check runners."
msgstr ""
......@@ -21381,6 +21384,12 @@ msgstr ""
msgid "Jobs|No jobs to show"
msgstr ""
msgid "Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens."
msgstr ""
msgid "Jobs|Status"
msgstr ""
msgid "Jobs|Use jobs to automate your tasks"
msgstr ""
......@@ -21408,15 +21417,24 @@ msgstr ""
msgid "Job|Cancel"
msgstr ""
msgid "Job|Canceled"
msgstr ""
msgid "Job|Complete Raw"
msgstr ""
msgid "Job|Created"
msgstr ""
msgid "Job|Download"
msgstr ""
msgid "Job|Erase job log and artifacts"
msgstr ""
msgid "Job|Failed"
msgstr ""
msgid "Job|Finished at"
msgstr ""
......@@ -21432,9 +21450,27 @@ msgstr ""
msgid "Job|Keep"
msgstr ""
msgid "Job|Manual"
msgstr ""
msgid "Job|Passed"
msgstr ""
msgid "Job|Pending"
msgstr ""
msgid "Job|Preparing"
msgstr ""
msgid "Job|Retry"
msgstr ""
msgid "Job|Running"
msgstr ""
msgid "Job|Scheduled"
msgstr ""
msgid "Job|Scroll to bottom"
msgstr ""
......@@ -21444,6 +21480,9 @@ msgstr ""
msgid "Job|Show complete raw"
msgstr ""
msgid "Job|Skipped"
msgstr ""
msgid "Job|Status"
msgstr ""
......@@ -21468,6 +21507,9 @@ msgstr ""
msgid "Job|This job is stuck because you don't have any active runners that can run this job."
msgstr ""
msgid "Job|Waiting for resource"
msgstr ""
msgid "Job|allowed to fail"
msgstr ""
......
import { GlFilteredSearch } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue';
import { mockFailedSearchToken } from '../../mock_data';
describe('Jobs filtered search', () => {
let wrapper;
const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
const getSearchToken = (type) =>
findFilteredSearch()
.props('availableTokens')
.find((token) => token.type === type);
const findStatusToken = () => getSearchToken('status');
const createComponent = () => {
wrapper = shallowMount(JobsFilteredSearch);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('displays filtered search', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
it('displays status token', () => {
expect(findStatusToken()).toMatchObject({
type: 'status',
icon: 'status',
title: 'Status',
unique: true,
operators: OPERATOR_IS_ONLY,
});
});
it('emits filter token to parent component', () => {
findFilteredSearch().vm.$emit('submit', mockFailedSearchToken);
expect(wrapper.emitted('filterJobsBySearch')).toEqual([[mockFailedSearchToken]]);
});
});
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import JobStatusToken from '~/jobs/components/filtered_search/tokens/job_status_token.vue';
describe('Job Status Token', () => {
let wrapper;
const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
const findAllFilteredSearchSuggestions = () =>
wrapper.findAllComponents(GlFilteredSearchSuggestion);
const findAllGlIcons = () => wrapper.findAllComponents(GlIcon);
const defaultProps = {
config: {
type: 'status',
icon: 'status',
title: 'Status',
unique: true,
},
value: {
data: '',
},
};
const createComponent = () => {
wrapper = shallowMount(JobStatusToken, {
propsData: {
...defaultProps,
},
stubs: {
GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, {
template: `<div><slot name="suggestions"></slot></div>`,
}),
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
it('renders all job statuses available', () => {
const expectedLength = 11;
expect(findAllFilteredSearchSuggestions()).toHaveLength(expectedLength);
expect(findAllGlIcons()).toHaveLength(expectedLength);
});
});
import { GlSkeletonLoader, GlAlert, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
import {
GlSkeletonLoader,
GlAlert,
GlEmptyState,
GlIntersectionObserver,
GlLoadingIcon,
} from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { s__ } from '~/locale';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
import { mockJobsQueryResponse, mockJobsQueryEmptyResponse } from '../../mock_data';
import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue';
import {
mockJobsQueryResponse,
mockJobsQueryEmptyResponse,
mockFailedSearchToken,
} from '../../mock_data';
const projectPath = 'gitlab-org/gitlab';
Vue.use(VueApollo);
jest.mock('~/flash');
describe('Job table app', () => {
let wrapper;
let jobsTableVueSearch = true;
const successHandler = jest.fn().mockResolvedValue(mockJobsQueryResponse);
const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
const emptyHandler = jest.fn().mockResolvedValue(mockJobsQueryEmptyResponse);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
const findTable = () => wrapper.findComponent(JobsTable);
const findTabs = () => wrapper.findComponent(JobsTableTabs);
const findAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch);
const triggerInfiniteScroll = () =>
wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
......@@ -48,6 +66,7 @@ describe('Job table app', () => {
},
provide: {
fullPath: projectPath,
glFeatures: { jobsTableVueSearch },
},
apolloProvider: createMockApolloProvider(handler),
});
......@@ -58,11 +77,21 @@ describe('Job table app', () => {
});
describe('loading state', () => {
it('should display skeleton loader when loading', () => {
beforeEach(() => {
createComponent();
});
it('should display skeleton loader when loading', () => {
expect(findSkeletonLoader().exists()).toBe(true);
expect(findTable().exists()).toBe(false);
expect(findLoadingSpinner().exists()).toBe(false);
});
it('when switching tabs only the skeleton loader should show', () => {
findTabs().vm.$emit('fetchJobsByStatus', 'PENDING');
expect(findSkeletonLoader().exists()).toBe(true);
expect(findLoadingSpinner().exists()).toBe(false);
});
});
......@@ -76,6 +105,7 @@ describe('Job table app', () => {
it('should display the jobs table with data', () => {
expect(findTable().exists()).toBe(true);
expect(findSkeletonLoader().exists()).toBe(false);
expect(findLoadingSpinner().exists()).toBe(false);
});
it('should refetch jobs query on fetchJobsByStatus event', async () => {
......@@ -98,8 +128,12 @@ describe('Job table app', () => {
});
it('handles infinite scrolling by calling fetch more', async () => {
expect(findLoadingSpinner().exists()).toBe(true);
await waitForPromises();
expect(findLoadingSpinner().exists()).toBe(false);
expect(successHandler).toHaveBeenCalledWith({
after: 'eyJpZCI6IjIzMTcifQ',
fullPath: 'gitlab-org/gitlab',
......@@ -137,4 +171,69 @@ describe('Job table app', () => {
expect(findTable().exists()).toBe(true);
});
});
describe('filtered search', () => {
it('should display filtered search', () => {
createComponent();
expect(findFilteredSearch().exists()).toBe(true);
});
// this test should be updated once BE supports tab and filtered search filtering
// https://gitlab.com/gitlab-org/gitlab/-/issues/356210
it.each`
scope | shouldDisplay
${null} | ${true}
${'PENDING'} | ${false}
${'RUNNING'} | ${false}
${['FAILED', 'SUCCESS', 'CANCELED']} | ${false}
`(
'with tab scope $scope the filtered search displays $shouldDisplay',
async ({ scope, shouldDisplay }) => {
createComponent();
await findTabs().vm.$emit('fetchJobsByStatus', scope);
expect(findFilteredSearch().exists()).toBe(shouldDisplay);
},
);
it('refetches jobs query when filtering', async () => {
createComponent();
jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
});
it('shows raw text warning when user inputs raw text', async () => {
const expectedWarning = {
message: s__(
'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.',
),
type: 'warning',
};
createComponent();
jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']);
expect(createFlash).toHaveBeenCalledWith(expectedWarning);
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
});
it('should not display filtered search', () => {
jobsTableVueSearch = false;
createComponent();
expect(findFilteredSearch().exists()).toBe(false);
});
});
});
......@@ -1918,3 +1918,5 @@ export const CIJobConnectionExistingCache = {
],
statuses: 'PENDING',
};
export const mockFailedSearchToken = { type: 'status', value: { data: 'FAILED', operator: '=' } };
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