diff --git a/app/assets/javascripts/jobs/components/filtered_search/constants.js b/app/assets/javascripts/jobs/components/filtered_search/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..0daba8923754db32d322a4638447e304d8f5a63c --- /dev/null +++ b/app/assets/javascripts/jobs/components/filtered_search/constants.js @@ -0,0 +1,13 @@ +export const jobStatusValues = [ + 'CANCELED', + 'CREATED', + 'FAILED', + 'MANUAL', + 'SUCCESS', + 'PENDING', + 'PREPARING', + 'RUNNING', + 'SCHEDULED', + 'SKIPPED', + 'WAITING_FOR_RESOURCE', +]; 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 index fe7b7428c6ed2f8977baba70b049d42e2e8dd951..e498a735898d57dd75f83731c6dc4849e4dac250 100644 --- a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue +++ b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue @@ -11,6 +11,13 @@ export default { components: { GlFilteredSearch, }, + props: { + queryString: { + type: Object, + required: false, + default: null, + }, + }, computed: { tokens() { return [ @@ -24,6 +31,20 @@ export default { }, ]; }, + filteredSearchValue() { + if (this.queryString?.statuses) { + return [ + { + type: 'status', + value: { + data: this.queryString?.statuses, + operator: '=', + }, + }, + ]; + } + return []; + }, }, methods: { onSubmit(filters) { @@ -37,6 +58,7 @@ export default { <gl-filtered-search :placeholder="s__('Jobs|Filter jobs')" :available-tokens="tokens" + :value="filteredSearchValue" @submit="onSubmit" /> </template> diff --git a/app/assets/javascripts/jobs/components/filtered_search/utils.js b/app/assets/javascripts/jobs/components/filtered_search/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..eef5b73886336b885619155cde8d6dd1deef01ae --- /dev/null +++ b/app/assets/javascripts/jobs/components/filtered_search/utils.js @@ -0,0 +1,23 @@ +import { jobStatusValues } from './constants'; + +// validates query string used for filtered search +// on jobs table to ensure GraphQL query is called correctly +export const validateQueryString = (queryStringObj) => { + // currently only one token is supported `statuses` + // this code will need to be expanded as more tokens + // are introduced + + const filters = Object.keys(queryStringObj); + + if (filters.includes('statuses')) { + const found = jobStatusValues.find((status) => status === queryStringObj.statuses); + + if (found) { + return queryStringObj; + } + + return null; + } + + return null; +}; 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 c2f460cb64740e44280a56780364a1c33351d027..b8ba781ab5fc657432d7024eeed05aedd2a54c1c 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -2,7 +2,9 @@ import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import createFlash from '~/flash'; +import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility'; import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue'; +import { validateQueryString } from '../filtered_search/utils'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import JobsTable from './jobs_table.vue'; import JobsTableEmptyState from './jobs_table_empty_state.vue'; @@ -37,6 +39,7 @@ export default { variables() { return { fullPath: this.fullPath, + ...this.validatedQueryString, }; }, update(data) { @@ -95,6 +98,11 @@ export default { jobsCount() { return this.jobs.count; }, + validatedQueryString() { + const queryStringObject = queryToObject(window.location.search); + + return validateQueryString(queryStringObject); + }, }, watch: { // this watcher ensures that the count on the all tab @@ -133,6 +141,10 @@ export default { } if (filter.type === 'status') { + updateHistory({ + url: setUrlParams({ statuses: filter.value.data }, window.location.href, true), + }); + this.$apollo.queries.jobs.refetch({ statuses: filter.value.data }); } }); @@ -175,6 +187,7 @@ export default { <jobs-filtered-search v-if="showFilteredSearch" :class="$options.filterSearchBoxStyles" + :query-string="validatedQueryString" @filterJobsBySearch="filterJobsBySearch" /> diff --git a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js index 322cfa3ba1fc627816d615d5218eafb350a0a889..98bdfc3fcbcf6539ff740723c11db736bf7fad68 100644 --- a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js +++ b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js @@ -15,23 +15,27 @@ describe('Jobs filtered search', () => { const findStatusToken = () => getSearchToken('status'); - const createComponent = () => { - wrapper = shallowMount(JobsFilteredSearch); + const createComponent = (props) => { + wrapper = shallowMount(JobsFilteredSearch, { + propsData: { + ...props, + }, + }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); it('displays filtered search', () => { + createComponent(); + expect(findFilteredSearch().exists()).toBe(true); }); it('displays status token', () => { + createComponent(); + expect(findStatusToken()).toMatchObject({ type: 'status', icon: 'status', @@ -42,8 +46,26 @@ describe('Jobs filtered search', () => { }); it('emits filter token to parent component', () => { + createComponent(); + findFilteredSearch().vm.$emit('submit', mockFailedSearchToken); expect(wrapper.emitted('filterJobsBySearch')).toEqual([[mockFailedSearchToken]]); }); + + it('filtered search value is empty array when no query string is passed', () => { + createComponent(); + + expect(findFilteredSearch().props('value')).toEqual([]); + }); + + it('filtered search returns correct data shape when passed query string', () => { + const value = 'SUCCESS'; + + createComponent({ queryString: { statuses: value } }); + + expect(findFilteredSearch().props('value')).toEqual([ + { type: 'status', value: { data: value, operator: '=' } }, + ]); + }); }); diff --git a/spec/frontend/jobs/components/filtered_search/utils_spec.js b/spec/frontend/jobs/components/filtered_search/utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8e9cd357a199042307bb2e31005542002b217857 --- /dev/null +++ b/spec/frontend/jobs/components/filtered_search/utils_spec.js @@ -0,0 +1,18 @@ +import { validateQueryString } from '~/jobs/components/filtered_search/utils'; + +describe('Filtered search utils', () => { + describe('validateQueryString', () => { + it.each` + queryStringObject | expected + ${{ statuses: 'SUCCESS' }} | ${{ statuses: 'SUCCESS' }} + ${{ wrong: 'SUCCESS' }} | ${null} + ${{ statuses: 'wrong' }} | ${null} + ${{ wrong: 'wrong' }} | ${null} + `( + 'when provided $queryStringObject, the expected result is $expected', + ({ queryStringObject, expected }) => { + expect(validateQueryString(queryStringObject)).toEqual(expected); + }, + ); + }); +}); diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 374768c3ee4df2df887a7f1377c03c39d0858eb7..8c724a8030bfc3a0f35303aa96ce56f0de7b81e4 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -11,12 +11,14 @@ import VueApollo from 'vue-apollo'; import { s__ } from '~/locale'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { TEST_HOST } from 'spec/test_constants'; 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 JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; +import * as urlUtils from '~/lib/utils/url_utility'; import { mockJobsResponsePaginated, mockJobsResponseEmpty, @@ -230,5 +232,17 @@ describe('Job table app', () => { expect(createFlash).toHaveBeenCalledWith(expectedWarning); expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); }); + + it('updates URL query string when filtering jobs by status', async () => { + createComponent(); + + jest.spyOn(urlUtils, 'updateHistory'); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?statuses=FAILED`, + }); + }); }); });