From 062b5a842bdfeff2df132a2d9a3c011ae9a8248f Mon Sep 17 00:00:00 2001
From: Mariia Sychova <84023879+mariiaschv@users.noreply.github.com>
Date: Tue, 7 Dec 2021 15:25:19 +0200
Subject: [PATCH] UIMARCAUTH-16: MARC Authorities Search Box (#28)
* UIMARCAUTH-16 Implement MARC Authorities Search Box
* UIMARCAUTH-16 Make updates
* UIMARCAUTH-16 Add tests
* UIMARCAUTH-16 Fix import path in test
* UIMARCAUTH-16 Fix comments
* UIMARCAUTH-16 Fix tests
* UIMARCAUTH-16 Add @rehooks/local-storage to resolutions in package.json
* UIMARCAUTH-16 Change @rehooks/local-storage version in dependencies
---
CHANGELOG.md | 1 +
Jenkinsfile | 2 +-
package.json | 7 +-
.../SearchResultsList.js} | 45 ++--
.../SearchResultsList.test.js} | 2 +-
src/components/SearchResultsList/index.js | 1 +
.../SearchTextareaField.css | 9 +
.../SearchTextareaField.js | 81 +++++++
.../SearchTextareaField.test.js | 54 +++++
src/components/SearchTextareaField/index.js | 1 +
src/components/index.js | 3 +-
src/components/search-results-list/index.js | 1 -
src/constants/index.js | 3 +
src/constants/rawSearchableIndexes.js | 15 ++
src/constants/searchableIndexesMap.js | 71 ++++++
src/constants/searchableIndexesValues.js | 13 ++
.../{authority-shape.js => authority.js} | 0
src/constants/shapes/index.js | 2 +-
src/hooks/useAuthorities/index.js | 1 +
src/hooks/useAuthorities/useAuthorities.js | 70 ++++++
.../useAuthorities/useAuthorities.test.js | 66 ++++++
src/hooks/utils/buildQuery.js | 59 +++++
src/hooks/utils/buildQuery.test.js | 36 ++++
src/hooks/utils/index.js | 1 +
src/index.js | 10 +-
src/routes/SearchRoute.js | 9 +
src/routes/search.js | 29 ---
.../AuthoritiesSearch/AuthoritiesSearch.css | 9 +
.../AuthoritiesSearch/AuthoritiesSearch.js | 204 ++++++++++++++++++
.../AuthoritiesSearch.test.js | 181 ++++++++++++++++
src/views/AuthoritiesSearch/index.js | 1 +
src/views/index.js | 1 +
test/jest/__mock__/stripesCore.mock.js | 6 +
translations/ui-marc-authorities/en.json | 15 +-
34 files changed, 938 insertions(+), 71 deletions(-)
rename src/components/{search-results-list/search-results-list.js => SearchResultsList/SearchResultsList.js} (66%)
rename src/components/{search-results-list/search-results-test.test.js => SearchResultsList/SearchResultsList.test.js} (92%)
create mode 100644 src/components/SearchResultsList/index.js
create mode 100644 src/components/SearchTextareaField/SearchTextareaField.css
create mode 100644 src/components/SearchTextareaField/SearchTextareaField.js
create mode 100644 src/components/SearchTextareaField/SearchTextareaField.test.js
create mode 100644 src/components/SearchTextareaField/index.js
delete mode 100644 src/components/search-results-list/index.js
create mode 100644 src/constants/index.js
create mode 100644 src/constants/rawSearchableIndexes.js
create mode 100644 src/constants/searchableIndexesMap.js
create mode 100644 src/constants/searchableIndexesValues.js
rename src/constants/shapes/{authority-shape.js => authority.js} (100%)
create mode 100644 src/hooks/useAuthorities/index.js
create mode 100644 src/hooks/useAuthorities/useAuthorities.js
create mode 100644 src/hooks/useAuthorities/useAuthorities.test.js
create mode 100644 src/hooks/utils/buildQuery.js
create mode 100644 src/hooks/utils/buildQuery.test.js
create mode 100644 src/hooks/utils/index.js
create mode 100644 src/routes/SearchRoute.js
delete mode 100644 src/routes/search.js
create mode 100644 src/views/AuthoritiesSearch/AuthoritiesSearch.css
create mode 100644 src/views/AuthoritiesSearch/AuthoritiesSearch.js
create mode 100644 src/views/AuthoritiesSearch/AuthoritiesSearch.test.js
create mode 100644 src/views/AuthoritiesSearch/index.js
create mode 100644 src/views/index.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4e74bb9..11023b28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,3 +5,4 @@
* New app created with stripes-cli
* [UIMARCAUTH-6](https://issues.folio.org/browse/UIMARCAUTH-6) Add View MARC authority record permission.
* [UIMARCAUTH-5](https://issues.folio.org/browse/UIMARCAUTH-5) Add Edit MARC authority record permission.
+* [UIMARCAUTH-16](https://issues.folio.org/browse/UIMARCAUTH-16) Implement MARC Authorities Search Box.
diff --git a/Jenkinsfile b/Jenkinsfile
index fed373ea..213c5e0a 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -1,7 +1,7 @@
buildNPM {
publishModDescriptor = true
runLint = true
- runTest = false
+ runTest = true
runSonarqube = false
runScripts = [
['formatjs-compile': ''],
diff --git a/package.json b/package.json
index ae122c6a..326678e4 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,9 @@
"regenerator-runtime": "^0.13.3"
},
"dependencies": {
- "prop-types": "^15.6.0"
+ "@rehooks/local-storage": "2.4.0",
+ "prop-types": "^15.6.0",
+ "query-string": "^7.0.1"
},
"peerDependencies": {
"@folio/stripes": "^7.0.0",
@@ -85,7 +87,8 @@
"permissionName": "ui-marc-authorities.authority-record.view",
"displayName": "View MARC authority record",
"subPermissions": [
- "records-editor.records.item.get"
+ "records-editor.records.item.get",
+ "search.authorities.collection.get"
],
"visible": true
},
diff --git a/src/components/search-results-list/search-results-list.js b/src/components/SearchResultsList/SearchResultsList.js
similarity index 66%
rename from src/components/search-results-list/search-results-list.js
rename to src/components/SearchResultsList/SearchResultsList.js
index 04709086..b85f4a96 100644
--- a/src/components/search-results-list/search-results-list.js
+++ b/src/components/SearchResultsList/SearchResultsList.js
@@ -1,17 +1,9 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
-import {
- MultiColumnList,
- Pane,
-} from '@folio/stripes/components';
-import {
- AppIcon,
-} from '@folio/stripes/core';
+import { MultiColumnList } from '@folio/stripes/components';
-import {
- AuthorityShape,
-} from '../../constants/shapes';
+import { AuthorityShape } from '../../constants/shapes';
const propTypes = {
authorities: PropTypes.arrayOf(AuthorityShape).isRequired,
@@ -60,26 +52,19 @@ const SearchResultsList = ({
];
return (
- }
- defaultWidth="fill"
- paneTitle={}
- >
-
-
+
);
};
diff --git a/src/components/search-results-list/search-results-test.test.js b/src/components/SearchResultsList/SearchResultsList.test.js
similarity index 92%
rename from src/components/search-results-list/search-results-test.test.js
rename to src/components/SearchResultsList/SearchResultsList.test.js
index f1a3dc13..25859eeb 100644
--- a/src/components/search-results-list/search-results-test.test.js
+++ b/src/components/SearchResultsList/SearchResultsList.test.js
@@ -4,7 +4,7 @@ import {
import noop from 'lodash/noop';
import Harness from '../../../test/jest/helpers/harness';
-import SearchResultsList from './search-results-list';
+import SearchResultsList from './SearchResultsList';
import authorities from '../../../mocks/authorities.json';
const renderSearchResultsList = (props = {}) => render(
diff --git a/src/components/SearchResultsList/index.js b/src/components/SearchResultsList/index.js
new file mode 100644
index 00000000..7d4a51e2
--- /dev/null
+++ b/src/components/SearchResultsList/index.js
@@ -0,0 +1 @@
+export { default as SearchResultsList } from './SearchResultsList';
diff --git a/src/components/SearchTextareaField/SearchTextareaField.css b/src/components/SearchTextareaField/SearchTextareaField.css
new file mode 100644
index 00000000..ec5d5470
--- /dev/null
+++ b/src/components/SearchTextareaField/SearchTextareaField.css
@@ -0,0 +1,9 @@
+.searchFieldWrap .select {
+ /* To make sure it's position is above
+ so the focus style isn't hidden by the input */
+ margin-bottom: -1px;
+
+ &:focus {
+ z-index: 5;
+ }
+}
diff --git a/src/components/SearchTextareaField/SearchTextareaField.js b/src/components/SearchTextareaField/SearchTextareaField.js
new file mode 100644
index 00000000..e8b12acd
--- /dev/null
+++ b/src/components/SearchTextareaField/SearchTextareaField.js
@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useIntl } from 'react-intl';
+
+import {
+ Select,
+ TextArea,
+} from '@folio/stripes/components';
+
+import css from './SearchTextareaField.css';
+
+const propTypes = {
+ className: PropTypes.string,
+ disabled: PropTypes.bool,
+ id: PropTypes.string.isRequired,
+ loading: PropTypes.bool,
+ onChange: PropTypes.func,
+ onChangeIndex: PropTypes.func,
+ searchableIndexes: PropTypes.arrayOf(PropTypes.shape({
+ label: PropTypes.string,
+ value: PropTypes.string,
+ })).isRequired,
+ selectedIndex: PropTypes.string,
+ value: PropTypes.string,
+};
+
+const SearchTextareaField = ({
+ className,
+ id,
+ value,
+ onChange,
+ loading,
+ searchableIndexes,
+ onChangeIndex,
+ selectedIndex,
+ disabled,
+ ...rest
+}) => {
+ const intl = useIntl();
+
+ const indexLabel = intl.formatMessage({ id: 'stripes-components.searchFieldIndex' });
+
+ const rootStyles = classNames(
+ css.searchFieldWrap,
+ className,
+ );
+
+ return (
+
+
+
+
+ );
+};
+
+SearchTextareaField.propTypes = propTypes;
+SearchTextareaField.defaultProps = {
+ disabled: false,
+ loading: false,
+};
+
+export default SearchTextareaField;
diff --git a/src/components/SearchTextareaField/SearchTextareaField.test.js b/src/components/SearchTextareaField/SearchTextareaField.test.js
new file mode 100644
index 00000000..e196d4b4
--- /dev/null
+++ b/src/components/SearchTextareaField/SearchTextareaField.test.js
@@ -0,0 +1,54 @@
+import {
+ render,
+ fireEvent,
+} from '@testing-library/react';
+
+import SearchTextareaField from './SearchTextareaField';
+
+jest.mock('@folio/stripes/components', () => ({
+ ...jest.requireActual('@folio/stripes/components'),
+ Select: () => Select component
,
+}));
+
+const searchableIndexes = [{
+ label: 'test-label-1',
+ value: 'test-value-1',
+}, {
+ label: 'test-label-2',
+ value: 'test-value-2',
+}];
+
+const onChange = jest.fn();
+
+const renderSearchTextareaField = (props = {}) => render(
+ ,
+);
+
+describe('Given SearchTextareaField', () => {
+ it('should render Select component', () => {
+ const { getByText } = renderSearchTextareaField();
+
+ expect(getByText('Select component')).toBeDefined();
+ });
+
+ it('should render textarea', () => {
+ const { getByTestId } = renderSearchTextareaField();
+
+ expect(getByTestId('search-textarea')).toBeDefined();
+ });
+
+ describe('when typing inside textarea', () => {
+ it('should handle onChange', () => {
+ const { getByTestId } = renderSearchTextareaField();
+
+ fireEvent.change(getByTestId('search-textarea'), { target: { value: 'test' } });
+
+ expect(onChange).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/SearchTextareaField/index.js b/src/components/SearchTextareaField/index.js
new file mode 100644
index 00000000..325ed10c
--- /dev/null
+++ b/src/components/SearchTextareaField/index.js
@@ -0,0 +1 @@
+export { default as SearchTextareaField } from './SearchTextareaField';
diff --git a/src/components/index.js b/src/components/index.js
index 1c2ba4e9..40174ba8 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -1 +1,2 @@
-export * from './search-results-list';
+export * from './SearchResultsList';
+export * from './SearchTextareaField';
diff --git a/src/components/search-results-list/index.js b/src/components/search-results-list/index.js
deleted file mode 100644
index 8dc5dcd5..00000000
--- a/src/components/search-results-list/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default as SearchResultsList } from './search-results-list';
diff --git a/src/constants/index.js b/src/constants/index.js
new file mode 100644
index 00000000..6994aae6
--- /dev/null
+++ b/src/constants/index.js
@@ -0,0 +1,3 @@
+export * from './searchableIndexesValues';
+export * from './rawSearchableIndexes';
+export * from './searchableIndexesMap';
diff --git a/src/constants/rawSearchableIndexes.js b/src/constants/rawSearchableIndexes.js
new file mode 100644
index 00000000..f4090a25
--- /dev/null
+++ b/src/constants/rawSearchableIndexes.js
@@ -0,0 +1,15 @@
+import { searchableIndexesValues } from './searchableIndexesValues';
+
+export const rawSearchableIndexes = [
+ { label: 'ui-marc-authorities.keyword', value: '' },
+ { label: 'ui-marc-authorities.identifier', value: searchableIndexesValues.IDENTIFIER },
+ { label: 'ui-marc-authorities.personalName', value: searchableIndexesValues.PERSONAL_NAME },
+ { label: 'ui-marc-authorities.corporateConferenceName', value: searchableIndexesValues.CORPORATE_CONFERENCE_NAME },
+ { label: 'ui-marc-authorities.geographicName', value: searchableIndexesValues.GEOGRAPHIC_NAME },
+ { label: 'ui-marc-authorities.nameTitle', value: searchableIndexesValues.NAME_TITLE },
+ { label: 'ui-marc-authorities.uniformTitle', value: searchableIndexesValues.UNIFORM_TITLE },
+ { label: 'ui-marc-authorities.subject', value: searchableIndexesValues.SUBJECT },
+ { label: 'ui-marc-authorities.childrenSubjectHeading', value: searchableIndexesValues.CHILDREN_SUBJECT_HEADING },
+ { label: 'ui-marc-authorities.genre', value: searchableIndexesValues.GENRE },
+ { label: 'ui-marc-authorities.authorityUUID', value: searchableIndexesValues.AUTHORITY_UUID },
+];
diff --git a/src/constants/searchableIndexesMap.js b/src/constants/searchableIndexesMap.js
new file mode 100644
index 00000000..0a7cc53a
--- /dev/null
+++ b/src/constants/searchableIndexesMap.js
@@ -0,0 +1,71 @@
+import { searchableIndexesValues } from './searchableIndexesValues';
+
+export const searchableIndexesMap = {
+ [searchableIndexesValues.KEYWORD]: [{
+ name: 'keyword',
+ plain: true,
+ }],
+ [searchableIndexesValues.IDENTIFIER]: [{
+ name: 'identifier',
+ plain: true,
+ }],
+ [searchableIndexesValues.PERSONAL_NAME]: [{
+ name: 'personalName',
+ plain: true,
+ sft: true,
+ saft: true,
+ }],
+ [searchableIndexesValues.CORPORATE_CONFERENCE_NAME]: [{
+ name: 'corporateName',
+ plain: true,
+ sft: true,
+ saft: true,
+ }, {
+ name: 'meetingName',
+ plain: true,
+ sft: true,
+ saft: true,
+ }],
+ [searchableIndexesValues.GEOGRAPHIC_NAME]: [{
+ name: 'geographicName',
+ plain: true,
+ }, {
+ name: 'sftGeographicTerm',
+ sft: true,
+ }, {
+ name: 'saftGeographicTerm',
+ saft: true,
+ }],
+ [searchableIndexesValues.NAME_TITLE]: [{
+ name: 'personalNameTitle',
+ plain: true,
+ sft: true,
+ saft: true,
+ }],
+ [searchableIndexesValues.UNIFORM_TITLE]: [{
+ name: 'uniformTitle',
+ plain: true,
+ sft: true,
+ saft: true,
+ }],
+ [searchableIndexesValues.SUBJECT]: [{
+ name: 'topicalTerm',
+ plain: true,
+ sft: true,
+ saft: true,
+ }],
+ [searchableIndexesValues.CHILDREN_SUBJECT_HEADING]: [{
+ name: 'subjectHeadings',
+ plain: true,
+ }],
+ [searchableIndexesValues.GENRE]: [{
+ name: 'genreTerm',
+ plain: true,
+ sft: true,
+ saft: true,
+ }],
+ [searchableIndexesValues.AUTHORITY_UUID]: [{
+ name: 'id',
+ plain: true,
+ }],
+};
diff --git a/src/constants/searchableIndexesValues.js b/src/constants/searchableIndexesValues.js
new file mode 100644
index 00000000..6251580d
--- /dev/null
+++ b/src/constants/searchableIndexesValues.js
@@ -0,0 +1,13 @@
+export const searchableIndexesValues = {
+ KEYWORD: 'keyword',
+ IDENTIFIER: 'identifier',
+ PERSONAL_NAME: 'personalName',
+ CORPORATE_CONFERENCE_NAME: 'corporateConferenceName',
+ GEOGRAPHIC_NAME: 'geographicName',
+ NAME_TITLE: 'nameTitle',
+ UNIFORM_TITLE: 'uniformTitle',
+ SUBJECT: 'subject',
+ CHILDREN_SUBJECT_HEADING: 'childrenSubjectHeading',
+ GENRE: 'genre',
+ AUTHORITY_UUID: 'authorityUUID',
+};
diff --git a/src/constants/shapes/authority-shape.js b/src/constants/shapes/authority.js
similarity index 100%
rename from src/constants/shapes/authority-shape.js
rename to src/constants/shapes/authority.js
diff --git a/src/constants/shapes/index.js b/src/constants/shapes/index.js
index acb24656..19e851b0 100644
--- a/src/constants/shapes/index.js
+++ b/src/constants/shapes/index.js
@@ -1 +1 @@
-export * from './authority-shape';
+export * from './authority';
diff --git a/src/hooks/useAuthorities/index.js b/src/hooks/useAuthorities/index.js
new file mode 100644
index 00000000..7dea8d33
--- /dev/null
+++ b/src/hooks/useAuthorities/index.js
@@ -0,0 +1 @@
+export { default as useAuthorities } from './useAuthorities';
diff --git a/src/hooks/useAuthorities/useAuthorities.js b/src/hooks/useAuthorities/useAuthorities.js
new file mode 100644
index 00000000..50ed370d
--- /dev/null
+++ b/src/hooks/useAuthorities/useAuthorities.js
@@ -0,0 +1,70 @@
+import {
+ useHistory,
+ useLocation,
+} from 'react-router-dom';
+import { useQuery } from 'react-query';
+import queryString from 'query-string';
+
+import {
+ useOkapiKy,
+ useNamespace,
+} from '@folio/stripes/core';
+
+import { template } from 'lodash';
+
+import { buildQuery } from '../utils';
+
+const AUTHORITIES_API = 'search/authorities';
+
+const useAuthorities = ({ searchQuery, searchIndex }) => {
+ const ky = useOkapiKy();
+ const [namespace] = useNamespace();
+
+ const history = useHistory();
+ const location = useLocation();
+
+ const queryParams = {
+ query: searchQuery,
+ };
+
+ const compileQuery = template(
+ buildQuery(searchIndex),
+ { interpolate: /%{([\s\S]+?)}/g },
+ );
+
+ const cqlQuery = queryParams.query?.trim().replace('*', '').split(/\s+/)
+ .map(query => compileQuery({ query }))
+ .join(' and ');
+
+ const searchParams = {
+ query: cqlQuery,
+ };
+
+ const { isFetching, data } = useQuery(
+ [namespace, searchParams],
+ async () => {
+ if (!searchQuery) {
+ return { authorities: [], totalRecords: 0 };
+ }
+
+ const locationSearchParams = queryString.parse(location.search);
+ locationSearchParams.query = searchQuery;
+ locationSearchParams.qindex = searchIndex;
+ location.search = queryString.stringify(locationSearchParams);
+ history.replace({
+ pathname: location.pathname,
+ search: location.search,
+ });
+
+ return ky.get(AUTHORITIES_API, { searchParams }).json();
+ },
+ );
+
+ return ({
+ ...data,
+ isLoading: isFetching,
+ query: cqlQuery,
+ });
+};
+
+export default useAuthorities;
diff --git a/src/hooks/useAuthorities/useAuthorities.test.js b/src/hooks/useAuthorities/useAuthorities.test.js
new file mode 100644
index 00000000..a8ee92fc
--- /dev/null
+++ b/src/hooks/useAuthorities/useAuthorities.test.js
@@ -0,0 +1,66 @@
+import {
+ QueryClient,
+ QueryClientProvider,
+} from 'react-query';
+import { renderHook } from '@testing-library/react-hooks';
+
+import routeData from 'react-router';
+
+import { createMemoryHistory } from 'history';
+
+import '../../../test/jest/__mock__';
+
+import { useOkapiKy } from '@folio/stripes/core';
+
+import Harness from '../../../test/jest/helpers/harness';
+import useAuthorities from './useAuthorities';
+
+const history = createMemoryHistory();
+
+const queryClient = new QueryClient();
+
+const wrapper = ({ children }) => (
+
+
+ {children}
+
+
+);
+
+describe('Given useAuthorities', () => {
+ const mockGet = jest.fn(() => ({
+ json: () => Promise.resolve({
+ authorities: [],
+ totalRecords: 0,
+ }),
+ }));
+
+ beforeEach(() => {
+ useOkapiKy.mockClear().mockReturnValue({
+ get: mockGet,
+ });
+
+ jest.spyOn(routeData, 'useLocation').mockReturnValue({
+ pathname: 'pathname',
+ search: '',
+ });
+ });
+
+ it('fetches authorities records', async () => {
+ const searchQuery = 'test';
+ const searchIndex = 'identifier';
+
+ const { result, waitFor } = renderHook(() => useAuthorities({ searchQuery, searchIndex }), { wrapper });
+
+ await waitFor(() => !result.current.isLoading);
+
+ expect(mockGet).toHaveBeenCalledWith(
+ 'search/authorities',
+ {
+ searchParams: {
+ query: '(identifier=="test*")',
+ },
+ },
+ );
+ });
+});
diff --git a/src/hooks/utils/buildQuery.js b/src/hooks/utils/buildQuery.js
new file mode 100644
index 00000000..4acef517
--- /dev/null
+++ b/src/hooks/utils/buildQuery.js
@@ -0,0 +1,59 @@
+import {
+ searchableIndexesValues,
+ searchableIndexesMap,
+} from '../../constants';
+
+const buildQuery = (searchIndex) => {
+ const indexData = searchableIndexesMap[searchIndex || searchableIndexesValues.KEYWORD];
+
+ const queryStrings = indexData.map(data => {
+ const queryParts = [];
+
+ const queryTemplate = (name, prefix) => `${name}=="${prefix ? prefix + ' ' : ''}%{query}*"`;
+
+ const capitalizeFirstLetter = ([first, ...rest]) => first.toUpperCase() + rest.join('');
+
+ if (data.plain) {
+ const query = queryTemplate(data.name);
+
+ queryParts.push(query);
+ }
+
+ if ((data.sft || data.saft) && data.plain) {
+ const name = capitalizeFirstLetter(data.name);
+
+ if (data.sft) {
+ const query = queryTemplate(`sft${name}`, 'sft');
+
+ queryParts.push(query);
+ }
+
+ if (data.saft) {
+ const query = queryTemplate(`saft${name}`, 'saft');
+
+ queryParts.push(query);
+ }
+ }
+
+ if (data.sft && !data.plain) {
+ const query = queryTemplate(data.name, 'sft');
+
+ queryParts.push(query);
+ }
+
+ if (data.saft && !data.plain) {
+ const query = queryTemplate(data.name, 'saft');
+
+ queryParts.push(query);
+ }
+
+ return queryParts;
+ });
+
+ const flattenedQueryStrings = queryStrings.reduce((acc, arr) => acc.concat(arr));
+ const joinedQueryParts = flattenedQueryStrings.join(' or ');
+
+ return `(${joinedQueryParts})`;
+};
+
+export default buildQuery;
diff --git a/src/hooks/utils/buildQuery.test.js b/src/hooks/utils/buildQuery.test.js
new file mode 100644
index 00000000..989c1809
--- /dev/null
+++ b/src/hooks/utils/buildQuery.test.js
@@ -0,0 +1,36 @@
+import buildQuery from './buildQuery';
+import { searchableIndexesValues } from '../../constants';
+
+describe('Given buildQuery', () => {
+ describe('when index without any prefix provided', () => {
+ it('should return correct query', () => {
+ const query = buildQuery(searchableIndexesValues.IDENTIFIER);
+
+ expect(query).toBe('(identifier=="%{query}*")');
+ });
+ });
+
+ describe('when index with plain, sft, and saft prefixes provided', () => {
+ it('should return correct query', () => {
+ const query = buildQuery(searchableIndexesValues.PERSONAL_NAME);
+
+ expect(query).toBe('(personalName=="%{query}*" or sftPersonalName=="sft %{query}*" or saftPersonalName=="saft %{query}*")');
+ });
+ });
+
+ describe('when index with different names for plain, sft, and saft prefixes provided', () => {
+ it('should return correct query', () => {
+ const query = buildQuery(searchableIndexesValues.GEOGRAPHIC_NAME);
+
+ expect(query).toBe('(geographicName=="%{query}*" or sftGeographicTerm=="sft %{query}*" or saftGeographicTerm=="saft %{query}*")');
+ });
+ });
+
+ describe('when index keyword index provided', () => {
+ it('should return correct query for keyword index', () => {
+ const query = buildQuery('');
+
+ expect(query).toBe('(keyword=="%{query}*")');
+ });
+ });
+});
diff --git a/src/hooks/utils/index.js b/src/hooks/utils/index.js
new file mode 100644
index 00000000..60ccef8d
--- /dev/null
+++ b/src/hooks/utils/index.js
@@ -0,0 +1 @@
+export { default as buildQuery } from './buildQuery';
diff --git a/src/index.js b/src/index.js
index 713c5c00..11f50488 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,9 +2,11 @@ import 'core-js/stable';
import 'regenerator-runtime/runtime';
import PropTypes from 'prop-types';
-import Switch from 'react-router-dom/Switch';
-import Route from 'react-router-dom/Route';
-import { Search } from './routes/search';
+import {
+ Route,
+ Switch,
+} from 'react-router-dom';
+import SearchRoute from './routes/SearchRoute';
const propTypes = {
match: PropTypes.object.isRequired,
@@ -23,7 +25,7 @@ const MarcAuthorities = ({
);
diff --git a/src/routes/SearchRoute.js b/src/routes/SearchRoute.js
new file mode 100644
index 00000000..a51bc732
--- /dev/null
+++ b/src/routes/SearchRoute.js
@@ -0,0 +1,9 @@
+import { AuthoritiesSearch } from '../views';
+
+const SearchRoute = () => {
+ return (
+
+ );
+};
+
+export default SearchRoute;
diff --git a/src/routes/search.js b/src/routes/search.js
deleted file mode 100644
index 86e28f39..00000000
--- a/src/routes/search.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useState } from 'react';
-
-import {
- SearchResultsList,
-} from '../components';
-
-import authoritiesMock from '../../mocks/authorities.json';
-
-const propTypes = {};
-
-const Search = () => {
- const pageSize = 15;
- const [authorities] = useState(authoritiesMock);
-
- const onFetchNextPage = () => {};
-
- return (
-
- );
-};
-
-Search.propTypes = propTypes;
-
-export { Search };
diff --git a/src/views/AuthoritiesSearch/AuthoritiesSearch.css b/src/views/AuthoritiesSearch/AuthoritiesSearch.css
new file mode 100644
index 00000000..7e0774be
--- /dev/null
+++ b/src/views/AuthoritiesSearch/AuthoritiesSearch.css
@@ -0,0 +1,9 @@
+@import "@folio/stripes-components/lib/variables";
+
+.searchGroupWrap {
+ margin-bottom: calc(var(--control-margin-bottom) / 2);
+}
+
+.searchField {
+ margin-bottom: calc(var(--control-margin-bottom) / -2);
+}
diff --git a/src/views/AuthoritiesSearch/AuthoritiesSearch.js b/src/views/AuthoritiesSearch/AuthoritiesSearch.js
new file mode 100644
index 00000000..f6b9e3af
--- /dev/null
+++ b/src/views/AuthoritiesSearch/AuthoritiesSearch.js
@@ -0,0 +1,204 @@
+import {
+ useState,
+ useEffect,
+} from 'react';
+import {
+ useHistory,
+ useLocation,
+} from 'react-router-dom';
+import { useIntl } from 'react-intl';
+import queryString from 'query-string';
+import {
+ useLocalStorage,
+ writeStorage,
+} from '@rehooks/local-storage';
+
+import {
+ Button,
+ Icon,
+ Pane,
+ PaneMenu,
+} from '@folio/stripes/components';
+
+import {
+ CollapseFilterPaneButton,
+ ExpandFilterPaneButton,
+ PersistedPaneset,
+} from '@folio/stripes/smart-components';
+
+import {
+ AppIcon,
+ useNamespace,
+} from '@folio/stripes/core';
+
+import {
+ SearchTextareaField,
+ SearchResultsList,
+} from '../../components';
+import { useAuthorities } from '../../hooks/useAuthorities';
+import { rawSearchableIndexes } from '../../constants';
+
+import css from './AuthoritiesSearch.css';
+
+const AuthoritiesSearch = () => {
+ const intl = useIntl();
+ const [, getNamespace] = useNamespace();
+
+ const history = useHistory();
+ const location = useLocation();
+
+ const filterPaneVisibilityKey = getNamespace({ key: 'marcAuthoritiesFilterPaneVisibility' });
+
+ const [searchInputValue, setSearchInputValue] = useState('');
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const [searchDropdownValue, setSearchDropdownValue] = useState('');
+ const [searchIndex, setSearchIndex] = useState('');
+
+ useEffect(() => {
+ const locationSearchParams = queryString.parse(location.search);
+
+ if (Object.keys(locationSearchParams).length > 0) {
+ if (locationSearchParams.query && locationSearchParams.query !== searchQuery) {
+ setSearchInputValue(locationSearchParams.query);
+ setSearchQuery(locationSearchParams.query);
+ }
+
+ if (locationSearchParams.qindex && locationSearchParams.qindex !== searchIndex) {
+ setSearchDropdownValue(locationSearchParams.qindex);
+ setSearchIndex(locationSearchParams.qindex);
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const { authorities, isLoading, totalRecords } = useAuthorities({ searchQuery, searchIndex });
+
+ const [storedFilterPaneVisibility] = useLocalStorage(filterPaneVisibilityKey, true);
+ const [isFilterPaneVisible, setIsFilterPaneVisible] = useState(storedFilterPaneVisibility);
+
+ const toggleFilterPane = () => {
+ setIsFilterPaneVisible(!isFilterPaneVisible);
+ writeStorage(filterPaneVisibilityKey, !isFilterPaneVisible);
+ };
+
+ const onChangeIndex = (value) => setSearchDropdownValue(value);
+
+ const onSubmitSearch = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ setSearchQuery(searchInputValue);
+ setSearchIndex(searchDropdownValue);
+ };
+
+ const updateSearchValue = (value) => setSearchInputValue(value);
+
+ const resetAll = () => {
+ setSearchInputValue('');
+ setSearchDropdownValue('');
+
+ history.replace({
+ pathname: location.pathname,
+ });
+ };
+
+ const pageSize = 15;
+
+ const onFetchNextPage = () => {};
+
+ const renderResultsFirstMenu = () => {
+ if (isFilterPaneVisible) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ };
+
+ const searchableIndexes = rawSearchableIndexes.map(index => ({
+ label: intl.formatMessage({ id: index.label }),
+ value: index.value,
+ }));
+
+ return (
+
+ {isFilterPaneVisible &&
+
+
+
+ )}
+ >
+
+
+ }
+ }
+ defaultWidth="fill"
+ paneTitle={intl.formatMessage({ id: 'ui-marc-authorities.meta.title' })}
+ firstMenu={renderResultsFirstMenu()}
+ >
+
+
+
+ );
+};
+
+export default AuthoritiesSearch;
diff --git a/src/views/AuthoritiesSearch/AuthoritiesSearch.test.js b/src/views/AuthoritiesSearch/AuthoritiesSearch.test.js
new file mode 100644
index 00000000..e5a930c8
--- /dev/null
+++ b/src/views/AuthoritiesSearch/AuthoritiesSearch.test.js
@@ -0,0 +1,181 @@
+import {
+ act,
+ waitFor,
+ render,
+ fireEvent,
+} from '@testing-library/react';
+
+import routeData from 'react-router';
+
+import { createMemoryHistory } from 'history';
+
+import '../../../test/jest/__mock__';
+
+import Harness from '../../../test/jest/helpers/harness';
+import AuthoritiesSearch from './AuthoritiesSearch';
+import { searchableIndexesValues } from '../../constants';
+
+const history = createMemoryHistory();
+const historyReplaceSpy = jest.spyOn(history, 'replace');
+
+jest.mock('../../hooks/useAuthorities', () => ({
+ useAuthorities: () => ({ authorities: [] }),
+}));
+
+const renderAuthoritiesSearch = (props = {}) => render(
+
+
+ ,
+);
+
+describe('Given AuthoritiesSearch', () => {
+ it('should render paneset', () => {
+ const { getByTestId } = renderAuthoritiesSearch();
+
+ expect(getByTestId('marc-authorities-paneset')).toBeDefined();
+ });
+
+ it('should display `Search & filter` label', () => {
+ const { getByText } = renderAuthoritiesSearch();
+
+ expect(getByText('ui-marc-authorities.search.searchAndFilter')).toBeDefined();
+ });
+
+ it('should display dropdown with searchable indexes', () => {
+ const { getByText } = renderAuthoritiesSearch();
+
+ Object.values(searchableIndexesValues).forEach(indexValue => {
+ expect(getByText(`ui-marc-authorities.${indexValue}`)).toBeDefined();
+ });
+ });
+
+ it('should display textarea', () => {
+ const { getByTestId } = renderAuthoritiesSearch();
+
+ expect(getByTestId('search-textarea')).toBeDefined();
+ });
+
+ it('should display Search button', () => {
+ const { getByRole } = renderAuthoritiesSearch();
+
+ expect(getByRole('button', { name: 'ui-marc-authorities.label.search' })).toBeDefined();
+ });
+
+ it('should display Reset all button', () => {
+ const { getByRole } = renderAuthoritiesSearch();
+
+ expect(getByRole('button', { name: 'stripes-smart-components.resetAll' })).toBeDefined();
+ });
+
+ describe('when textarea is not empty and Reset all button is clicked', () => {
+ it('should clear textarea', () => {
+ const {
+ getByRole,
+ getByTestId,
+ } = renderAuthoritiesSearch();
+
+ const textarea = getByTestId('search-textarea');
+ const resetAllButton = getByRole('button', { name: 'stripes-smart-components.resetAll' });
+
+ fireEvent.change(textarea, { target: { value: 'test search' } });
+
+ expect(textarea.value).toBe('test search');
+
+ fireEvent.click(resetAllButton);
+
+ expect(textarea.value).toBe('');
+ });
+
+ it('should handle history replace', () => {
+ const {
+ getByRole,
+ getByTestId,
+ } = renderAuthoritiesSearch();
+
+ const textarea = getByTestId('search-textarea');
+ const resetAllButton = getByRole('button', { name: 'stripes-smart-components.resetAll' });
+
+ fireEvent.change(textarea, { target: { value: 'test search' } });
+
+ expect(textarea.value).toBe('test search');
+
+ fireEvent.click(resetAllButton);
+
+ expect(historyReplaceSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('when click on toggle filter pane button', () => {
+ describe('when filters were shown', () => {
+ it('should hide filters', async () => {
+ jest.spyOn(routeData, 'useLocation').mockReturnValue({
+ pathname: 'pathname',
+ search: '?qindex=test',
+ });
+
+ let getByRoleFunction;
+ let getByTestIdFunction;
+ let queryByTestIdFunction;
+
+ await act(async () => {
+ const {
+ getByRole,
+ getByTestId,
+ queryByTestId,
+ } = await renderAuthoritiesSearch();
+
+ getByRoleFunction = getByRole;
+ getByTestIdFunction = getByTestId;
+ queryByTestIdFunction = queryByTestId;
+ });
+
+ const filterPaneTestId = 'pane-authorities-filters';
+ const hideFilterPaneButton = getByRoleFunction('button', { name: 'stripes-smart-components.hideSearchPane' });
+
+ expect(getByTestIdFunction(filterPaneTestId)).toBeDefined();
+
+ fireEvent.click(hideFilterPaneButton);
+
+ await waitFor(() => {
+ expect(queryByTestIdFunction(filterPaneTestId)).toBeNull();
+ });
+ });
+ });
+
+ describe('when filters were hidden', () => {
+ it('should show filters', async () => {
+ jest.spyOn(routeData, 'useLocation').mockReturnValue({
+ pathname: 'pathname',
+ search: '?query=test',
+ });
+
+ let getByRoleFunction;
+ let getByTestIdFunction;
+ let queryByTestIdFunction;
+
+ await act(async () => {
+ const {
+ getByRole,
+ getByTestId,
+ queryByTestId,
+ } = await renderAuthoritiesSearch();
+
+ getByRoleFunction = getByRole;
+ getByTestIdFunction = getByTestId;
+ queryByTestIdFunction = queryByTestId;
+ });
+
+ const filterPaneTestId = 'pane-authorities-filters';
+ const showFilterPaneButton = getByRoleFunction('button', { name: 'stripes-smart-components.showSearchPane' });
+
+ expect(queryByTestIdFunction(filterPaneTestId)).toBeNull();
+
+ fireEvent.click(showFilterPaneButton);
+
+ await waitFor(() => {
+ expect(getByTestIdFunction(filterPaneTestId)).toBeDefined();
+ });
+ });
+ });
+ });
+});
diff --git a/src/views/AuthoritiesSearch/index.js b/src/views/AuthoritiesSearch/index.js
new file mode 100644
index 00000000..861e88b9
--- /dev/null
+++ b/src/views/AuthoritiesSearch/index.js
@@ -0,0 +1 @@
+export { default as AuthoritiesSearch } from './AuthoritiesSearch';
diff --git a/src/views/index.js b/src/views/index.js
new file mode 100644
index 00000000..651cdd73
--- /dev/null
+++ b/src/views/index.js
@@ -0,0 +1 @@
+export * from './AuthoritiesSearch';
diff --git a/test/jest/__mock__/stripesCore.mock.js b/test/jest/__mock__/stripesCore.mock.js
index de63d8c4..eb3c2f0b 100644
--- a/test/jest/__mock__/stripesCore.mock.js
+++ b/test/jest/__mock__/stripesCore.mock.js
@@ -72,6 +72,10 @@ jest.mock('@folio/stripes/core', () => {
return ;
};
+ const useOkapiKy = jest.fn();
+
+ const useNamespace = () => ['@folio/marc-authorities', jest.fn()];
+
// eslint-disable-next-line react/prop-types
const withStripes = (Component) => ({ stripes, ...rest }) => {
const fakeStripes = stripes || STRIPES;
@@ -92,6 +96,8 @@ jest.mock('@folio/stripes/core', () => {
withStripes,
IfPermission,
AppContextMenu,
+ useOkapiKy,
+ useNamespace,
};
}, { virtual: true });
diff --git a/translations/ui-marc-authorities/en.json b/translations/ui-marc-authorities/en.json
index 6970a1dc..92a578de 100644
--- a/translations/ui-marc-authorities/en.json
+++ b/translations/ui-marc-authorities/en.json
@@ -7,5 +7,18 @@
"permission.app.enabled": "UI: MARC Authorities module is enabled",
"permission.authority-record.view": "View MARC authority record",
- "permission.authority-record.edit": "Edit MARC authority record"
+ "permission.authority-record.edit": "Edit MARC authority record",
+ "label.search": "Search",
+ "search.searchAndFilter": "Search & filter",
+ "keyword": "Keyword",
+ "identifier": "Identifier (all)",
+ "personalName": "Personal name",
+ "corporateConferenceName": "Corporate/Conference name",
+ "geographicName": "Geographic name",
+ "nameTitle": "Name-title",
+ "uniformTitle": "Uniform title",
+ "subject": "Subject",
+ "childrenSubjectHeading": "Children's subject heading",
+ "genre": "Genre",
+ "authorityUUID": "Authority UUID"
}