Repository: folio-org/stripes-components
Pull request #2456: STCOM-1424: Use empty rows state for `isPristine` and for resetting rows state; add clear icon for inputs; disable search button (follow-up PR).
Author: login: Dmytro-Melnyshyn
Created: 2025-04-04T10:14:36Z | Merged: 2025-04-04T15:30:06Z

## Purpose
1. The `Reset all` and `Search` buttons should be disabled with no values/selections in the advanced search modal.
2. A clear icon should be added for text fields.

## Issues
[STCOM-1424](https://folio-org.atlassian.net/browse/STCOM-1424)

## Screencasts

https://github.com/user-attachments/assets/0de86b8f-8736-4bfb-b153-6cf08f86ace1




Diff:
diff --git a/lib/AdvancedSearch/components/AdvancedSearchModal/AdvancedSearchModal.js b/lib/AdvancedSearch/components/AdvancedSearchModal/AdvancedSearchModal.js
index 1601b07b1..a1f3257a7 100644
--- a/lib/AdvancedSearch/components/AdvancedSearchModal/AdvancedSearchModal.js
+++ b/lib/AdvancedSearch/components/AdvancedSearchModal/AdvancedSearchModal.js
@@ -44,6 +44,7 @@ const AdvancedSearchModal = ({
           onClick={onSearch}
           marginBottom0
           aria-label={searchLabel}
+          disabled={isPristine}
         >
           {searchLabel}
         </Button>
@@ -55,7 +56,7 @@ const AdvancedSearchModal = ({
           disabled={isPristine}
           buttonStyle="none"
         >
-          <Icon 
+          <Icon
             size="small"
             icon="times-circle-solid"
           >
@@ -71,8 +72,12 @@ const AdvancedSearchModal = ({
   }), []);
 
   const handlers = useMemo(() => ({
-    search: onSearch,
-  }), [onSearch]);
+    search: (e) => {
+      if (!isPristine) {
+        onSearch(e);
+      }
+    },
+  }), [onSearch, isPristine]);
 
   return (
     <Modal
diff --git a/lib/AdvancedSearch/components/AdvancedSearchRow/AdvancedSearchRow.js b/lib/AdvancedSearch/components/AdvancedSearchRow/AdvancedSearchRow.js
index 52fee2f69..b01308262 100644
--- a/lib/AdvancedSearch/components/AdvancedSearchRow/AdvancedSearchRow.js
+++ b/lib/AdvancedSearch/components/AdvancedSearchRow/AdvancedSearchRow.js
@@ -89,6 +89,7 @@ const AdvancedSearchRow = ({
         <Col xs>
           <TextArea
             isCursorAtEnd
+            hasClearIcon
             rows={TEXTAREA_HEIGHT}
             data-test-advanced-search-query
             aria-label={intl.formatMessage({ id: 'stripes-components.advancedSearch.fieldLabel' })}
diff --git a/lib/AdvancedSearch/hooks/useAdvancedSearch/useAdvancedSearch.js b/lib/AdvancedSearch/hooks/useAdvancedSearch/useAdvancedSearch.js
index bd7d5397f..66c50b51c 100644
--- a/lib/AdvancedSearch/hooks/useAdvancedSearch/useAdvancedSearch.js
+++ b/lib/AdvancedSearch/hooks/useAdvancedSearch/useAdvancedSearch.js
@@ -5,6 +5,7 @@ import {
 } from 'react';
 import isEqual from 'lodash/isEqual';
 import noop from 'lodash/noop';
+import cloneDeep from 'lodash/cloneDeep';
 import { useIntl } from 'react-intl';
 
 import useActiveRow from '../useActiveQuery';
@@ -23,7 +24,9 @@ import {
 
 const createInitialRowState = (firstRowInitialSearch, defaultSearchOptionValue) => {
   return new Array(ROW_COUNT).fill(null).map((_, index) => ({
-    [FIELD_NAMES.BOOL]: BOOLEAN_OPERATORS.AND,
+    [FIELD_NAMES.BOOL]: index === 0
+      ? ''
+      : BOOLEAN_OPERATORS.AND,
     [FIELD_NAMES.QUERY]: index === 0
       ? firstRowInitialSearch?.query || ''
       : '',
@@ -63,6 +66,8 @@ const useAdvancedSearch = ({
   hasQueryOption,
   open,
 }) => {
+  const emptyRowsState = useMemo(() => createInitialRowState(null, defaultSearchOptionValue), [defaultSearchOptionValue]);
+
   const initialRowState = useMemo(() => {
     const initialRows = createInitialRowState(firstRowInitialSearch, defaultSearchOptionValue);
     return splitQueryRows(initialRows, queryToRow);
@@ -79,7 +84,7 @@ const useAdvancedSearch = ({
     return filterFilledRows(rows)
   }, [rowState, queryToRow, searchOptions, defaultSearchOptionValue, hasQueryOption, open]);
 
-  const isPristine = useMemo(() => isEqual(rowState, initialRowState), [rowState, initialRowState]);
+  const isPristine = useMemo(() => isEqual(rowState, emptyRowsState), [rowState, emptyRowsState]);
 
   const searchOptionsWithQuery = useMemo(() => (
     [{
@@ -97,12 +102,12 @@ const useAdvancedSearch = ({
 
   const resetRows = () => {
     resetActiveRow();
-    setRowState(createInitialRowState(firstRowInitialSearch, defaultSearchOptionValue));
+    setRowState(emptyRowsState);
   };
 
   const onChange = (rowIndex, key, value) => {
     setRowState((currentRowState) => {
-      const newRowState = [...currentRowState];
+      const newRowState = cloneDeep(currentRowState);
 
       newRowState[rowIndex][key] = value;
       changeActiveRow(rowIndex, key, newRowState);
@@ -129,6 +134,7 @@ const useAdvancedSearch = ({
 
   // if the firstRowInitialSearch is updated, go ahead and update state
   // before the state is committed to the DOM...
+  // (sync the changed value of the external search field with the fields of the advanced search modal window)
   if (!isEqual(firstRowInitialSearch, prevFirstRowInitialSearch)) {
     const splitRows = splitQueryRows(initialRowState, queryToRow);
     setPrevFirstRowInitialSearch(firstRowInitialSearch);
diff --git a/lib/AdvancedSearch/tests/AdvancedSearch-test.js b/lib/AdvancedSearch/tests/AdvancedSearch-test.js
index 9be5a6a83..817a0b72b 100644
--- a/lib/AdvancedSearch/tests/AdvancedSearch-test.js
+++ b/lib/AdvancedSearch/tests/AdvancedSearch-test.js
@@ -16,6 +16,7 @@ import {
   AdvancedSearch as Interactor,
   AdvancedSearchRow as RowInteractor,
   Button as ButtonInteractor,
+  including,
 } from '@folio/stripes-testing';
 
 import { mountWithContext } from '../../../tests/helpers';
@@ -23,17 +24,30 @@ import { mountWithContext } from '../../../tests/helpers';
 import AdvancedSearch from '../AdvancedSearch';
 import {
   BOOLEAN_OPERATORS,
+  DEFAULT_SEARCH_OPTION,
   MATCH_OPTIONS,
 } from '../constants';
 import Button from '../../Button';
 
-const Harness = (props) => {
+const Harness = ({
+  open = false,
+  onCancel,
+  ...props
+}) => {
   const [initState, setInitState] = useState({ query: 'try', option: 'keyword' });
+  const [isOpen, setIsOpen] = useState(open);
+
   return (
     <>
       <Button onClick={() => { setInitState({ query: 'test', option: 'keyword' }) }}>Reset</Button>
+      <Button onClick={() => setIsOpen(true)}>Advanced search</Button>
       <AdvancedSearch
+        open={isOpen}
         firstRowInitialSearch={initState}
+        onCancel={() => {
+          setIsOpen(false);
+          onCancel?.();
+        }}
         {...props}
       >
         {({ resetRows }) => (
@@ -56,6 +70,8 @@ describe('AdvancedSearch', () => {
   const resetButton = ButtonInteractor('Reset');
   const searchButton = ButtonInteractor('Search');
   const resetAllButton = ButtonInteractor('Reset all');
+  const advancedSearchButton = ButtonInteractor('Advanced search');
+  const closeModalIcon = ButtonInteractor({ className: including('closeModal') });
 
   const onSearchSpy = sinon.spy();
   const onCancelSpy = sinon.spy();
@@ -120,7 +136,7 @@ describe('AdvancedSearch', () => {
 
   it('should render component children', () => resetButton.is({ visible: true }));
 
-  it('should render component children', () => searchButton.is({ visible: true }));
+  it('should render "Search" button', () => searchButton.is({ disabled: true }));
 
   it('should render "Reset all" button', () => resetAllButton.is({ disabled: true }));
 
@@ -171,6 +187,13 @@ describe('AdvancedSearch', () => {
         expect(document.activeElement === queryField).to.be.true;
       });
     });
+
+    it('should display the clear icon inside the input field', () => {
+      const firstRow = advancedSearch.find(RowInteractor({ index: 0 }));
+      const clearIcon = firstRow.find(ButtonInteractor({ icon: 'times-circle-solid'}));
+
+      return clearIcon.exists();
+    });
   });
 
   describe('when resetting advanced search', () => {
@@ -325,4 +348,56 @@ describe('AdvancedSearch', () => {
       });
     })
   })
+
+  describe('when hitting the reset button inside the modal window', () => {
+    beforeEach(async () => {
+      await mountWithContext(
+        <Harness
+          open
+          defaultSearchOptionValue={DEFAULT_SEARCH_OPTION}
+          searchOptions={searchOptions}
+          firstRowInitialSearch={{
+            query: 'contributor exactPhrase test or isbn startsWith test2',
+            option: 'advancedSearch',
+          }}
+        />
+      );
+
+      await resetAllButton.click();
+    });
+
+    it('should disable the "Reset all" button', () => resetAllButton.is({ disabled: true }));
+
+    it('should disable the "Search" button', () => searchButton.is({ disabled: true }));
+
+    it('should remove all values in modal', async () => {
+      for (let i = 0; i < ROW_COUNT; i++) {
+        await advancedSearch.find(RowInteractor({ index: i })).perform(el => {
+          if (i > 0) {
+            expect(el.querySelector('[data-test-advanced-search-bool]').value).to.equal(BOOLEAN_OPERATORS.AND);
+          }
+          expect(el.querySelector('[data-test-advanced-search-query]').value).to.equal('');
+          expect(el.querySelector('[data-test-advanced-search-match]').value).to.equal(MATCH_OPTIONS.CONTAINS_ALL);
+          expect(el.querySelector('[data-test-advanced-search-option]').value).to.equal(DEFAULT_SEARCH_OPTION);
+        });
+      }
+    });
+
+    describe('when reopening the modal', () => {
+      beforeEach(async () => {
+        await closeModalIcon.click();
+        await advancedSearchButton.click();
+      });
+
+      it('should not have values outside the modal', async () => {
+        await advancedSearch.find(RowInteractor({ index: 0 })).perform(el => {
+          expect(el.querySelector('[data-test-advanced-search-query]').value).to.equal('');
+        });
+      });
+
+      it('should render the "Reset all" button disabled', () => resetAllButton.is({ disabled: true }));
+
+      it('should render the "Search" button disabled', () => searchButton.is({ disabled: true }));
+    });
+  });
 });


Reviews:
user: login: BogdanDenis
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T11:37:29Z
user: login: zburke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T13:21:26Z
user: login: Dmytro-Melnyshyn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:49:17Z
user: login: Dmytro-Melnyshyn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:54:37Z
user: login: Dmytro-Melnyshyn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T14:29:34Z
user: login: zburke
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-04T15:11:59Z
user: login: zburke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T15:12:57Z

Review comments:
diff_hunk: @@ -338,9 +338,7 @@ describe('AdvancedSearch', () => {
 
     describe('resetting from the outside', () => {
       beforeEach(async () => {
-        await closeModalIcon.click();
         await ButtonInteractor('Reset').click();
-        await advancedSearchButton.click();
       });
 
       it('renders updated query values', async () => {
path: lib/AdvancedSearch/tests/AdvancedSearch-test.js
user: login: zburke
body: I don't understand how this test works now :/ previously, the `beforeEach` would close the AS modal, click reset, and open the AS modal. How can you click the reset button if the modal is visible? And you can you test components in the AS modal if you haven't clicked the AS button? I'm so confused. 
created_at: 2025-04-04T13:21:26Z
author_association: MEMBER
diff_hunk: @@ -338,9 +338,7 @@ describe('AdvancedSearch', () => {
 
     describe('resetting from the outside', () => {
       beforeEach(async () => {
-        await closeModalIcon.click();
         await ButtonInteractor('Reset').click();
-        await advancedSearchButton.click();
       });
 
       it('renders updated query values', async () => {
path: lib/AdvancedSearch/tests/AdvancedSearch-test.js
user: login: Dmytro-Melnyshyn
body: @zburke this is not my test. I changed it but then reverted the changes because I changed the implementation. It somehow allows clicking. It looks like you are not looking at the latest changes.
created_at: 2025-04-04T13:49:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -338,9 +338,7 @@ describe('AdvancedSearch', () => {
 
     describe('resetting from the outside', () => {
       beforeEach(async () => {
-        await closeModalIcon.click();
         await ButtonInteractor('Reset').click();
-        await advancedSearchButton.click();
       });
 
       it('renders updated query values', async () => {
path: lib/AdvancedSearch/tests/AdvancedSearch-test.js
user: login: Dmytro-Melnyshyn
body: @zburke there are two reset buttons, one in the main search (outside the modal) and the second reset button is inside the advanced search modal. This test describes the outer reset button click. 
created_at: 2025-04-04T13:54:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -338,9 +338,7 @@ describe('AdvancedSearch', () => {
 
     describe('resetting from the outside', () => {
       beforeEach(async () => {
-        await closeModalIcon.click();
         await ButtonInteractor('Reset').click();
-        await advancedSearchButton.click();
       });
 
       it('renders updated query values', async () => {
path: lib/AdvancedSearch/tests/AdvancedSearch-test.js
user: login: Dmytro-Melnyshyn
body: > How can you click the reset button if the modal is visible?

I don't click it in my tests. But this happens because there is no `<div id="OverlayContainer" class="overlayContainer---W3T5s"></div>` that has `pointer-events: none;`

> And you can you test components in the AS modal if you haven't clicked the AS button? I'm so confused.

`open=true` is passed to the modal.
created_at: 2025-04-04T14:29:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -338,9 +338,7 @@ describe('AdvancedSearch', () => {
 
     describe('resetting from the outside', () => {
       beforeEach(async () => {
-        await closeModalIcon.click();
         await ButtonInteractor('Reset').click();
-        await advancedSearchButton.click();
       });
 
       it('renders updated query values', async () => {
path: lib/AdvancedSearch/tests/AdvancedSearch-test.js
user: login: zburke
body: Thanks for taking the time to explain this to me, @Dmytro-Melnyshyn . I appreciate it.
created_at: 2025-04-04T15:12:57Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-04T10:17:10Z
body: ## Bigtest Unit Test Results
    1 files  ±0      1 suites  ±0   18s ⏱️ +2s
1 573 tests +8  1 565 ✅ +8  8 💤 ±0  0 ❌ ±0 
1 574 runs  +7  1 566 ✅ +7  8 💤 ±0  0 ❌ ±0 

Results for commit 3236e935. ± Comparison against base commit ba9c3230.

[test-results]:data:application/gzip;base64,H4sIAJr672cC/02MOw7DIBAFr2JRpwA2YJzLWAsGaRV/IgxVlLuHIAe7nHl682aBZr+zRyduHdszpQZTjphoW39oCpcl1U318Mdxz85Vp9XpnvQqrn3GgDQXwZvwMW7xMDGvR/V+0CWqmzqblS/Jytei25aFUgEGErQfQKH1XAQZAMXkjJWS9yhB4GCCR2OBfb4d1uQdCAEAAA==

:recycle: This comment has been updated with latest results.
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-04T15:29:24Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=org.folio%3Astripes-components&pullRequest=2456) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&pullRequest=2456&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&pullRequest=2456&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=org.folio%3Astripes-components&pullRequest=2456&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [63.6% Coverage on New Code](https://sonarcloud.io/component_measures?id=org.folio%3Astripes-components&pullRequest=2456&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=org.folio%3Astripes-components&pullRequest=2456&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=org.folio%3Astripes-components&pullRequest=2456)


author_association: NONE