diff --git a/CHANGELOG.md b/CHANGELOG.md index 5718e7cb0..4a1b5d32d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Enable administrators to split facility matches into new facilities [#633](https://github.com/open-apparel-registry/open-apparel-registry/pull/633) - Log requests made with token authentication [#646](https://github.com/open-apparel-registry/open-apparel-registry/pull/646) - `./scripts/resetdb` to expedite refreshing application data during development [#672](https://github.com/open-apparel-registry/open-apparel-registry/pull/672) +- Enable searching by OAR ID from the facility search tab [#675](https://github.com/open-apparel-registry/open-apparel-registry/pull/675) ### Changed - Require a token for all API endpoints [#644](https://github.com/open-apparel-registry/open-apparel-registry/pull/644) diff --git a/src/app/src/__tests__/utils.tests.js b/src/app/src/__tests__/utils.tests.js index 674fc5c57..d2c5aa961 100644 --- a/src/app/src/__tests__/utils.tests.js +++ b/src/app/src/__tests__/utils.tests.js @@ -152,7 +152,7 @@ it('gets the value from a React Select option object', () => { it('creates a querystring from a set of filter selection', () => { const emptyFilterSelections = { - facilityName: '', + facilityFreeTextQuery: '', contributors: [], contributorTypes: [], countries: [], @@ -163,7 +163,7 @@ it('creates a querystring from a set of filter selection', () => { .toEqual(expectedEmptySelectionQSMatch); const multipleFilterSelections = { - facilityName: '', + facilityFreeTextQuery: '', contributors: [ { value: 'foo', label: 'foo' }, { value: 'bar', label: 'bar' }, @@ -181,7 +181,7 @@ it('creates a querystring from a set of filter selection', () => { .toEqual(expectedMultipleFilterSelectionsMatch); const allFilters = { - facilityName: 'hello', + facilityFreeTextQuery: 'hello', contributors: [ { value: 'hello', label: 'hello' }, { value: 'world', label: 'hello' }, @@ -195,7 +195,7 @@ it('creates a querystring from a set of filter selection', () => { }; const expectedAllFiltersMatch = - 'name=hello&contributors=hello&contributors=world' + 'q=hello&contributors=hello&contributors=world' .concat('&contributor_types=foo&countries=bar'); expect(createQueryStringFromSearchFilters(allFilters)) .toEqual(expectedAllFiltersMatch); @@ -230,7 +230,7 @@ it('checks whether the filters object has only empty values', () => { it('creates a set of filters from a querystring', () => { const contributorsString = '?contributors=1&contributors=2'; const expectedContributorsMatch = { - facilityName: '', + facilityFreeTextQuery: '', contributors: [ { value: 1, @@ -252,7 +252,7 @@ it('creates a set of filters from a querystring', () => { const typesString = '?contributor_types=Union&contributor_types=Service Provider'; const expectedTypesMatch = { - facilityName: '', + facilityFreeTextQuery: '', contributors: [], contributorTypes: [ { @@ -274,7 +274,7 @@ it('creates a set of filters from a querystring', () => { const countriesString = '?countries=US&countries=CN'; const expectedCountriesMatch = { - facilityName: '', + facilityFreeTextQuery: '', contributors: [], contributorTypes: [], countries: [ @@ -296,7 +296,7 @@ it('creates a set of filters from a querystring', () => { const stringWithCountriesMissing = '?contributor_types=Union&countries='; const expectedMissingCountriesMatch = { - facilityName: '', + facilityFreeTextQuery: '', contributors: [], contributorTypes: [ { diff --git a/src/app/src/actions/facilities.js b/src/app/src/actions/facilities.js index 931f54d9e..c9ecd7494 100644 --- a/src/app/src/actions/facilities.js +++ b/src/app/src/actions/facilities.js @@ -1,4 +1,6 @@ import { createAction } from 'redux-act'; +import noop from 'lodash/noop'; +import get from 'lodash/get'; import apiRequest from '../util/apiRequest'; @@ -6,6 +8,7 @@ import { logErrorAndDispatchFailure, makeGetFacilitiesURLWithQueryString, makeGetFacilityByOARIdURL, + makeFacilityDetailLink, createQueryStringFromSearchFilters, } from '../util/util'; @@ -19,7 +22,7 @@ export const failFetchSingleFacility = createAction('FAIL_FETCH_SINGLE_FACILITY' export const completeFetchSingleFacility = createAction('COMPLETE_FETCH_SINGLE_FACILITY'); export const resetSingleFacility = createAction('RESET_SINGLE_FACILITY'); -export function fetchFacilities() { +export function fetchFacilities(pushNewRoute = noop) { return (dispatch, getState) => { dispatch(startFetchFacilities()); @@ -31,7 +34,26 @@ export function fetchFacilities() { return apiRequest .get(makeGetFacilitiesURLWithQueryString(qs)) - .then(({ data }) => dispatch(completeFetchFacilities(data))) + .then(({ data }) => { + const responseHasOnlyOneFacility = get( + data, + 'features', + [], + ).length === 1; + + if (responseHasOnlyOneFacility) { + const facilityID = get(data, 'features[0].id', null); + + if (!facilityID) { + throw new Error('No facility ID was found'); + } + + pushNewRoute(makeFacilityDetailLink(facilityID)); + } + + return data; + }) + .then(data => dispatch(completeFetchFacilities(data))) .catch(err => dispatch(logErrorAndDispatchFailure( err, 'An error prevented fetching facilities', diff --git a/src/app/src/actions/filters.js b/src/app/src/actions/filters.js index c04550123..dd8b09dfc 100644 --- a/src/app/src/actions/filters.js +++ b/src/app/src/actions/filters.js @@ -6,7 +6,8 @@ import { updateListWithLabels, } from '../util/util'; -export const updateFacilityNameFilter = createAction('UPDATE_FACILITY_NAME_FILTER'); +export const updateFacilityFreeTextQueryFilter = + createAction('UPDATE_FACILITY_FREE_TEXT_QUERY_FILTER'); export const updateContributorFilter = createAction('UPDATE_CONTRIBUTOR_FILTER'); export const updateContributorTypeFilter = createAction('UPDATE_CONTRIBUTOR_TYPE_FILTER'); export const updateCountryFilter = createAction('UPDATE_COUNTRY_FILTER'); diff --git a/src/app/src/components/FilterSidebar.jsx b/src/app/src/components/FilterSidebar.jsx index 9fbb0f191..af1e1fb4f 100644 --- a/src/app/src/components/FilterSidebar.jsx +++ b/src/app/src/components/FilterSidebar.jsx @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import AppBar from '@material-ui/core/AppBar'; import Tabs from '@material-ui/core/Tabs'; import Tab from '@material-ui/core/Tab'; +import { Route } from 'react-router-dom'; import FilterSidebarGuideTab from './FilterSidebarGuideTab'; import FilterSidebarSearchTab from './FilterSidebarSearchTab'; @@ -131,7 +132,9 @@ class FilterSidebar extends Component { case filterSidebarTabsEnum.guide: return ; case filterSidebarTabsEnum.search: - return ; + // We wrap this component in a `Route` to give it access to `history.push` + // in its `mapDispatchToProps` function. + return ; case filterSidebarTabsEnum.facilities: return ; default: diff --git a/src/app/src/components/FilterSidebarSearchTab.jsx b/src/app/src/components/FilterSidebarSearchTab.jsx index ce489c96c..69f2b97d9 100644 --- a/src/app/src/components/FilterSidebarSearchTab.jsx +++ b/src/app/src/components/FilterSidebarSearchTab.jsx @@ -10,7 +10,7 @@ import ReactSelect from 'react-select'; import FacilitySidebarSearchTabFacilitiesCount from './FacilitySidebarSearchTabFacilitiesCount'; import { - updateFacilityNameFilter, + updateFacilityFreeTextQueryFilter, updateContributorFilter, updateContributorTypeFilter, updateCountryFilter, @@ -59,8 +59,8 @@ function FilterSidebarSearchTab({ contributorTypeOptions, countryOptions, resetFilters, - facilityName, - updateFacilityName, + facilityFreeTextQuery, + updateFacilityFreeTextQuery, contributors, updateContributor, contributorTypes, @@ -114,14 +114,14 @@ function FilterSidebarSearchTab({ htmlFor={FACILITIES} className="form__label" > - Search a Facility Name + Search a Facility Name or OAR ID @@ -243,11 +243,11 @@ FilterSidebarSearchTab.propTypes = { contributorTypeOptions: contributorTypeOptionsPropType.isRequired, countryOptions: countryOptionsPropType.isRequired, resetFilters: func.isRequired, - updateFacilityName: func.isRequired, + updateFacilityFreeTextQuery: func.isRequired, updateContributor: func.isRequired, updateContributorType: func.isRequired, updateCountry: func.isRequired, - facilityName: string.isRequired, + facilityFreeTextQuery: string.isRequired, contributors: contributorOptionsPropType.isRequired, contributorTypes: contributorTypeOptionsPropType.isRequired, countries: countryOptionsPropType.isRequired, @@ -273,7 +273,7 @@ function mapStateToProps({ }, }, filters: { - facilityName, + facilityFreeTextQuery, contributors, contributorTypes, countries, @@ -289,7 +289,7 @@ function mapStateToProps({ contributorOptions, contributorTypeOptions, countryOptions, - facilityName, + facilityFreeTextQuery, contributors, contributorTypes, countries, @@ -301,9 +301,14 @@ function mapStateToProps({ }; } -function mapDispatchToProps(dispatch) { +function mapDispatchToProps(dispatch, { + history: { + push, + }, +}) { return { - updateFacilityName: e => dispatch(updateFacilityNameFilter(getValueFromEvent(e))), + updateFacilityFreeTextQuery: e => + dispatch(updateFacilityFreeTextQueryFilter(getValueFromEvent(e))), updateContributor: v => dispatch(updateContributorFilter(v)), updateContributorType: v => dispatch(updateContributorTypeFilter(v)), updateCountry: v => dispatch(updateCountryFilter(v)), @@ -311,9 +316,9 @@ function mapDispatchToProps(dispatch) { dispatch(recordSearchTabResetButtonClick()); return dispatch(resetAllFilters()); }, - searchForFacilities: () => dispatch(fetchFacilities()), + searchForFacilities: () => dispatch(fetchFacilities(push)), submitFormOnEnterKeyPress: makeSubmitFormOnEnterKeyPressFunction( - () => dispatch(fetchFacilities()), + () => dispatch(fetchFacilities(push)), ), }; } diff --git a/src/app/src/reducers/FacilitiesReducer.js b/src/app/src/reducers/FacilitiesReducer.js index 01be5fc62..b356a0003 100644 --- a/src/app/src/reducers/FacilitiesReducer.js +++ b/src/app/src/reducers/FacilitiesReducer.js @@ -16,7 +16,7 @@ import { } from '../actions/facilities'; import { - updateFacilityNameFilter, + updateFacilityFreeTextQueryFilter, updateContributorFilter, updateContributorTypeFilter, updateCountryFilter, @@ -122,7 +122,7 @@ export default createReducer({ [resetSingleFacility]: state => update(state, { singleFacility: { $set: initialState.singleFacility }, }), - [updateFacilityNameFilter]: clearFacilitiesDataOnFilterChange, + [updateFacilityFreeTextQueryFilter]: clearFacilitiesDataOnFilterChange, [updateContributorFilter]: clearFacilitiesDataOnFilterChange, [updateContributorTypeFilter]: clearFacilitiesDataOnFilterChange, [updateCountryFilter]: clearFacilitiesDataOnFilterChange, diff --git a/src/app/src/reducers/FiltersReducer.js b/src/app/src/reducers/FiltersReducer.js index 8b33d981a..d72957e05 100644 --- a/src/app/src/reducers/FiltersReducer.js +++ b/src/app/src/reducers/FiltersReducer.js @@ -2,7 +2,7 @@ import { createReducer } from 'redux-act'; import update from 'immutability-helper'; import { - updateFacilityNameFilter, + updateFacilityFreeTextQueryFilter, updateContributorFilter, updateContributorTypeFilter, updateCountryFilter, @@ -23,7 +23,7 @@ import { } from '../util/util'; const initialState = Object.freeze({ - facilityName: '', + facilityFreeTextQuery: '', contributors: Object.freeze([]), contributorTypes: Object.freeze([]), countries: Object.freeze([]), @@ -44,8 +44,8 @@ export const maybeSetFromQueryString = field => (state, payload) => { }; export default createReducer({ - [updateFacilityNameFilter]: (state, payload) => update(state, { - facilityName: { $set: payload }, + [updateFacilityFreeTextQueryFilter]: (state, payload) => update(state, { + facilityFreeTextQuery: { $set: payload }, }), [updateContributorFilter]: (state, payload) => update(state, { contributors: { $set: payload }, diff --git a/src/app/src/util/propTypes.js b/src/app/src/util/propTypes.js index 6761883a6..8f54d6a4a 100644 --- a/src/app/src/util/propTypes.js +++ b/src/app/src/util/propTypes.js @@ -203,7 +203,7 @@ export const reactSelectOptionPropType = shape({ }); export const filtersPropType = shape({ - facilityName: string.isRequired, + facilityFreeTextQuery: string.isRequired, contributors: arrayOf(reactSelectOptionPropType).isRequired, contributorTypes: arrayOf(reactSelectOptionPropType).isRequired, countries: arrayOf(reactSelectOptionPropType).isRequired, diff --git a/src/app/src/util/util.js b/src/app/src/util/util.js index 5648acddc..e84676ace 100644 --- a/src/app/src/util/util.js +++ b/src/app/src/util/util.js @@ -129,13 +129,13 @@ export const makeGetClientInfoURL = () => { export const getValueFromObject = ({ value }) => value; export const createQueryStringFromSearchFilters = ({ - facilityName = '', + facilityFreeTextQuery = '', contributors = [], contributorTypes = [], countries = [], }) => { const inputForQueryString = Object.freeze({ - name: facilityName, + q: facilityFreeTextQuery, contributors: compact(contributors.map(getValueFromObject)), contributor_types: compact(contributorTypes.map(getValueFromObject)), countries: compact(countries.map(getValueFromObject)), @@ -177,14 +177,14 @@ export const createFiltersFromQueryString = (qs) => { : qs; const { - name = '', + q: facilityFreeTextQuery = '', contributors = [], contributor_types: contributorTypes = [], countries = [], } = querystring.parse(qsToParse); return Object.freeze({ - facilityName: name, + facilityFreeTextQuery, contributors: createSelectOptionsFromParams(contributors), contributorTypes: createSelectOptionsFromParams(contributorTypes), countries: createSelectOptionsFromParams(countries), diff --git a/src/app/src/util/withQueryStringSync.jsx b/src/app/src/util/withQueryStringSync.jsx index 82a57cb88..58eccd12a 100644 --- a/src/app/src/util/withQueryStringSync.jsx +++ b/src/app/src/util/withQueryStringSync.jsx @@ -99,13 +99,17 @@ export default function withQueryStringSync(WrappedComponent) { }; } - function mapDispatchToProps(dispatch) { + function mapDispatchToProps(dispatch, { + history: { + push, + }, + }) { return { hydrateFiltersFromQueryString: (qs, fetch = true) => { dispatch(setFiltersFromQueryString(qs)); return fetch - ? dispatch(fetchFacilities()) + ? dispatch(fetchFacilities(push)) : null; }, clearFacilities: () => dispatch(resetFacilities()), diff --git a/src/django/api/constants.py b/src/django/api/constants.py index 545aee317..18da42fd3 100644 --- a/src/django/api/constants.py +++ b/src/django/api/constants.py @@ -19,6 +19,7 @@ class ProcessingAction: class FacilitiesQueryParams: + Q = 'q' NAME = 'name' CONTRIBUTORS = 'contributors' CONTRIBUTOR_TYPES = 'contributor_types' diff --git a/src/django/api/views.py b/src/django/api/views.py index 9d3217c17..fa0a15c53 100644 --- a/src/django/api/views.py +++ b/src/django/api/views.py @@ -385,12 +385,19 @@ class FacilitiesAPIFilterBackend(BaseFilterBackend): def get_schema_fields(self, view): if view.action == 'list': return [ + coreapi.Field( + name='q', + location='query', + type='string', + required=False, + description='Facility Name or OAR ID', + ), coreapi.Field( name='name', location='query', type='string', required=False, - description='Facility Name', + description='Facility Name (DEPRECATED; use `q` instead)' ), coreapi.Field( name='contributors', @@ -495,8 +502,10 @@ def list(self, request): if not params.is_valid(): raise ValidationError(params.errors) - name = request.query_params.get(FacilitiesQueryParams.NAME, - None) + free_text_query = request.query_params.get(FacilitiesQueryParams.Q, + None) + name = request.query_params.get(FacilitiesQueryParams.NAME, None) + contributors = request.query_params \ .getlist(FacilitiesQueryParams.CONTRIBUTORS) @@ -508,8 +517,15 @@ def list(self, request): queryset = Facility.objects.all() + if free_text_query is not None: + queryset = queryset.filter(Q(name__icontains=free_text_query) | + Q(id__icontains=free_text_query)) + + # `name` is deprecated in favor of `q`. We keep `name` available for + # backward compatibility. if name is not None: - queryset = queryset.filter(name__icontains=name) + queryset = queryset.filter(Q(name__icontains=name) | + Q(id__icontains=name)) if countries is not None and len(countries): queryset = queryset.filter(country_code__in=countries)