diff --git a/CHANGELOG.md b/CHANGELOG.md index 1104a6dfa..18a456bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Add `target="_blank"` to the `New` button of agreements accordion. (UIEH-1446) * Remove `shouldFocus` HOC and all related code. (UIEH-1444) +* Package Record - Title List Accordion - Improve Visibility of Search Within/Filter/Sort options. (UIEH-1440) ## [10.0.1] (https://github.com/folio-org/ui-eholdings/tree/v10.0.1) (2024-11-12) diff --git a/src/components/access-type-filter/access-type-filter.css b/src/components/access-type-filter/access-type-filter.css new file mode 100644 index 000000000..1fcd1b4d3 --- /dev/null +++ b/src/components/access-type-filter/access-type-filter.css @@ -0,0 +1,4 @@ +.headline { + display: flex; + align-items: baseline; +} diff --git a/src/components/access-type-filter/access-type-filter.js b/src/components/access-type-filter/access-type-filter.js new file mode 100644 index 000000000..320f9881d --- /dev/null +++ b/src/components/access-type-filter/access-type-filter.js @@ -0,0 +1,98 @@ +import { useRef } from 'react'; +import { useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; + +import { Icon } from '@folio/stripes/components'; +import { MultiSelectionFilter } from '@folio/stripes/smart-components'; + +import { SearchByCheckbox } from '../search-by-checkbox'; +import { ClearButton } from '../clear-button'; +import { accessTypesReduxStateShape } from '../../constants'; + +import styles from './access-type-filter.css'; + +const propTypes = { + accessTypesStoreData: accessTypesReduxStateShape.isRequired, + dataOptions: PropTypes.arrayOf(PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.string, + })).isRequired, + handleStandaloneFilterChange: PropTypes.func.isRequired, + onStandaloneFilterChange: PropTypes.func.isRequired, + onStandaloneFilterToggle: PropTypes.func.isRequired, + searchByAccessTypesEnabled: PropTypes.bool.isRequired, + selectedValues: PropTypes.array.isRequired, + showClearButton: PropTypes.bool, +}; + +const AccessTypesFilter = ({ + accessTypesStoreData, + searchByAccessTypesEnabled, + selectedValues, + onStandaloneFilterChange, + onStandaloneFilterToggle, + dataOptions, + handleStandaloneFilterChange, + showClearButton = false, +}) => { + const intl = useIntl(); + const labelRef = useRef(null); + + const accessStatusTypesExist = !!accessTypesStoreData?.items?.data?.length; + const accessTypeFilterLabel = intl.formatMessage({ id: 'ui-eholdings.accessTypes.filter' }); + + const handleClearButtonClick = () => { + onStandaloneFilterChange({ 'access-type': undefined }); + labelRef.current?.focus(); + }; + + if (accessTypesStoreData?.isLoading) { + return ; + } + + if (!accessStatusTypesExist) { + return null; + } + + return ( + <> +
+ + {intl.formatMessage({ id: 'ui-eholdings.settings.accessStatusTypes' })} + + + {showClearButton && ( + 0} + label={accessTypeFilterLabel} + onClick={handleClearButtonClick} + /> + )} +
+
+ +
+ + ); +}; + +AccessTypesFilter.propTypes = propTypes; + +export { AccessTypesFilter }; diff --git a/src/components/access-type-filter/access-type-filter.test.js b/src/components/access-type-filter/access-type-filter.test.js new file mode 100644 index 000000000..34dfbe418 --- /dev/null +++ b/src/components/access-type-filter/access-type-filter.test.js @@ -0,0 +1,100 @@ +import { act } from 'react'; + +import { render } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; + +import { AccessTypesFilter } from './access-type-filter'; + +const mockOnStandaloneFilterChange = jest.fn(); +const mockOnStandaloneFilterToggle = jest.fn(); +const mockHandleStandaloneFilterChange = jest.fn(); + +const accessTypesStoreData = { + items: { + data: [ + { + id: '1', + attributes: { name: 'Open Access' }, + }, + { + id: '2', + attributes: { name: 'Restricted Access' }, + }, + ], + }, + isLoading: false, + isDeleted: false, +}; + +const dataOptions = [ + { label: 'Open Access', value: 'open-access' }, + { label: 'Restricted Access', value: 'restricted-access' }, +]; + +const renderAccessTypeFilter = (props = {}) => render( + +); + +describe('AccessTypesFilter', () => { + it('should render loading', () => { + const { getByTestId } = renderAccessTypeFilter({ + accessTypesStoreData: { + ...accessTypesStoreData, + isLoading: true, + }, + }); + + expect(getByTestId('spinner-ellipsis')).toBeInTheDocument(); + }); + + describe('when there are no access types', () => { + it('should render nothing', () => { + const { container } = renderAccessTypeFilter({ + accessTypesStoreData: { + ...accessTypesStoreData, + items: { data: [] }, + }, + }); + + expect(container).toBeEmptyDOMElement(); + }); + }); + + it('should render access types filter', () => { + const { getByText } = renderAccessTypeFilter(); + + expect(getByText('ui-eholdings.accessTypes.filter')).toBeInTheDocument(); + }); + + describe('when hitting the clear icon', () => { + it('should call onStandaloneFilterChange', async () => { + const { getByRole } = renderAccessTypeFilter({ + selectedValues: ['open-access'], + showClearButton: true, + }); + + await act(() => userEvent.click(getByRole('button', { name: /clear/i }))); + + expect(mockOnStandaloneFilterChange).toHaveBeenCalledWith({ 'access-type': undefined }); + }); + }); + + describe('when searchByAccessTypesEnabled is false', () => { + it('should disable filter', () => { + const { getByRole } = renderAccessTypeFilter({ + searchByAccessTypesEnabled: false, + }); + + expect(getByRole('combobox')).toBeDisabled(); + }); + }); +}); diff --git a/src/components/access-type-filter/index.js b/src/components/access-type-filter/index.js new file mode 100644 index 000000000..ffda5dede --- /dev/null +++ b/src/components/access-type-filter/index.js @@ -0,0 +1 @@ +export * from './access-type-filter'; diff --git a/src/components/clear-button/clear-button.js b/src/components/clear-button/clear-button.js new file mode 100644 index 000000000..2ea44c81e --- /dev/null +++ b/src/components/clear-button/clear-button.js @@ -0,0 +1,40 @@ +import { useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; + +import { IconButton } from '@folio/stripes/components'; + +const propTypes = { + className: PropTypes.string, + label: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + show: PropTypes.bool.isRequired, +}; + +const ClearButton = ({ + show, + label, + className, + onClick, +}) => { + const intl = useIntl(); + + if (!show) { + return null; + } + return ( + + ); +}; + +ClearButton.propTypes = propTypes; + +export { ClearButton }; diff --git a/src/components/clear-button/index.js b/src/components/clear-button/index.js new file mode 100644 index 000000000..b2b2bb8ea --- /dev/null +++ b/src/components/clear-button/index.js @@ -0,0 +1 @@ +export * from './clear-button'; diff --git a/src/components/search-by-checkbox/index.js b/src/components/search-by-checkbox/index.js new file mode 100644 index 000000000..784228fec --- /dev/null +++ b/src/components/search-by-checkbox/index.js @@ -0,0 +1 @@ +export * from './search-by-checkbox'; diff --git a/src/components/search-form/components/search-by-checkbox/search-by-checkbox.css b/src/components/search-by-checkbox/search-by-checkbox.css similarity index 100% rename from src/components/search-form/components/search-by-checkbox/search-by-checkbox.css rename to src/components/search-by-checkbox/search-by-checkbox.css diff --git a/src/components/search-form/components/search-by-checkbox/search-by-checkbox.js b/src/components/search-by-checkbox/search-by-checkbox.js similarity index 83% rename from src/components/search-form/components/search-by-checkbox/search-by-checkbox.js rename to src/components/search-by-checkbox/search-by-checkbox.js index 03fb97019..842f85522 100644 --- a/src/components/search-form/components/search-by-checkbox/search-by-checkbox.js +++ b/src/components/search-by-checkbox/search-by-checkbox.js @@ -1,5 +1,6 @@ -import PropTypes from 'prop-types'; +import { forwardRef } from 'react'; import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; import camelCase from 'lodash/camelCase'; import upperFirst from 'lodash/upperFirst'; @@ -13,19 +14,16 @@ const propTypes = { onStandaloneFilterToggle: PropTypes.func.isRequired, }; -const defaultProps = { - isEnabled: false, -}; - -const SearchByCheckbox = ({ +const SearchByCheckbox = forwardRef(({ filterType, - isEnabled, + isEnabled = false, onStandaloneFilterToggle, -}) => { +}, ref) => { const upperFilterType = upperFirst(camelCase(filterType)); return ( @@ -36,9 +34,8 @@ const SearchByCheckbox = ({ data-test-search-by={filterType} /> ); -}; +}); SearchByCheckbox.propTypes = propTypes; -SearchByCheckbox.defaultProps = defaultProps; -export default SearchByCheckbox; +export { SearchByCheckbox }; diff --git a/src/components/search-form/components/access-types-filter-accordion/access-types-filter-accordion.js b/src/components/search-form/components/access-types-filter-accordion/access-types-filter-accordion.js index 2e9d38704..d32d49567 100644 --- a/src/components/search-form/components/access-types-filter-accordion/access-types-filter-accordion.js +++ b/src/components/search-form/components/access-types-filter-accordion/access-types-filter-accordion.js @@ -5,9 +5,9 @@ import { Accordion, Icon, } from '@folio/stripes/components'; -import { MultiSelectionFilter } from '@folio/stripes/smart-components'; -import SearchByCheckbox from '../search-by-checkbox'; +import { AccessTypesFilter } from '../../../access-type-filter'; +import { getAccessTypesList } from '../../../utilities'; import { accessTypesReduxStateShape } from '../../../../constants'; import styles from './access-types-filter-accordion.css'; @@ -49,17 +49,8 @@ const AccessTypesFilterAccordion = ({ 'access-type': accessTypes = [], } = searchFilter; - let accessTypesList = []; - - if (accessTypes) { - accessTypesList = Array.isArray(accessTypes) - ? accessTypes - : accessTypes.split(','); - } - - accessTypesList.sort(); - const accessStatusTypesExist = !!accessTypesStoreData?.items?.data?.length; + const accessTypesList = getAccessTypesList(accessTypes); return accessTypesStoreData?.isLoading ? @@ -80,32 +71,15 @@ const AccessTypesFilterAccordion = ({ onToggle={onToggle} className={styles['search-filter-accordion']} > - - - - - - { - ([label]) => ( -
- -
- ) - } -
); diff --git a/src/components/search-form/components/search-by-checkbox/index.js b/src/components/search-form/components/search-by-checkbox/index.js deleted file mode 100644 index fb3bab3d5..000000000 --- a/src/components/search-form/components/search-by-checkbox/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './search-by-checkbox'; diff --git a/src/components/search-form/components/tags-filter-accordion/tags-filter-accordion.js b/src/components/search-form/components/tags-filter-accordion/tags-filter-accordion.js index 089eadbb9..326ccd43b 100644 --- a/src/components/search-form/components/tags-filter-accordion/tags-filter-accordion.js +++ b/src/components/search-form/components/tags-filter-accordion/tags-filter-accordion.js @@ -5,9 +5,9 @@ import { Accordion, Icon, } from '@folio/stripes/components'; -import { MultiSelectionFilter } from '@folio/stripes/smart-components'; -import SearchByCheckbox from '../search-by-checkbox'; +import { TagsFilter } from '../../../tags-filter'; +import { getTagsList } from '../../../utilities'; import styles from './tags-filter-accordion.css'; @@ -48,18 +48,7 @@ const TagsFilterAccordion = ({ tags = '', } = searchFilter; - let tagsList = []; - - if (tags && dataOptions.length) { - tagsList = Array.isArray(tags) - ? tags - : tags.split(','); - } - - tagsList = tagsList - .filter(tag => dataOptions.some(option => option.value === tag)) - .map(tag => tag.toLowerCase()) - .sort(); + const tagsList = getTagsList(tags, dataOptions); return tagsModel.isLoading ? @@ -80,35 +69,15 @@ const TagsFilterAccordion = ({ onToggle={onToggle} className={styles['search-filter-accordion']} > - - - - - - { - ([label]) => ( -
- -
- ) - } -
); diff --git a/src/components/search-form/search-filters.js b/src/components/search-form/search-filters.js index d9e141e0b..1491bd877 100644 --- a/src/components/search-form/search-filters.js +++ b/src/components/search-form/search-filters.js @@ -1,12 +1,16 @@ +import { useRef } from 'react'; import PropTypes from 'prop-types'; import { filter } from 'funcadelic'; import { Accordion, FilterAccordionHeader, + Label, RadioButton } from '@folio/stripes/components'; +import { ClearButton } from '../clear-button'; + import styles from './search-form.css'; const propTypes = { @@ -22,6 +26,7 @@ const propTypes = { })).isRequired, closedByDefault: PropTypes.bool, disabled: PropTypes.bool, + hasAccordion: PropTypes.bool, onUpdate: PropTypes.func.isRequired, searchType: PropTypes.string.isRequired, }; @@ -39,7 +44,64 @@ const SearchFilters = ({ onUpdate, closedByDefault, disabled, + hasAccordion = true, }) => { + const labelRef = useRef(null); + + const handleClearFilter = (name) => { + onUpdate({ + ...activeFilters, + [name]: undefined, + }); + }; + + const handleClearButtonClick = (labelId, name) => { + handleClearFilter(name); + + // focus on the default option + setTimeout(() => { + document.querySelector(`[aria-labelledby="${labelId}"] input[tabindex="0"]`)?.focus(); + }); + }; + + const renderRadioGroup = ({ name, options, accordionLabelId, defaultValue }) => { + return ( +
+ {options.map(({ label: radioBtnLabel, value }, i) => { + const isChecked = value === (activeFilters[name] || defaultValue); + + return ( + { + const replaced = { + ...activeFilters, + // if this option is a default, clear the filter + [name]: value === defaultValue ? undefined : value + }; + const withoutDefault = filter(item => item.value !== undefined, replaced); + + return onUpdate(withoutDefault); + }} + /> + ); + })} +
+ ); + }; + return (
{ const accordionLabelId = `filter-${searchType}-${name}-label`; + const radioGroupProps = { + name, + options, + accordionLabelId, + defaultValue, + label, + }; + + if (!hasAccordion) { + const hasSelectedOption = ![undefined, defaultValue].includes(activeFilters[name]); + + return ( +
+
+ + handleClearButtonClick(accordionLabelId, name)} + /> +
+ {renderRadioGroup(radioGroupProps)} +
+ ); + } + return ( onUpdate({ ...activeFilters, [name]: undefined })} + onClearFilter={() => handleClearFilter(name)} id={`filter-${searchType}-${name}`} className={styles['search-filter-accordion']} > -
- {options.map(({ label: radioBtnLabel, value }, i) => { - const isChecked = value === (activeFilters[name] || defaultValue); - - return ( - { - const replaced = { - ...activeFilters, - // if this option is a default, clear the filter - [name]: value === defaultValue ? undefined : value - }; - const withoutDefault = filter(item => item.value !== undefined, replaced); - - return onUpdate(withoutDefault); - }} - /> - ); - })} -
+ {renderRadioGroup(radioGroupProps)}
); })} diff --git a/src/components/search-form/search-form.css b/src/components/search-form/search-form.css index a4cda3282..09339ada1 100644 --- a/src/components/search-form/search-form.css +++ b/src/components/search-form/search-form.css @@ -23,3 +23,16 @@ .search-field { margin-bottom: calc(var(--control-margin-bottom) / 2); } + +.groupContainer { + margin-bottom: 1rem; +} + +.clearButton { + margin-bottom: var(--control-label-margin-bottom); +} + +.groupTitle { + display: flex; + align-items: center; +} diff --git a/src/components/search-modal/search-modal.js b/src/components/search-modal/search-modal.js index 2262bb7e0..ddf738e32 100644 --- a/src/components/search-modal/search-modal.js +++ b/src/components/search-modal/search-modal.js @@ -19,21 +19,10 @@ import { accessTypesReduxStateShape, searchTypes, } from '../../constants'; -import { filterCountFromQuery } from '../utilities'; - -export const normalize = (query = {}) => { - return { - filter: query.filter || { - tags: undefined, - type: undefined, - selected: undefined, - 'access-type': undefined, - }, - q: query.q || '', - searchfield: query.searchfield, - sort: query.sort, - }; -}; +import { + filterCountFromQuery, + normalize, +} from '../utilities'; class SearchModal extends PureComponent { static propTypes = { diff --git a/src/components/search-section/action-menu/action-menu.css b/src/components/search-section/action-menu/action-menu.css new file mode 100644 index 000000000..e5be30c25 --- /dev/null +++ b/src/components/search-section/action-menu/action-menu.css @@ -0,0 +1,5 @@ +.actionMenuToggle { + display: flex; + align-items: center; + gap: 0.5rem; +} diff --git a/src/components/search-section/action-menu/action-menu.js b/src/components/search-section/action-menu/action-menu.js new file mode 100644 index 000000000..ccdf5f092 --- /dev/null +++ b/src/components/search-section/action-menu/action-menu.js @@ -0,0 +1,239 @@ +import { + FormattedMessage, + useIntl, +} from 'react-intl'; +import PropTypes from 'prop-types'; +import sortBy from 'lodash/sortBy'; + +import { + Badge, + Button, + DropdownMenu, + Icon, + Tooltip, + Dropdown, +} from '@folio/stripes/components'; +import { IfPermission } from '@folio/stripes/core'; + +import { TagsFilter } from '../../tags-filter'; +import { AccessTypesFilter } from '../../access-type-filter'; +import PackageSearchFilters from '../../package-search-filters'; +import ProviderSearchFilters from '../../provider-search-filters'; +import TitleSearchFilters from '../../title-search-filters'; +import { + getAccessTypesList, + getTagLabelsArr, + getTagsList, +} from '../../utilities'; +import { searchTypes } from '../../../constants'; + +import styles from './action-menu.css'; + +const searchFiltersComponents = { + [searchTypes.PACKAGES]: PackageSearchFilters, + [searchTypes.PROVIDERS]: ProviderSearchFilters, + [searchTypes.TITLES]: TitleSearchFilters, +}; + +const propTypes = { + accessTypes: PropTypes.object.isRequired, + filterCount: PropTypes.number.isRequired, + onFilterChange: PropTypes.func.isRequired, + onStandaloneFilterChange: PropTypes.func.isRequired, + onToggleActions: PropTypes.func.isRequired, + onToggleFilter: PropTypes.func.isRequired, + packagesFacetCollection: PropTypes.object, + params: PropTypes.object, + prevDataOfOptedPackage: PropTypes.object, + query: PropTypes.object.isRequired, + results: PropTypes.object, + searchByAccessTypesEnabled: PropTypes.bool.isRequired, + searchByTagsEnabled: PropTypes.bool.isRequired, + searchType: PropTypes.string.isRequired, + standaloneFiltersEnabled: PropTypes.bool.isRequired, + tagsModelOfAlreadyAddedTags: PropTypes.object.isRequired, + titlesFacets: PropTypes.object, +}; + +const ActionMenu = ({ + searchType, + tagsModelOfAlreadyAddedTags, + searchByTagsEnabled, + searchByAccessTypesEnabled, + query, + accessTypes, + standaloneFiltersEnabled, + params, + prevDataOfOptedPackage, + results, + titlesFacets, + packagesFacetCollection, + filterCount, + onFilterChange, + onToggleFilter, + onToggleActions, + onStandaloneFilterChange, +}) => { + const intl = useIntl(); + + const Filters = searchFiltersComponents[searchType]; + + // sort is treated separately from the rest of the filters on submit, + // but treated together when rendering the filters. + const combinedFilters = { + sort: query.sort, + ...query.filter, + }; + + const handleStandaloneFilterChange = filter => { + const formattedFilter = { + [filter.name]: filter.values || undefined, + }; + + onStandaloneFilterChange(formattedFilter); + }; + + const getSortedDataOptions = () => { + const dataOptions = getTagLabelsArr(tagsModelOfAlreadyAddedTags) + .filter(tag => tag.value) + .map(tag => { + const tagDisplay = tag.value.toLowerCase(); + + return { + value: tagDisplay, + label: tagDisplay, + }; + }); + + return sortBy(dataOptions, ['value']); + }; + + const renderAccessTypesFilter = () => { + const accessStatusTypesExist = !!accessTypes?.items?.data?.length; + const isPackagesOrTitlesSearchType = [searchTypes.PACKAGES, searchTypes.TITLES].includes(searchType); + + const accessTypesDataOptions = accessTypes?.items?.data?.map(({ attributes }) => ({ + value: attributes.name, + label: attributes.name, + })); + + if (isPackagesOrTitlesSearchType && accessStatusTypesExist) { + return ( + + ); + } + + return null; + }; + + const renderActionMenu = () => { + const tagsOptions = getSortedDataOptions(); + + return ( +
+ + + + {renderAccessTypesFilter()} + +
+ ); + }; + + const renderActionMenuContent = () => ( + + {renderActionMenu()} + + ); + + const renderActionMenuToggle = ({ onToggle, triggerRef, keyHandler, open, ariaProps, getTriggerProps }) => { + const handleActionMenuToggle = (e) => { + onToggleActions(!open); + onToggle(e); + }; + + return ( +
+ + {filterCount > 0 && ( + + {({ ref, ariaIds }) => ( +
+ + {filterCount} + +
+ )} +
+ )} +
+ ); + }; + + return ( + + ); +}; + +ActionMenu.propTypes = propTypes; + +export { ActionMenu }; diff --git a/src/components/search-section/action-menu/index.js b/src/components/search-section/action-menu/index.js new file mode 100644 index 000000000..19707d5d0 --- /dev/null +++ b/src/components/search-section/action-menu/index.js @@ -0,0 +1 @@ +export * from './action-menu'; diff --git a/src/components/search-section/index.js b/src/components/search-section/index.js new file mode 100644 index 000000000..38e12c211 --- /dev/null +++ b/src/components/search-section/index.js @@ -0,0 +1 @@ +export * from './search-section'; diff --git a/src/components/search-section/search-section.css b/src/components/search-section/search-section.css new file mode 100644 index 000000000..54423ad8c --- /dev/null +++ b/src/components/search-section/search-section.css @@ -0,0 +1,4 @@ +.container { + display: flex; + gap: 1rem; +} diff --git a/src/components/search-section/search-section.js b/src/components/search-section/search-section.js new file mode 100644 index 000000000..9042801e3 --- /dev/null +++ b/src/components/search-section/search-section.js @@ -0,0 +1,221 @@ +import { + useRef, + useState, +} from 'react'; +import { useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; + +import { + SearchField, + Select +} from '@folio/stripes/components'; + +import { ActionMenu } from './action-menu'; +import { + searchableIndexes, + searchTypes, +} from '../../constants'; +import { + filterCountFromQuery, + normalize, +} from '../utilities'; + +import styles from './search-section.css'; + +const EMPTY_OBJECT = {}; + +const propTypes = { + accessTypes: PropTypes.object.isRequired, + onFilter: PropTypes.func.isRequired, + onToggleActions: PropTypes.func.isRequired, + packagesFacetCollection: PropTypes.object, + params: PropTypes.object, + prevDataOfOptedPackage: PropTypes.object, + queryProp: PropTypes.object.isRequired, + results: PropTypes.object, + searchType: PropTypes.string.isRequired, + tagsModelOfAlreadyAddedTags: PropTypes.object, + titlesFacets: PropTypes.object, +}; + +const SearchSection = ({ + searchType, + queryProp, + tagsModelOfAlreadyAddedTags, + accessTypes, + params = EMPTY_OBJECT, + prevDataOfOptedPackage = EMPTY_OBJECT, + results = EMPTY_OBJECT, + titlesFacets = EMPTY_OBJECT, + packagesFacetCollection = EMPTY_OBJECT, + onFilter, + onToggleActions, +}) => { + const intl = useIntl(); + + const queryContainsTagsFilter = !!queryProp?.filter?.tags; + const queryContainsAccessTypesFilter = !!queryProp?.filter['access-type']; + + const searchFieldRef = useRef(null); + const [query, setQuery] = useState(normalize(queryProp)); + const [searchByTagsEnabled, setSearchByTagsEnabled] = useState(queryContainsTagsFilter); + const [searchByAccessTypesEnabled, setSearchByAccessTypesEnabled] = useState(queryContainsAccessTypesFilter && !queryContainsTagsFilter); + + const standaloneFiltersEnabled = searchByTagsEnabled || searchByAccessTypesEnabled; + const queryFromProps = normalize(queryProp); + const filterCount = filterCountFromQuery(queryFromProps); + + const updateFilter = (_query) => { + let searchQuery; + + if (!searchByTagsEnabled && _query.q !== '') { + searchQuery = _query.q; + } + + const filter = { ..._query.filter }; + + if (!searchByTagsEnabled) { + filter.tags = undefined; + } + + if (!searchByAccessTypesEnabled) { + filter['access-type'] = undefined; + } + + onFilter({ + ..._query, + filter, + q: searchQuery, + }); + }; + + const handleSearchSubmit = (e) => { + e.preventDefault(); + updateFilter(query); + }; + + const handleSearchFieldChange = (e) => { + setQuery(cur => normalize({ + ...cur, + searchfield: e.target.value, + })); + }; + + const handleSearchQueryChange = e => { + setQuery(cur => ({ + ...cur, + q: e.target.value, + })); + }; + + const handleClearSearch = () => { + setQuery(cur => ({ + ...cur, + q: '', + })); + }; + + const toggleFilter = filterName => () => { + if (filterName === 'access-type') { + setSearchByAccessTypesEnabled(cur => !cur); + setSearchByTagsEnabled(false); + } else { + setSearchByTagsEnabled(cur => !cur); + setSearchByAccessTypesEnabled(false); + } + }; + + const handleStandaloneFilterChange = filter => { + setQuery(cur => { + const newQuery = normalize({ + sort: cur.sort, + filter, + }); + + updateFilter(newQuery); + + return newQuery; + }); + }; + + const handleFilterChange = (args) => { + const { sort, ...filter } = args; + + setQuery(cur => { + const newQuery = normalize({ + sort, + filter, + searchfield: cur.searchfield, + q: cur.q, + }); + + updateFilter(newQuery); + + return newQuery; + }); + }; + + return ( +
+ {searchType === searchTypes.TITLES && ( + + )} +
+ + + +
+ ); +}; + +SearchSection.propTypes = propTypes; + +export { SearchSection }; diff --git a/src/components/search-section/search-section.test.js b/src/components/search-section/search-section.test.js new file mode 100644 index 000000000..110123b78 --- /dev/null +++ b/src/components/search-section/search-section.test.js @@ -0,0 +1,123 @@ +import { act } from 'react'; + +import { render } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; + +import { SearchSection } from './search-section'; +import { + searchableIndexes, + searchTypes, +} from '../../constants'; + +const mockOnFilter = jest.fn(); + +const queryProp = { + searchfield: 'title', + count: 100, + page: 1, + filter: { + 'access-type': undefined, + selected: undefined, + tags: undefined, + type: undefined, + }, +}; + +const accessTypes = { + isLoading: false, + items: { + data: [], + meta: { + totalResults: 0 + }, + jsonapi: { + version: '1.0', + }, + }, + errors: [], + isDeleted: false +}; + +const tagsModelOfAlreadyAddedTags = { + isLoading: false, +}; + +const renderSearchSection = (props = {}) => render( + +); + +describe('SearchSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render search box', () => { + const { getByRole } = renderSearchSection(); + + expect(getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' })).toBeInTheDocument(); + }); + + describe('when search option changes', () => { + it('should not fetch data', async () => { + const { getByRole } = renderSearchSection(); + + await userEvent.selectOptions(getByRole('combobox'), searchableIndexes.ISNX); + + expect(mockOnFilter).not.toHaveBeenCalled(); + }); + }); + + describe('when search query changes', () => { + it('should not fetch data', async () => { + const { getByRole } = renderSearchSection(); + + await userEvent.type(getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' }), 'Test query'); + + expect(mockOnFilter).not.toHaveBeenCalled(); + }); + }); + + describe('when search is submitted', () => { + it('should fetch data', async () => { + const { getByRole } = renderSearchSection(); + + const searchBox = getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' }); + + await act(() => userEvent.type(searchBox, 'Title name{enter}')); + + expect(mockOnFilter).toHaveBeenCalledWith(expect.objectContaining({ q: 'Title name' })); + }); + }); + + describe('when hitting clear icon', () => { + it('should clear search box', async () => { + const { getByRole } = renderSearchSection(); + + const searchBox = getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' }); + + await userEvent.type(searchBox, 'Title name'); + await userEvent.click(getByRole('button', { name: 'stripes-components.clearThisField' })); + + expect(searchBox.value).toBe(''); + }); + }); + + describe('when search by tags filter is enabled', () => { + it('should disable search box', async () => { + const { getByText, getByRole } = renderSearchSection(); + + expect(getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' })).toBeEnabled(); + + await userEvent.click(getByText('ui-eholdings.search.searchByTagsOnly')); + + expect(getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' })).toBeDisabled(); + }); + }); +}); diff --git a/src/components/tags-filter/index.js b/src/components/tags-filter/index.js new file mode 100644 index 000000000..50a466333 --- /dev/null +++ b/src/components/tags-filter/index.js @@ -0,0 +1 @@ +export * from './tags-filter'; diff --git a/src/components/tags-filter/tags-filter.css b/src/components/tags-filter/tags-filter.css new file mode 100644 index 000000000..1fcd1b4d3 --- /dev/null +++ b/src/components/tags-filter/tags-filter.css @@ -0,0 +1,4 @@ +.headline { + display: flex; + align-items: baseline; +} diff --git a/src/components/tags-filter/tags-filter.js b/src/components/tags-filter/tags-filter.js new file mode 100644 index 000000000..7c9278f73 --- /dev/null +++ b/src/components/tags-filter/tags-filter.js @@ -0,0 +1,88 @@ +import { useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; + +import { MultiSelectionFilter } from '@folio/stripes/smart-components'; + +import { ClearButton } from '../clear-button'; +import { SearchByCheckbox } from '../search-by-checkbox'; + +import styles from './tags-filter.css'; + +const propTypes = { + dataOptions: PropTypes.arrayOf(PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.string, + })).isRequired, + handleStandaloneFilterChange: PropTypes.func.isRequired, + isLoading: PropTypes.object.isRequired, + onStandaloneFilterChange: PropTypes.func.isRequired, + onStandaloneFilterToggle: PropTypes.func.isRequired, + searchByTagsEnabled: PropTypes.bool.isRequired, + selectedValues: PropTypes.object.isRequired, + showClearButton: PropTypes.bool, +}; + +const TagsFilter = ({ + isLoading, + selectedValues, + searchByTagsEnabled, + onStandaloneFilterChange, + onStandaloneFilterToggle, + handleStandaloneFilterChange, + dataOptions, + showClearButton = false, +}) => { + const intl = useIntl(); + const labelRef = useRef(null); + + const tagsFilterLabel = intl.formatMessage({ id: 'ui-eholdings.tags.filter' }); + + const handleClearButtonClick = () => { + onStandaloneFilterChange({ tags: undefined }); + labelRef.current?.focus(); + }; + + return ( + <> +
+ + {intl.formatMessage({ id: 'ui-eholdings.tags' })} + + + {showClearButton && ( + 0} + label={tagsFilterLabel} + onClick={handleClearButtonClick} + /> + )} +
+
+ +
+ + ); +}; + +TagsFilter.propTypes = propTypes; + +export { TagsFilter }; diff --git a/src/components/utilities.js b/src/components/utilities.js index c29d7d683..ea5f0b203 100644 --- a/src/components/utilities.js +++ b/src/components/utilities.js @@ -295,3 +295,48 @@ export const handleSaveKeyFormSubmit = (formRef) => (event) => { export const filterCountFromQuery = ({ q, sort, filter = [] }) => { return [q, sort].concat(Object.values(filter)).filter(Boolean).length; }; + +export const getTagsList = (tags, dataOptions) => { + let tagsList = []; + + if (tags && dataOptions.length) { + tagsList = Array.isArray(tags) + ? tags + : tags.split(','); + } + + tagsList = tagsList + .filter(tag => dataOptions.some(option => option.value === tag)) + .map(tag => tag.toLowerCase()) + .sort(); + + return tagsList; +}; + +export const getAccessTypesList = (accessTypes) => { + let accessTypesList = []; + + if (accessTypes) { + accessTypesList = Array.isArray(accessTypes) + ? accessTypes + : accessTypes.split(','); + } + + accessTypesList.sort(); + + return accessTypesList; +}; + +export const normalize = (query = {}) => { + return { + filter: query.filter || { + tags: undefined, + type: undefined, + selected: undefined, + 'access-type': undefined, + }, + q: query.q || '', + searchfield: query.searchfield, + sort: query.sort, + }; +}; diff --git a/src/routes/package-show-route/package-show-route.js b/src/routes/package-show-route/package-show-route.js index f5599e2e4..441d4e6dd 100644 --- a/src/routes/package-show-route/package-show-route.js +++ b/src/routes/package-show-route/package-show-route.js @@ -19,7 +19,7 @@ import { } from '../../constants'; import View from '../../components/package/show'; -import SearchModal from '../../components/search-modal'; +import { SearchSection } from '../../components/search-section'; class PackageShowRoute extends Component { static propTypes = { @@ -397,7 +397,6 @@ class PackageShowRoute extends Component { const { pkgSearchParams, - queryId, isTitlesUpdating, } = this.state; @@ -440,15 +439,13 @@ class PackageShowRoute extends Component { history.location.state.isDestroyed } searchModal={ - } /> diff --git a/src/routes/package-show-route/package-show-route.test.js b/src/routes/package-show-route/package-show-route.test.js index 435d522e0..0fe98d39a 100644 --- a/src/routes/package-show-route/package-show-route.test.js +++ b/src/routes/package-show-route/package-show-route.test.js @@ -10,6 +10,8 @@ import { waitFor, } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; + import PackageShowRoute from './package-show-route'; import Harness from '../../../test/jest/helpers/harness'; @@ -510,9 +512,8 @@ describe('Given PackageShowRoute', () => { }); describe('when package search params change', () => { - it('should handle getPackageTitles', () => { + it('should handle getPackageTitles', async () => { const { - getAllByTestId, getByRole, } = renderPackageShowRoute({ getPackageTitles: mockGetPackageTitles, @@ -525,11 +526,9 @@ describe('Given PackageShowRoute', () => { }, }); - fireEvent.click(getAllByTestId('search-badge')[0]); - fireEvent.change(getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' }), { - target: { value: 'Title name' }, - }); - fireEvent.click(getByRole('button', { name: 'ui-eholdings.label.search' })); + const searchBox = getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' }); + + await userEvent.type(searchBox, 'Title name{enter}'); expect(mockGetPackageTitles).toHaveBeenCalledWith({ packageId, @@ -550,9 +549,8 @@ describe('Given PackageShowRoute', () => { }); describe('when changed param is not single and it is not "page"', () => { - it('should handle clearPackageTitles', () => { + it('should handle clearPackageTitles', async () => { const { - getAllByTestId, getByRole, } = renderPackageShowRoute({ clearPackageTitles: mockClearPackageTitles, @@ -565,11 +563,9 @@ describe('Given PackageShowRoute', () => { }, }); - fireEvent.click(getAllByTestId('search-badge')[0]); - fireEvent.change(getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' }), { - target: { value: 'Title name' }, - }); - fireEvent.click(getByRole('button', { name: 'ui-eholdings.label.search' })); + const searchBox = getByRole('searchbox', { name: 'ui-eholdings.search.enterYourSearch' }); + + await userEvent.type(searchBox, 'Title name{enter}'); expect(mockClearPackageTitles).toHaveBeenCalled(); }); @@ -694,7 +690,7 @@ describe('Given PackageShowRoute', () => { describe('when remove package from holdings', () => { describe('when model is not custom', () => { it('should handle updatePackage', () => { - const { getByRole } = renderPackageShowRoute({ + const { getAllByRole, getByRole } = renderPackageShowRoute({ updatePackage: mockUpdatePackage, model: { ...model, @@ -702,7 +698,7 @@ describe('Given PackageShowRoute', () => { }, }); - fireEvent.click(getByRole('button', { name: 'stripes-components.paneMenuActionsToggleLabel' })); + fireEvent.click(getAllByRole('button', { name: 'stripes-components.paneMenuActionsToggleLabel' })[0]); fireEvent.click(getByRole('button', { name: 'ui-eholdings.package.removeFromHoldings' })); fireEvent.click(getByRole('button', { name: 'ui-eholdings.package.modal.buttonConfirm' })); @@ -712,7 +708,7 @@ describe('Given PackageShowRoute', () => { describe('when model is custom', () => { it('should handle destroyPackage', () => { - const { getByRole } = renderPackageShowRoute({ + const { getByRole, getAllByRole } = renderPackageShowRoute({ destroyPackage: mockDestroyPackage, model: { ...model, @@ -721,7 +717,7 @@ describe('Given PackageShowRoute', () => { }, }); - fireEvent.click(getByRole('button', { name: 'stripes-components.paneMenuActionsToggleLabel' })); + fireEvent.click(getAllByRole('button', { name: 'stripes-components.paneMenuActionsToggleLabel' })[0]); fireEvent.click(getByRole('button', { name: 'ui-eholdings.package.deletePackage' })); fireEvent.click(getByRole('button', { name: 'ui-eholdings.package.modal.buttonConfirm.isCustom' })); @@ -792,14 +788,14 @@ describe('Given PackageShowRoute', () => { describe('when click on Edit button', () => { it('should redirect to edit package page', () => { - const { getByRole } = renderPackageShowRoute({ + const { getByRole, getAllByRole } = renderPackageShowRoute({ model: { ...model, isSelected: true, }, }); - fireEvent.click(getByRole('button', { name: 'stripes-components.paneMenuActionsToggleLabel' })); + fireEvent.click(getAllByRole('button', { name: 'stripes-components.paneMenuActionsToggleLabel' })[0]); fireEvent.click(getByRole('button', { name: 'ui-eholdings.actionMenu.edit' })); expect(historyReplaceSpy).toHaveBeenCalledWith({ diff --git a/translations/ui-eholdings/en.json b/translations/ui-eholdings/en.json index 1b0220071..2a882f81a 100644 --- a/translations/ui-eholdings/en.json +++ b/translations/ui-eholdings/en.json @@ -635,5 +635,8 @@ "permission.settings.custom-labels.view": "Settings (eholdings): Can view custom labels", "permission.settings.usage-consolidation.view": "Settings (eholdings): View Usage Consolidation API credentials", "permission.settings.usage-consolidation.create-edit": "Settings (eholdings): Create, edit, and view Usage Consolidation API credentials", - "permission.settings.assignedUser": "Settings (eHoldings): Can assign/unassign a user from a KB" + "permission.settings.assignedUser": "Settings (eHoldings): Can assign/unassign a user from a KB", + + "actionMenu.label": "Actions", + "actionMenu.filterBadgeTooltip": "{count} applied {count, plural, one {filter} other {filters}}" }