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'; ...@@ -4,6 +4,9 @@ import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
/* Error constants */ /* Error constants */
export const POST_FAILURE = 'post_failure'; export const POST_FAILURE = 'post_failure';
export const DEFAULT = 'default'; 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 */ /* Job Status Constants */
export const JOB_SCHEDULED = 'SCHEDULED'; export const JOB_SCHEDULED = 'SCHEDULED';
......
<script> <script>
import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale'; 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 eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql'; import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue'; import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue'; import JobsTableEmptyState from './jobs_table_empty_state.vue';
import JobsTableTabs from './jobs_table_tabs.vue'; import JobsTableTabs from './jobs_table_tabs.vue';
import { RAW_TEXT_WARNING } from './constants';
export default { export default {
i18n: { i18n: {
errorMsg: __('There was an error fetching the jobs for your project.'), errorMsg: __('There was an error fetching the jobs for your project.'),
loadingAriaLabel: __('Loading'), 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: { components: {
GlAlert, GlAlert,
GlSkeletonLoader, GlSkeletonLoader,
JobsFilteredSearch,
JobsTable, JobsTable,
JobsTableEmptyState, JobsTableEmptyState,
JobsTableTabs, JobsTableTabs,
GlIntersectionObserver, GlIntersectionObserver,
GlLoadingIcon, GlLoadingIcon,
}, },
mixins: [glFeatureFlagMixin()],
inject: { inject: {
fullPath: { fullPath: {
default: '', default: '',
...@@ -54,19 +62,37 @@ export default { ...@@ -54,19 +62,37 @@ export default {
hasError: false, hasError: false,
isAlertDismissed: false, isAlertDismissed: false,
scope: null, scope: null,
firstLoad: true, infiniteScrollingTriggered: false,
filterSearchTriggered: false,
}; };
}, },
computed: { computed: {
loading() {
return this.$apollo.queries.jobs.loading;
},
shouldShowAlert() { shouldShowAlert() {
return this.hasError && !this.isAlertDismissed; 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() { showEmptyState() {
return this.jobs.list.length === 0 && !this.scope; return (
this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered
);
}, },
hasNextPage() { hasNextPage() {
return this.jobs?.pageInfo?.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() { mounted() {
eventHub.$on('jobActionPerformed', this.handleJobAction); eventHub.$on('jobActionPerformed', this.handleJobAction);
...@@ -79,16 +105,38 @@ export default { ...@@ -79,16 +105,38 @@ export default {
this.$apollo.queries.jobs.refetch({ statuses: this.scope }); this.$apollo.queries.jobs.refetch({ statuses: this.scope });
}, },
fetchJobsByStatus(scope) { fetchJobsByStatus(scope) {
this.firstLoad = true; this.infiniteScrollingTriggered = false;
this.scope = scope; this.scope = scope;
this.$apollo.queries.jobs.refetch({ statuses: 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() { fetchMoreJobs() {
this.firstLoad = false; if (!this.loading) {
this.infiniteScrollingTriggered = true;
if (!this.$apollo.queries.jobs.loading) {
this.$apollo.queries.jobs.fetchMore({ this.$apollo.queries.jobs.fetchMore({
variables: { variables: {
fullPath: this.fullPath, fullPath: this.fullPath,
...@@ -115,7 +163,13 @@ export default { ...@@ -115,7 +163,13 @@ export default {
<jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" /> <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"> <gl-skeleton-loader :width="1248" :height="73">
<circle cx="748.031" cy="37.7193" r="15.0307" /> <circle cx="748.031" cy="37.7193" r="15.0307" />
<circle cx="787.241" cy="37.7193" r="15.0307" /> <circle cx="787.241" cy="37.7193" r="15.0307" />
...@@ -138,7 +192,7 @@ export default { ...@@ -138,7 +192,7 @@ export default {
<gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs"> <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
<gl-loading-icon <gl-loading-icon
v-if="$apollo.loading" v-if="showLoadingSpinner"
size="md" size="md"
:aria-label="$options.i18n.loadingAriaLabel" :aria-label="$options.i18n.loadingAriaLabel"
/> />
......
...@@ -50,7 +50,7 @@ export default { ...@@ -50,7 +50,7 @@ export default {
</script> </script>
<template> <template>
<gl-tabs content-class="gl-pb-0"> <gl-tabs content-class="gl-py-0">
<gl-tab <gl-tab
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.text" :key="tab.text"
......
...@@ -18,6 +18,7 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -18,6 +18,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, 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, only: [:index]
before_action :push_jobs_table_vue_search, only: [:index]
before_action do before_action do
push_frontend_feature_flag(:infinitely_collapsible_sections, @project, default_enabled: :yaml) push_frontend_feature_flag(:infinitely_collapsible_sections, @project, default_enabled: :yaml)
...@@ -269,4 +270,8 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -269,4 +270,8 @@ class Projects::JobsController < Projects::ApplicationController
def push_jobs_table_vue def push_jobs_table_vue
push_frontend_feature_flag(:jobs_table_vue, @project, default_enabled: :yaml) push_frontend_feature_flag(:jobs_table_vue, @project, default_enabled: :yaml)
end end
def push_jobs_table_vue_search
push_frontend_feature_flag(:jobs_table_vue_search, @project, default_enabled: :yaml)
end
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 "" ...@@ -21372,6 +21372,9 @@ msgstr ""
msgid "Jobs|Create CI/CD configuration file" msgid "Jobs|Create CI/CD configuration file"
msgstr "" msgstr ""
msgid "Jobs|Filter jobs"
msgstr ""
msgid "Jobs|Job is stuck. Check runners." msgid "Jobs|Job is stuck. Check runners."
msgstr "" msgstr ""
...@@ -21381,6 +21384,12 @@ msgstr "" ...@@ -21381,6 +21384,12 @@ msgstr ""
msgid "Jobs|No jobs to show" msgid "Jobs|No jobs to show"
msgstr "" 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" msgid "Jobs|Use jobs to automate your tasks"
msgstr "" msgstr ""
...@@ -21408,15 +21417,24 @@ msgstr "" ...@@ -21408,15 +21417,24 @@ msgstr ""
msgid "Job|Cancel" msgid "Job|Cancel"
msgstr "" msgstr ""
msgid "Job|Canceled"
msgstr ""
msgid "Job|Complete Raw" msgid "Job|Complete Raw"
msgstr "" msgstr ""
msgid "Job|Created"
msgstr ""
msgid "Job|Download" msgid "Job|Download"
msgstr "" msgstr ""
msgid "Job|Erase job log and artifacts" msgid "Job|Erase job log and artifacts"
msgstr "" msgstr ""
msgid "Job|Failed"
msgstr ""
msgid "Job|Finished at" msgid "Job|Finished at"
msgstr "" msgstr ""
...@@ -21432,9 +21450,27 @@ msgstr "" ...@@ -21432,9 +21450,27 @@ msgstr ""
msgid "Job|Keep" msgid "Job|Keep"
msgstr "" msgstr ""
msgid "Job|Manual"
msgstr ""
msgid "Job|Passed"
msgstr ""
msgid "Job|Pending"
msgstr ""
msgid "Job|Preparing"
msgstr ""
msgid "Job|Retry" msgid "Job|Retry"
msgstr "" msgstr ""
msgid "Job|Running"
msgstr ""
msgid "Job|Scheduled"
msgstr ""
msgid "Job|Scroll to bottom" msgid "Job|Scroll to bottom"
msgstr "" msgstr ""
...@@ -21444,6 +21480,9 @@ msgstr "" ...@@ -21444,6 +21480,9 @@ msgstr ""
msgid "Job|Show complete raw" msgid "Job|Show complete raw"
msgstr "" msgstr ""
msgid "Job|Skipped"
msgstr ""
msgid "Job|Status" msgid "Job|Status"
msgstr "" msgstr ""
...@@ -21468,6 +21507,9 @@ 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." msgid "Job|This job is stuck because you don't have any active runners that can run this job."
msgstr "" msgstr ""
msgid "Job|Waiting for resource"
msgstr ""
msgid "Job|allowed to fail" msgid "Job|allowed to fail"
msgstr "" 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 { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { s__ } from '~/locale';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql'; import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql';
import JobsTable from '~/jobs/components/table/jobs_table.vue'; import JobsTable from '~/jobs/components/table/jobs_table.vue';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.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'; const projectPath = 'gitlab-org/gitlab';
Vue.use(VueApollo); Vue.use(VueApollo);
jest.mock('~/flash');
describe('Job table app', () => { describe('Job table app', () => {
let wrapper; let wrapper;
let jobsTableVueSearch = true;
const successHandler = jest.fn().mockResolvedValue(mockJobsQueryResponse); const successHandler = jest.fn().mockResolvedValue(mockJobsQueryResponse);
const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
const emptyHandler = jest.fn().mockResolvedValue(mockJobsQueryEmptyResponse); const emptyHandler = jest.fn().mockResolvedValue(mockJobsQueryEmptyResponse);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
const findTable = () => wrapper.findComponent(JobsTable); const findTable = () => wrapper.findComponent(JobsTable);
const findTabs = () => wrapper.findComponent(JobsTableTabs); const findTabs = () => wrapper.findComponent(JobsTableTabs);
const findAlert = () => wrapper.findComponent(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch);
const triggerInfiniteScroll = () => const triggerInfiniteScroll = () =>
wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
...@@ -48,6 +66,7 @@ describe('Job table app', () => { ...@@ -48,6 +66,7 @@ describe('Job table app', () => {
}, },
provide: { provide: {
fullPath: projectPath, fullPath: projectPath,
glFeatures: { jobsTableVueSearch },
}, },
apolloProvider: createMockApolloProvider(handler), apolloProvider: createMockApolloProvider(handler),
}); });
...@@ -58,11 +77,21 @@ describe('Job table app', () => { ...@@ -58,11 +77,21 @@ describe('Job table app', () => {
}); });
describe('loading state', () => { describe('loading state', () => {
it('should display skeleton loader when loading', () => { beforeEach(() => {
createComponent(); createComponent();
});
it('should display skeleton loader when loading', () => {
expect(findSkeletonLoader().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(true);
expect(findTable().exists()).toBe(false); 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', () => { ...@@ -76,6 +105,7 @@ describe('Job table app', () => {
it('should display the jobs table with data', () => { it('should display the jobs table with data', () => {
expect(findTable().exists()).toBe(true); expect(findTable().exists()).toBe(true);
expect(findSkeletonLoader().exists()).toBe(false); expect(findSkeletonLoader().exists()).toBe(false);
expect(findLoadingSpinner().exists()).toBe(false);
}); });
it('should refetch jobs query on fetchJobsByStatus event', async () => { it('should refetch jobs query on fetchJobsByStatus event', async () => {
...@@ -98,8 +128,12 @@ describe('Job table app', () => { ...@@ -98,8 +128,12 @@ describe('Job table app', () => {
}); });
it('handles infinite scrolling by calling fetch more', async () => { it('handles infinite scrolling by calling fetch more', async () => {
expect(findLoadingSpinner().exists()).toBe(true);
await waitForPromises(); await waitForPromises();
expect(findLoadingSpinner().exists()).toBe(false);
expect(successHandler).toHaveBeenCalledWith({ expect(successHandler).toHaveBeenCalledWith({
after: 'eyJpZCI6IjIzMTcifQ', after: 'eyJpZCI6IjIzMTcifQ',
fullPath: 'gitlab-org/gitlab', fullPath: 'gitlab-org/gitlab',
...@@ -137,4 +171,69 @@ describe('Job table app', () => { ...@@ -137,4 +171,69 @@ describe('Job table app', () => {
expect(findTable().exists()).toBe(true); 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 = { ...@@ -1918,3 +1918,5 @@ export const CIJobConnectionExistingCache = {
], ],
statuses: 'PENDING', 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