From d6cceea65ea1992473a76947e3d0701063f3fadf Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Wed, 26 Jun 2024 13:47:28 +0300 Subject: [PATCH 01/10] Update data fetcher hooks to support sort and filters --- .../useServerSideActionDataGrid.test.tsx.snap | 41 + .../react-utils/src/hooks/tests/fixtures.ts | 17 + ... => useClientSideActionsDataGrid.test.tsx} | 147 +++- .../useServerSideActionDataGrid.test.tsx | 714 ++++++++++++++++++ .../hooks/tests/useSimpleTabularView.test.tsx | 218 ------ .../react-utils/src/hooks/tests/utils.test.ts | 22 +- ...arch.ts => useClientSideActonsDataGrid.ts} | 53 +- ...iew.ts => useServerSideActionsDataGrid.ts} | 111 ++- packages/react-utils/src/hooks/utils.ts | 60 ++ 9 files changed, 1151 insertions(+), 232 deletions(-) create mode 100644 packages/react-utils/src/hooks/tests/__snapshots__/useServerSideActionDataGrid.test.tsx.snap rename packages/react-utils/src/hooks/tests/{useTabularViewWithLocalSearch.test.tsx => useClientSideActionsDataGrid.test.tsx} (61%) create mode 100644 packages/react-utils/src/hooks/tests/useServerSideActionDataGrid.test.tsx delete mode 100644 packages/react-utils/src/hooks/tests/useSimpleTabularView.test.tsx rename packages/react-utils/src/hooks/{useTabularViewWithLocalSearch.ts => useClientSideActonsDataGrid.ts} (70%) rename packages/react-utils/src/hooks/{useSimpleTabularView.ts => useServerSideActionsDataGrid.ts} (52%) diff --git a/packages/react-utils/src/hooks/tests/__snapshots__/useServerSideActionDataGrid.test.tsx.snap b/packages/react-utils/src/hooks/tests/__snapshots__/useServerSideActionDataGrid.test.tsx.snap new file mode 100644 index 000000000..6abc245c0 --- /dev/null +++ b/packages/react-utils/src/hooks/tests/__snapshots__/useServerSideActionDataGrid.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`pagination and search work correctly: Search 1 page 1 1`] = ` + + Birth Notification CRVS sample + +`; + +exports[`pagination and search work correctly: table row 1 page 1 1`] = ` + + NSW Government My Personal Health Record + +`; + +exports[`pagination and search work correctly: table row 1 page 2 1`] = ` + + 426 - title + +`; + +exports[`pagination and search work correctly: table row 2 page 1 1`] = ` + + 219 - title + +`; + +exports[`pagination and search work correctly: table row 2 page 2 1`] = ` + + NSW Government My Personal Health Record + +`; diff --git a/packages/react-utils/src/hooks/tests/fixtures.ts b/packages/react-utils/src/hooks/tests/fixtures.ts index 828908cec..4e85a2d2a 100644 --- a/packages/react-utils/src/hooks/tests/fixtures.ts +++ b/packages/react-utils/src/hooks/tests/fixtures.ts @@ -1365,3 +1365,20 @@ export const hugeSinglePageData = { }, ], }; + +export const emptyPage = { + resourceType: 'Bundle', + id: '67f02e3f-8028-4410-81c0-fde9391ef6b1', + meta: { + lastUpdated: '2022-01-13T15:23:21.950+00:00', + }, + type: 'searchset', + total: 0, + link: [ + { + relation: 'self', + url: 'http://fhir.labs.smartregister.org/fhir/Questionnaire/_search?_count=2&_format=json&_getpagesoffset=0', + }, + ], + entry: [], +}; diff --git a/packages/react-utils/src/hooks/tests/useTabularViewWithLocalSearch.test.tsx b/packages/react-utils/src/hooks/tests/useClientSideActionsDataGrid.test.tsx similarity index 61% rename from packages/react-utils/src/hooks/tests/useTabularViewWithLocalSearch.test.tsx rename to packages/react-utils/src/hooks/tests/useClientSideActionsDataGrid.test.tsx index 2d8e12240..4a307500e 100644 --- a/packages/react-utils/src/hooks/tests/useTabularViewWithLocalSearch.test.tsx +++ b/packages/react-utils/src/hooks/tests/useClientSideActionsDataGrid.test.tsx @@ -15,13 +15,17 @@ import { QueryClientProvider, QueryClient } from 'react-query'; import { Input } from 'antd'; import TableLayout from '../../components/TableLayout'; import { Router, Route, Switch } from 'react-router'; -import { useTabularViewWithLocalSearch } from '../useTabularViewWithLocalSearch'; -import { hugeSinglePageData, hugeSinglePageDataSummary } from './fixtures'; +import { useClientSideActionsDataGrid } from '../useClientSideActonsDataGrid'; +import { emptyPage, hugeSinglePageData, hugeSinglePageDataSummary } from './fixtures'; +import { renderHook, act } from '@testing-library/react-hooks'; +import flushPromises from 'flush-promises'; jest.mock('fhirclient', () => { return jest.requireActual('fhirclient/lib/entry/browser'); }); +const history = createMemoryHistory(); + const rQClient = new QueryClient({ defaultOptions: { queries: { @@ -65,7 +69,7 @@ const SearchForm = (props: any) => { const SampleApp = () => { const { baseUrl, endpoint } = options; const matchesSearch = (obj, search) => obj.name.includes(search); - const { tablePaginationProps, queryValues, searchFormProps } = useTabularViewWithLocalSearch( + const { tablePaginationProps, queryValues, searchFormProps } = useClientSideActionsDataGrid( baseUrl, endpoint, {}, @@ -84,7 +88,7 @@ const SampleApp = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const tableProps: any = { - datasource: data ?? [], + datasource: data, columns, loading: isFetching || isLoading, pagination: tablePaginationProps, @@ -246,3 +250,138 @@ test('integrates correctly in component', async () => { expect(nock.pendingMocks()).toEqual([]); }); + +test('useClientSideActionsDataGrid hook work for filter state', async () => { + history.push('/'); + + const wrapper = ({ children }) => ( + <> + + + + +
{children}
+
+
+
+
+ + ); + const fhirBaseURL = 'https://test.server'; + const resourceType = 'Location'; + + nock(fhirBaseURL) + .get(`/${resourceType}/_search`) + .query({ + _summary: 'count', + }) + .reply(200, emptyPage) + .persist(); + nock(fhirBaseURL).get(`/${resourceType}/_search`).query({}).reply(200, emptyPage).persist(); + + const { result } = renderHook(() => useClientSideActionsDataGrid(fhirBaseURL, resourceType, {}), { + wrapper, + }); + + // check initial state + expect(result.error).toBeUndefined(); + expect(result.current.filterOptions).toMatchObject({ + updateFilter: expect.any(Function), + currentFilters: {}, + }); + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual([]); + }); + + act(() => { + result.current.filterOptions.updateFilter('name', { + operand: 'includes', + value: 'petName', + caseSensitive: true, + }); + }); + await flushPromises(); + expect(result.current.filterOptions.currentFilters).toEqual({ + name: { + caseSensitive: true, + operand: 'includes', + value: 'petName', + }, + }); + + // with name sorted in ascend + act(() => { + result.current.filterOptions.updateFilter('name'); + }); + await flushPromises(); + expect(result.current.filterOptions.currentFilters).toEqual({}); + expect(nock.pendingMocks()).toEqual([]); +}); + +test('useClientSideActionsDataGrid retains initial filter values', async () => { + history.push('/'); + + const wrapper = ({ children }) => ( + <> + + + + +
{children}
+
+
+
+
+ + ); + const fhirBaseURL = 'https://test.server'; + const resourceType = 'Location'; + + nock(fhirBaseURL) + .get(`/${resourceType}/_search`) + .query({ + _summary: 'count', + }) + .reply(200, emptyPage) + .persist(); + nock(fhirBaseURL).get(`/${resourceType}/_search`).query({}).reply(200, emptyPage).persist(); + + const { result } = renderHook( + () => + useClientSideActionsDataGrid(fhirBaseURL, resourceType, {}, undefined, undefined, { + name: { + operand: 'includes', + value: 'someValue', + }, + }), + { wrapper } + ); + + // check initial state + expect(result.error).toBeUndefined(); + expect(result.current.filterOptions).toMatchObject({ + updateFilter: expect.any(Function), + currentFilters: {}, + }); + await flushPromises(); + await waitFor(() => { + expect(nock.pendingMocks()).toEqual([]); + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual([]); + }); + expect(result.current.filterOptions.currentFilters).toEqual({ + name: { operand: 'includes', value: 'someValue' }, + }); + + // with name sorted in ascend + act(() => { + result.current.filterOptions.updateFilter('name'); + }); + await flushPromises(); + expect(result.current.filterOptions.currentFilters).toEqual({}); + expect(nock.pendingMocks()).toEqual([]); +}); diff --git a/packages/react-utils/src/hooks/tests/useServerSideActionDataGrid.test.tsx b/packages/react-utils/src/hooks/tests/useServerSideActionDataGrid.test.tsx new file mode 100644 index 000000000..4aab3f05e --- /dev/null +++ b/packages/react-utils/src/hooks/tests/useServerSideActionDataGrid.test.tsx @@ -0,0 +1,714 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { store } from '@opensrp/store'; +import { authenticateUser } from '@onaio/session-reducer'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { QueryClientProvider, QueryClient } from 'react-query'; +import { Router, Route, Switch } from 'react-router'; +import { TableLayout } from '../../components/TableLayout'; +import { useServerSideActionsDataGrid } from '../useServerSideActionsDataGrid'; +import nock from 'nock'; +import { dataPage1, dataPage2, emptyPage, searchData } from './fixtures'; +import userEvents from '@testing-library/user-event'; +import { Input } from 'antd'; +import { renderHook, act } from '@testing-library/react-hooks'; +import flushPromises from 'flush-promises'; + +const history = createMemoryHistory(); + +jest.mock('fhirclient', () => { + return jest.requireActual('fhirclient/lib/entry/browser'); +}); + +const rQClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +// TODO - boiler plate +store.dispatch( + authenticateUser( + true, + { + email: 'bob@example.com', + name: 'Bobbie', + username: 'RobertBaratheon', + }, + { api_token: 'hunter2', oAuth2Data: { access_token: 'bamboocha', state: 'abcde' } } + ) +); + +// we first setup the wrapper components, somewhere to run the hooks during tests +const options = { + baseUrl: 'http://example.com', + endpoint: 'data', +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const SearchForm = (props: any) => { + const { onChangeHandler, ...otherProps } = props; + + return ( +
+ +
+ ); +}; + +// minimal app to wrap our hook. +const SampleApp = () => { + const { tablePaginationProps, queryValues, searchFormProps } = useServerSideActionsDataGrid( + options.baseUrl, + options.endpoint + ); + + const { data, isFetching, isLoading } = queryValues; + + const columns = [ + { + title: 'Name/Id', + dataIndex: 'title', + width: '20%', + }, + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tableProps: any = { + datasource: data?.records ?? [], + columns, + loading: isFetching || isLoading, + pagination: tablePaginationProps, + }; + + return ( +
+ + +
+ ); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const App = (props: any) => { + return ( + + + {props.children} + + + ); +}; + +// we now setup the tests +beforeAll(() => { + nock.disableNetConnect(); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +afterEach(() => { + nock.cleanAll(); + cleanup(); +}); + +test('pagination and search work correctly', async () => { + const history = createMemoryHistory(); + history.push('/qr'); + + nock(options.baseUrl) + .get(`/${options.endpoint}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 0, + _count: 20, + }) + .reply(200, dataPage1) + .persist(); + + nock(options.baseUrl) + .get(`/${options.endpoint}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 20, + _count: 20, + }) + .reply(200, dataPage2) + .persist(); + + nock(options.baseUrl) + .get(`/${options.endpoint}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 0, + _count: 20, + 'name:contains': '345', + }) + .reply(200, searchData) + .persist(); + + render( + + + + + + ); + + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + + const waitForSpinner = async () => { + return await waitFor(() => { + expect(document.querySelector('.ant-spin')).toBeInTheDocument(); + }); + }; + + await waitFor(() => { + expect(screen.getByText(/NSW Government My Personal Health Record/)).toBeInTheDocument(); + }); + + document.querySelectorAll('tr').forEach((tr, idx) => { + tr.querySelectorAll('td').forEach((td) => { + expect(td).toMatchSnapshot(`table row ${idx} page 1`); + }); + }); + + fireEvent.click(screen.getByTitle('2')); + + expect(history.location.search).toEqual('?pageSize=20&page=2'); + + await waitForSpinner(); + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + + expect(screen.getByText(/426 - title/)).toBeInTheDocument(); + document.querySelectorAll('tr').forEach((tr, idx) => { + tr.querySelectorAll('td').forEach((td) => { + expect(td).toMatchSnapshot(`table row ${idx} page 2`); + }); + }); + + // works with search as well. + const searchForm = document.querySelector('[data-testid="search-form"]') as Element; + userEvents.type(searchForm, '345'); + + expect(history.location.search).toEqual('?pageSize=20&page=1&search=345'); + + await waitForSpinner(); + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + + document.querySelectorAll('tr').forEach((tr, idx) => { + tr.querySelectorAll('td').forEach((td) => { + expect(td).toMatchSnapshot(`Search ${idx} page 1`); + }); + }); + + // remove search. + userEvents.clear(searchForm); + expect(history.location.search).toEqual('?pageSize=20&page=1'); + + expect(nock.pendingMocks()).toEqual([]); +}); + +test('useServerSideActionDataGrid hook work for sort state', async () => { + history.push('/'); + + const wrapper = ({ children }) => ( + <> + + + + +
{children}
+
+
+
+
+ + ); + const fhirBaseURL = 'https://test.server'; + const resourceType = 'Location'; + + nock(fhirBaseURL) + .get(`/${resourceType}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 0, + _count: 20, + }) + .reply(200, emptyPage) + .persist(); + nock(fhirBaseURL) + .get(`/${resourceType}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 0, + _sort: 'name', + _count: 20, + }) + .reply(200, { ...emptyPage, entry: [{ status: 'ascend' }] }); + nock(fhirBaseURL) + .get(`/${resourceType}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 0, + _sort: '-name', + _count: 20, + // _summary: "count" + }) + .reply(200, { ...emptyPage, entry: [{ status: 'descend' }] }); + + const { result } = renderHook( + () => + useServerSideActionsDataGrid(fhirBaseURL, resourceType, {}, (x) => { + return x.entry as any; + }), + { wrapper } + ); + + // check initial state + expect(result.error).toBeUndefined(); + expect(result.current.sortOptions).toMatchObject({ + updateSortParams: expect.any(Function), + getControlledSortProps: expect.any(Function), + currentParams: {}, + sortState: {}, + }); + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual({ + records: [], + total: 0, + }); + }); + // gets a column's controlled props from empty state + const controlledSortProps = result.current.sortOptions.getControlledSortProps('nonExistent'); + expect(controlledSortProps).toEqual({}); + + act(() => { + result.current.sortOptions.updateSortParams({ + name: { + paramAccessor: 'name', + order: 'descend', + }, + }); + }); + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual({ + records: [ + { + status: 'descend', + }, + ], + total: 0, + }); + }); + + // gets a column's controlled props from empty state + const nameColumnSortProps = result.current.sortOptions.getControlledSortProps('name'); + expect(nameColumnSortProps).toEqual({ + sortDirections: ['ascend', 'descend'], + sortOrder: 'descend', + sorter: true, + }); + expect(result.current.sortOptions.sortState).toEqual({ + name: { + order: 'descend', + paramAccessor: 'name', + }, + }); + expect(result.current.sortOptions.currentParams).toEqual({ + _count: 20, + _getpagesoffset: 0, + _sort: '-name', + _total: 'accurate', + }); + + // with name sorted in ascend + act(() => { + result.current.sortOptions.updateSortParams({ + name: { + paramAccessor: 'name', + order: 'ascend', + }, + }); + }); + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual({ + records: [ + { + status: 'ascend', + }, + ], + + total: 0, + }); + }); + expect(result.current.sortOptions.sortState).toEqual({ + name: { + order: 'ascend', + paramAccessor: 'name', + }, + }); + expect(result.current.sortOptions.currentParams).toEqual({ + _count: 20, + _getpagesoffset: 0, + _total: 'accurate', + _sort: 'name', + }); + + // update sort state + act(() => { + result.current.sortOptions.updateSortParams({ + name: undefined, + }); + }); + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual({ records: [], total: 0 }); + }); + expect(result.current.sortOptions.sortState).toEqual({}); + expect(result.current.sortOptions.currentParams).toEqual({ + _count: 20, + _getpagesoffset: 0, + _total: 'accurate', + }); + + expect(nock.pendingMocks()).toEqual([]); + + // expect(current.sParams.toString()).toEqual(''); + // const params = { + // key: 'value', + // key1: 'value1', + // key2: 'value2', + // }; + // current.addParams(params); + // expect(current.sParams.toString()).toEqual('key=value&key1=value1&key2=value2'); + + // //Test that when we call addParams to an existing key we replace it instead of appending + // current.addParam('key1', 'newValue3'); + // expect(current.sParams.toString()).toEqual('key=value&key1=newValue3&key2=value2'); + + // expect(history.location).toMatchObject({ + // hash: '', + // key: expect.any(String), + // pathname: '/qr', + // search: '?key=value&key1=newValue3&key2=value2', + // state: undefined, + // }); + + // current.removeParam('key1'); + // expect(current.sParams.toString()).toEqual('key=value&key2=value2'); + // expect(history.location).toMatchObject({ + // hash: '', + // key: expect.any(String), + // pathname: '/qr', + // search: '?key=value&key2=value2', + // state: undefined, + // }); +}); + +test('useServerSideActionDataGrid retains initial sort values', async () => { + history.push('/'); + + const wrapper = ({ children }) => ( + <> + + + + +
{children}
+
+
+
+
+ + ); + const fhirBaseURL = 'https://test.server'; + const resourceType = 'Location'; + + nock(fhirBaseURL) + .get(`/${resourceType}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 0, + _sort: 'name', + _count: 20, + }) + .reply(200, { ...emptyPage, entry: [{ status: 'ascend' }] }); + + const { result } = renderHook( + () => + useServerSideActionsDataGrid( + fhirBaseURL, + resourceType, + {}, + (x) => { + return x.entry as any; + }, + { + name: { + paramAccessor: 'name', + order: 'ascend', + }, + } + ), + { wrapper } + ); + + // check initial state + expect(result.error).toBeUndefined(); + expect(result.current.sortOptions).toMatchObject({ + updateSortParams: expect.any(Function), + getControlledSortProps: expect.any(Function), + currentParams: {}, + sortState: {}, + }); + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual({ + records: [ + { + status: 'ascend', + }, + ], + total: 0, + }); + }); + // gets a column's controlled props from empty state + const controlledSortProps = result.current.sortOptions.getControlledSortProps('name'); + expect(controlledSortProps).toEqual({ + sortDirections: ['ascend', 'descend'], + sortOrder: 'ascend', + sorter: true, + }); + + expect(nock.isDone).toBeTruthy(); +}); + +test('useServerSideActionDataGrid hook work for filter state', async () => { + history.push('/'); + + const wrapper = ({ children }) => ( + <> + + + + +
{children}
+
+
+
+
+ + ); + const fhirBaseURL = 'https://test.server'; + const resourceType = 'Location'; + + nock(fhirBaseURL) + .get(`/${resourceType}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 0, + _count: 20, + }) + .reply(200, emptyPage) + .persist(); + nock(fhirBaseURL) + .get(`/${resourceType}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 0, + _count: 20, + name: 'pet', + }) + .reply(200, { ...emptyPage, entry: [{ filter: 'name:pet' }] }); + + const { result } = renderHook( + () => + useServerSideActionsDataGrid(fhirBaseURL, resourceType, {}, (x) => { + return x.entry as any; + }), + { wrapper } + ); + + // check initial state + expect(result.error).toBeUndefined(); + expect(result.current.filterOptions).toMatchObject({ + updateFilterParams: expect.any(Function), + currentParams: {}, + currentFilters: {}, + }); + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual({ + records: [], + total: 0, + }); + }); + + // add filter + act(() => { + result.current.filterOptions.updateFilterParams({ + name: { + paramAccessor: 'name', + rawValue: 'pet', + paramValue: 'pet', + }, + }); + }); + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual({ + records: [ + { + filter: 'name:pet', + }, + ], + total: 0, + }); + }); + expect(result.current.filterOptions.currentFilters).toEqual({ + name: { + paramValue: 'pet', + rawValue: 'pet', + paramAccessor: 'name', + }, + }); + expect(result.current.filterOptions.currentParams).toEqual({ + _count: 20, + _getpagesoffset: 0, + name: 'pet', + _total: 'accurate', + }); + + // update filter by removing it + act(() => { + result.current.filterOptions.updateFilterParams({ + name: undefined, + }); + }); + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual({ records: [], total: 0 }); + }); + expect(result.current.sortOptions.sortState).toEqual({}); + expect(result.current.sortOptions.currentParams).toEqual({ + _count: 20, + _getpagesoffset: 0, + _total: 'accurate', + }); + + expect(nock.pendingMocks()).toEqual([]); +}); + +test('useServerSideActionDataGrid retains initial filter values', async () => { + history.push('/'); + + const wrapper = ({ children }) => ( + <> + + + + +
{children}
+
+
+
+
+ + ); + const fhirBaseURL = 'https://test.server'; + const resourceType = 'Location'; + + nock(fhirBaseURL) + .get(`/${resourceType}/_search`) + .query({ + _total: 'accurate', + _getpagesoffset: 0, + _count: 20, + name: 'pet', + }) + .reply(200, { ...emptyPage, entry: [{ filter: 'name:pet' }] }); + + const { result } = renderHook( + () => + useServerSideActionsDataGrid( + fhirBaseURL, + resourceType, + {}, + (x) => { + return x.entry as any; + }, + undefined, + { + name: { + paramAccessor: 'name', + rawValue: 'pet', + paramValue: 'pet', + }, + } + ), + { wrapper } + ); + + // check initial state + await flushPromises(); + await waitFor(() => { + // confirm that the request resolved + expect(result.current.queryValues.error).toBeNull(); + expect(result.current.queryValues.data).toEqual({ + records: [ + { + filter: 'name:pet', + }, + ], + total: 0, + }); + }); + expect(result.current.filterOptions.currentFilters).toEqual({ + name: { + paramValue: 'pet', + rawValue: 'pet', + paramAccessor: 'name', + }, + }); + expect(result.current.filterOptions.currentParams).toEqual({ + _count: 20, + _getpagesoffset: 0, + name: 'pet', + _total: 'accurate', + }); + + expect(nock.isDone).toBeTruthy(); +}); diff --git a/packages/react-utils/src/hooks/tests/useSimpleTabularView.test.tsx b/packages/react-utils/src/hooks/tests/useSimpleTabularView.test.tsx deleted file mode 100644 index 73988c06d..000000000 --- a/packages/react-utils/src/hooks/tests/useSimpleTabularView.test.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React from 'react'; -import { store } from '@opensrp/store'; -import { authenticateUser } from '@onaio/session-reducer'; -import { - cleanup, - fireEvent, - render, - screen, - waitFor, - waitForElementToBeRemoved, -} from '@testing-library/react'; -import { createMemoryHistory } from 'history'; -import { QueryClientProvider, QueryClient } from 'react-query'; -import { Router, Route, Switch } from 'react-router'; -import { TableLayout } from '../../components/TableLayout'; -import { useSimpleTabularView } from '../useSimpleTabularView'; -import nock from 'nock'; -import { dataPage1, dataPage2, searchData } from './fixtures'; -import userEvents from '@testing-library/user-event'; -import { Input } from 'antd'; - -jest.mock('fhirclient', () => { - return jest.requireActual('fhirclient/lib/entry/browser'); -}); - -const rQClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - cacheTime: 0, - }, - }, -}); - -// TODO - boiler plate -store.dispatch( - authenticateUser( - true, - { - email: 'bob@example.com', - name: 'Bobbie', - username: 'RobertBaratheon', - }, - { api_token: 'hunter2', oAuth2Data: { access_token: 'bamboocha', state: 'abcde' } } - ) -); - -// we first setup the wrapper components, somewhere to run the hooks during tests -const options = { - baseUrl: 'http://example.com', - endpoint: 'data', -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const SearchForm = (props: any) => { - const { onChangeHandler, ...otherProps } = props; - - return ( -
- -
- ); -}; - -// minimal app to wrap our hook. -const SampleApp = () => { - const { tablePaginationProps, queryValues, searchFormProps } = useSimpleTabularView( - options.baseUrl, - options.endpoint - ); - - const { data, isFetching, isLoading } = queryValues; - - const columns = [ - { - title: 'Name/Id', - dataIndex: 'title', - width: '20%', - }, - ]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tableProps: any = { - datasource: data?.records ?? [], - columns, - loading: isFetching || isLoading, - pagination: tablePaginationProps, - }; - - return ( -
- - -
- ); -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const App = (props: any) => { - return ( - - - {props.children} - - - ); -}; - -// we now setup the tests -beforeAll(() => { - nock.disableNetConnect(); -}); - -afterAll(() => { - nock.enableNetConnect(); -}); - -afterEach(() => { - nock.cleanAll(); - cleanup(); -}); - -test('pagination and search work correctly', async () => { - const history = createMemoryHistory(); - history.push('/qr'); - - nock(options.baseUrl) - .get(`/${options.endpoint}/_search`) - .query({ - _total: 'accurate', - _getpagesoffset: 0, - _count: 20, - }) - .reply(200, dataPage1) - .persist(); - - nock(options.baseUrl) - .get(`/${options.endpoint}/_search`) - .query({ - _total: 'accurate', - _getpagesoffset: 20, - _count: 20, - }) - .reply(200, dataPage2) - .persist(); - - nock(options.baseUrl) - .get(`/${options.endpoint}/_search`) - .query({ - _total: 'accurate', - _getpagesoffset: 0, - _count: 20, - 'name:contains': '345', - }) - .reply(200, searchData) - .persist(); - - render( - - - - - - ); - - await waitForElementToBeRemoved(document.querySelector('.ant-spin')); - - const waitForSpinner = async () => { - return await waitFor(() => { - expect(document.querySelector('.ant-spin')).toBeInTheDocument(); - }); - }; - - await waitFor(() => { - expect(screen.getByText(/NSW Government My Personal Health Record/)).toBeInTheDocument(); - }); - - document.querySelectorAll('tr').forEach((tr, idx) => { - tr.querySelectorAll('td').forEach((td) => { - expect(td).toMatchSnapshot(`table row ${idx} page 1`); - }); - }); - - fireEvent.click(screen.getByTitle('2')); - - expect(history.location.search).toEqual('?pageSize=20&page=2'); - - await waitForSpinner(); - await waitForElementToBeRemoved(document.querySelector('.ant-spin')); - - expect(screen.getByText(/426 - title/)).toBeInTheDocument(); - document.querySelectorAll('tr').forEach((tr, idx) => { - tr.querySelectorAll('td').forEach((td) => { - expect(td).toMatchSnapshot(`table row ${idx} page 2`); - }); - }); - - // works with search as well. - const searchForm = document.querySelector('[data-testid="search-form"]') as Element; - userEvents.type(searchForm, '345'); - - expect(history.location.search).toEqual('?pageSize=20&page=1&search=345'); - - await waitForSpinner(); - await waitForElementToBeRemoved(document.querySelector('.ant-spin')); - - document.querySelectorAll('tr').forEach((tr, idx) => { - tr.querySelectorAll('td').forEach((td) => { - expect(td).toMatchSnapshot(`Search ${idx} page 1`); - }); - }); - - // remove search. - userEvents.clear(searchForm); - expect(history.location.search).toEqual('?pageSize=20&page=1'); - - expect(nock.pendingMocks()).toEqual([]); -}); diff --git a/packages/react-utils/src/hooks/tests/utils.test.ts b/packages/react-utils/src/hooks/tests/utils.test.ts index b27765432..cdafc4e5c 100644 --- a/packages/react-utils/src/hooks/tests/utils.test.ts +++ b/packages/react-utils/src/hooks/tests/utils.test.ts @@ -1,5 +1,5 @@ import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; -import { matchesOnName } from '../utils'; +import { checkFilter, matchesOnName } from '../utils'; import { hugeSinglePageData } from './fixtures'; test('search match util works correctly', () => { @@ -11,3 +11,23 @@ test('search match util works correctly', () => { result = matchesOnName(singleCareTeam, 'non-existent'); expect(result).toBeFalsy(); }); + +test('check filter works correctly', () => { + const sampleItem = { + name: 'RejectFB24', + }; + let result = checkFilter(sampleItem, 'name', { operand: '===', value: 'tanoTena' }); + expect(result).toBeFalsy(); + result = checkFilter(sampleItem, 'name', { operand: '!==', value: 'tanoTena' }); + expect(result).toBeTruthy(); + result = checkFilter(sampleItem, 'nonExistent', { operand: '===', value: 'RejectFB24' }); + expect(result).toBeFalsy(); + result = checkFilter(sampleItem, 'name', { operand: 'includes', value: 'reject' }); + expect(result).toBeTruthy(); + result = checkFilter(sampleItem, 'name', { + operand: 'includes', + value: 'reject', + caseSensitive: true, + }); + expect(result).toBeFalsy(); +}); diff --git a/packages/react-utils/src/hooks/useTabularViewWithLocalSearch.ts b/packages/react-utils/src/hooks/useClientSideActonsDataGrid.ts similarity index 70% rename from packages/react-utils/src/hooks/useTabularViewWithLocalSearch.ts rename to packages/react-utils/src/hooks/useClientSideActonsDataGrid.ts index 9ab40695f..0f0ad9ff5 100644 --- a/packages/react-utils/src/hooks/useTabularViewWithLocalSearch.ts +++ b/packages/react-utils/src/hooks/useClientSideActonsDataGrid.ts @@ -1,4 +1,4 @@ -import { ChangeEvent, useCallback } from 'react'; +import { ChangeEvent, useCallback, useMemo, useState } from 'react'; import { getQueryParams } from '../components/Search/utils'; import { getResourcesFromBundle } from '../helpers/utils'; import { useQuery } from 'react-query'; @@ -8,6 +8,9 @@ import type { IBundle } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IBundle' import { URLParams } from '@opensrp/server-service'; import { loadAllResources } from '../helpers/fhir-utils'; import { + Filter, + FilterDescription, + checkFilter, getNextUrlOnSearch, getNumberParam, getStringParam, @@ -29,16 +32,18 @@ import { * @param matchesSearch - function that computes whether a resource payload should be matched by search * @param dataTransformer - function to process data after fetch */ -export function useTabularViewWithLocalSearch( +export function useClientSideActionsDataGrid( fhirBaseUrl: string, resourceType: string, extraParams: URLParams | ((search: string | null) => URLParams) = {}, matchesSearch: (obj: T, search: string) => boolean = matchesOnName, - dataTransformer: (response: IBundle) => T[] = getResourcesFromBundle + dataTransformer: (response: IBundle) => T[] = getResourcesFromBundle, + initialFilters: FilterDescription = {} ) { const location = useLocation(); const history = useHistory(); const match = useRouteMatch(); + const [filters, setFilters] = useState(initialFilters); const page = getNumberParam(location, pageQuery, startingPage) as number; const search = getStringParam(location, searchQuery); @@ -74,6 +79,26 @@ export function useTabularViewWithLocalSearch( }); } + // Method to apply all filters to the data + filteredData = useMemo(() => { + const filtered = []; + for (const item of filteredData ?? []) { + let fullyChecked = true; + for (const accessor in filters) { + const filterDescription = filters[accessor] as Filter; + const filterResult = checkFilter(item, accessor, filterDescription); + if (filterResult) { + fullyChecked = false; + break; + } + } + if (fullyChecked) { + filtered.push(item); + } + } + return filtered; + }, [data, filters]); + const searchFormProps = { defaultValue: getQueryParams(location)[searchQuery], onChangeHandler: function onChangeHandler(event: ChangeEvent) { @@ -82,6 +107,22 @@ export function useTabularViewWithLocalSearch( }, }; + // Method to update the filters + const updateFilter = useCallback((accessor: string, filter?: Filter) => { + setFilters((prevFilters) => { + const newFilters = { ...prevFilters }; + if (filter === undefined) { + delete newFilters[accessor]; + return newFilters; + } else { + return { + ...newFilters, + [accessor]: filter, + }; + } + }); + }, []); + const tablePaginationProps = { current: page, pageSize, @@ -99,6 +140,10 @@ export function useTabularViewWithLocalSearch( return { tablePaginationProps, + filterOptions: { + updateFilter, + currentFilters: filters, + }, queryValues: { data: filteredData, ...restQueryValues, @@ -106,3 +151,5 @@ export function useTabularViewWithLocalSearch( searchFormProps, }; } + +export const useTabularViewWithLocalSearch = useClientSideActionsDataGrid; diff --git a/packages/react-utils/src/hooks/useSimpleTabularView.ts b/packages/react-utils/src/hooks/useServerSideActionsDataGrid.ts similarity index 52% rename from packages/react-utils/src/hooks/useSimpleTabularView.ts rename to packages/react-utils/src/hooks/useServerSideActionsDataGrid.ts index 288eb6f3e..78f939b4f 100644 --- a/packages/react-utils/src/hooks/useSimpleTabularView.ts +++ b/packages/react-utils/src/hooks/useServerSideActionsDataGrid.ts @@ -1,4 +1,4 @@ -import { ChangeEvent, useCallback } from 'react'; +import { ChangeEvent, useCallback, useState } from 'react'; import { getQueryParams } from '../components/Search/utils'; import { getResourcesFromBundle } from '../helpers/utils'; import { useQuery } from 'react-query'; @@ -18,6 +18,7 @@ import { startingPage, startingPageSize, } from './utils'; +import { SortOrder } from 'antd/es/table/interface'; export type ExtraParams = URLParams | ((search: string | null) => URLParams); @@ -30,6 +31,49 @@ const defaultGetExtraParams = (search: string | null) => { return {}; }; +export interface SortParamState { + [dataIndex: string]: { paramAccessor: string; order: SortOrder } | undefined; +} +export interface FilterParamState { + [dataIndex: string]: { paramAccessor: string; rawValue: unknown; paramValue: string } | undefined; +} // TODO - know the unknown + +function SortParamsToSearchParams(sortState: SortParamState) { + const sortString = Object.entries(sortState).reduce( + (accumulator, [dataIndex, sortDescription], currIdx, fullArray) => { + if (!sortDescription) { + return accumulator; + } + const direction = sortDescription.order === 'descend' ? '-' : ''; + const sep = currIdx === fullArray.length - 1 ? '' : ','; + accumulator += `${direction}${sortDescription.paramAccessor}${sep}`; + return accumulator; + }, + '' + ); + if (sortString) { + return { + _sort: sortString, + }; + } else { + return {}; + } +} + +function filterParamstoSearchParams(filterState: FilterParamState) { + const filterParam = Object.entries(filterState).reduce( + (accumulator, [dataIndex, filterDescription]) => { + if (!filterDescription) { + return accumulator; + } + accumulator[filterDescription.paramAccessor] = filterDescription.paramValue; + return accumulator; + }, + {} as URLParams + ); + return filterParam; +} + /** * Re-usable hook that abstracts search and table pagination for usual list view component * Should only be used when getting data from a server that follows the hapi FHIR spec @@ -43,11 +87,15 @@ export function useSimpleTabularView( fhirBaseUrl: string, resourceType: string, extraParams: URLParams | ((search: string | null) => URLParams) = defaultGetExtraParams, - extractResources: ExtractResources = getResourcesFromBundle + extractResources: ExtractResources = getResourcesFromBundle, + defaultSortState: SortParamState = {}, + defaultFilterState: FilterParamState = {} ) { const location = useLocation(); const history = useHistory(); const match = useRouteMatch(); + const [sortState, setSortState] = useState(defaultSortState); + const [filterState, setFilterState] = useState(defaultFilterState); const page = getNumberParam(location, pageQuery, startingPage) as number; const search = getStringParam(location, searchQuery); @@ -63,6 +111,8 @@ export function useSimpleTabularView( typeof extraParams === 'function' ? extraParams(search) : extraParams; otherParams = { ...otherParams, + ...SortParamsToSearchParams(sortState), + ...filterParamstoSearchParams(filterState), _total: 'accurate', _getpagesoffset: (page - 1) * pageSize, _count: pageSize, @@ -80,10 +130,12 @@ export function useSimpleTabularView( const rQuery = { queryKey: [resourceType, otherParams] as TRQuery, queryFn, - select: (data: IBundle) => ({ - records: extractResources(data), - total: data.total ?? 0, - }), + select: (data: IBundle) => { + return { + records: extractResources(data), + total: data.total ?? 0, + }; + }, keepPreviousData: true, staleTime: 5000, refetchOnMount: false, @@ -116,8 +168,52 @@ export function useSimpleTabularView( }, }; + const sanitizeParams = ( + origState: SortParamState | FilterParamState, + state: SortParamState | FilterParamState + ) => { + const newSortState = { ...origState, ...state }; + const sanitizedState: SortParamState | FilterParamState = {}; + for (const dataIdx in newSortState) { + if (newSortState[dataIdx]) { + sanitizedState[dataIdx] = newSortState[dataIdx]; + } + } + return sanitizedState; + }; + + const updateSortParams = (state: SortParamState) => { + const sanitized = sanitizeParams(sortState, state) as SortParamState; + setSortState(sanitized); + }; + + const updateFilterParams = (state: FilterParamState) => { + const sanitized = sanitizeParams(filterState, state) as FilterParamState; + setFilterState(sanitized); + }; + + const getControlledSortProps = (dataIndex: string) => { + const sortColumnProps = sortState[dataIndex]; + if (sortColumnProps) { + return { + sorter: true, + sortOrder: sortState[dataIndex]?.order, + sortDirections: ['ascend' as const, 'descend' as const], + }; + } else { + return {}; + } + }; + return { tablePaginationProps, + sortOptions: { + updateSortParams, + getControlledSortProps, + currentParams: otherParams, + sortState, + }, + filterOptions: { updateFilterParams, currentFilters: filterState, currentParams: otherParams }, queryValues: { data, ...restQueryValues, @@ -125,3 +221,6 @@ export function useSimpleTabularView( searchFormProps, }; } + +const useServerSideActionsDataGrid = useSimpleTabularView; +export { useServerSideActionsDataGrid }; diff --git a/packages/react-utils/src/hooks/utils.ts b/packages/react-utils/src/hooks/utils.ts index 62e682cd4..615e8fa32 100644 --- a/packages/react-utils/src/hooks/utils.ts +++ b/packages/react-utils/src/hooks/utils.ts @@ -4,6 +4,7 @@ import { URLParams } from 'opensrp-server-service/dist/types'; import { ChangeEvent } from 'react'; import { RouteComponentProps } from 'react-router'; import { FHIRServiceClass } from '../helpers/dataLoaders'; +import { Dictionary } from '@onaio/utils'; export const pageSizeQuery = 'pageSize'; export const pageQuery = 'page'; @@ -108,3 +109,62 @@ export const loadResources = async (baseUrl: string, resourceType: string, param } return res; }; + +// Define the type for a filter object +export interface StringFilter { + operand: '===' | '!==' | 'includes'; + value: string; + caseSensitive?: boolean; +} +export interface NumberFilter { + operand: '>' | '<'; + value: number; +} + +export type Filter = StringFilter | NumberFilter; + +// Define the type for the filters state +export interface FilterDescription { + [accessor: string]: Filter; +} + +/** + * return true if record correctly matches the filter + * + * @param item - record to evaluate + * @param accessor - property accessor on item + * @param filter - filter to check on + */ +export const checkFilter = (item: Dictionary, accessor: string, filter: Filter) => { + const { value, operand } = filter; + + let itemsValue = item[accessor]; + let checkValue = value; + + const caseSensitive = + (filter as StringFilter).caseSensitive !== undefined && !(filter as StringFilter).caseSensitive; + + if (caseSensitive) { + if (typeof itemsValue === 'string') { + itemsValue = itemsValue.toLowerCase(); + } + if (typeof checkValue === 'string') { + checkValue = checkValue.toLowerCase(); + } + } + + switch (operand) { + case '===': + return itemsValue === value; + case '!==': + return itemsValue !== value; + case '>': + return itemsValue > value; + case '<': + return itemsValue < value; + case 'includes': + return itemsValue && itemsValue.includes(value); + default: + return false; + } +}; From ec240fa1f97db48293c0f430edff70128e31fa02 Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Thu, 25 Jul 2024 15:56:04 +0300 Subject: [PATCH 02/10] Add hook to manage client side filters --- .../useSimpleTabularView.test.tsx.snap | 41 ----- .../react-utils/src/hooks/tests/fixtures.ts | 163 ++++++++++++++++++ .../useClientSideActionsDataGrid.test.tsx | 145 +--------------- .../useClientSideDataGridFilters.test.tsx | 91 ++++++++++ .../src/hooks/useClientSideActonsDataGrid.ts | 56 +----- .../src/hooks/useClientSideDataGridFilters.ts | 62 +++++++ 6 files changed, 325 insertions(+), 233 deletions(-) delete mode 100644 packages/react-utils/src/hooks/tests/__snapshots__/useSimpleTabularView.test.tsx.snap create mode 100644 packages/react-utils/src/hooks/tests/useClientSideDataGridFilters.test.tsx create mode 100644 packages/react-utils/src/hooks/useClientSideDataGridFilters.ts diff --git a/packages/react-utils/src/hooks/tests/__snapshots__/useSimpleTabularView.test.tsx.snap b/packages/react-utils/src/hooks/tests/__snapshots__/useSimpleTabularView.test.tsx.snap deleted file mode 100644 index 6abc245c0..000000000 --- a/packages/react-utils/src/hooks/tests/__snapshots__/useSimpleTabularView.test.tsx.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`pagination and search work correctly: Search 1 page 1 1`] = ` - - Birth Notification CRVS sample - -`; - -exports[`pagination and search work correctly: table row 1 page 1 1`] = ` - - NSW Government My Personal Health Record - -`; - -exports[`pagination and search work correctly: table row 1 page 2 1`] = ` - - 426 - title - -`; - -exports[`pagination and search work correctly: table row 2 page 1 1`] = ` - - 219 - title - -`; - -exports[`pagination and search work correctly: table row 2 page 2 1`] = ` - - NSW Government My Personal Health Record - -`; diff --git a/packages/react-utils/src/hooks/tests/fixtures.ts b/packages/react-utils/src/hooks/tests/fixtures.ts index 4e85a2d2a..fe2fe34a6 100644 --- a/packages/react-utils/src/hooks/tests/fixtures.ts +++ b/packages/react-utils/src/hooks/tests/fixtures.ts @@ -1382,3 +1382,166 @@ export const emptyPage = { ], entry: [], }; + +export const sampleData = [ + { + id: 1, + first_name: 'Kearney', + last_name: 'Rizzello', + email: 'krizzello0@walmart.com', + gender: 'Male', + ip_address: '198.197.194.238', + }, + { + id: 2, + first_name: 'Melina', + last_name: 'Sherwin', + email: 'msherwin1@rakuten.co.jp', + gender: 'Female', + ip_address: '146.184.11.4', + }, + { + id: 3, + first_name: 'Stillmann', + last_name: 'Jacobowicz', + email: 'sjacobowicz2@vimeo.com', + gender: 'Male', + ip_address: '180.237.208.205', + }, + { + id: 4, + first_name: 'Reamonn', + last_name: 'Punter', + email: 'rpunter3@nps.gov', + gender: 'Male', + ip_address: '60.148.165.38', + }, + { + id: 5, + first_name: 'Michele', + last_name: 'La Batie', + email: 'mlabatie4@discovery.com', + gender: 'Genderqueer', + ip_address: '35.251.187.180', + }, + { + id: 6, + first_name: 'Genny', + last_name: 'Reggiani', + email: 'greggiani5@tmall.com', + gender: 'Female', + ip_address: '227.93.216.116', + }, + { + id: 7, + first_name: 'Feodora', + last_name: 'Ellingham', + email: 'fellingham6@ca.gov', + gender: 'Female', + ip_address: '195.88.42.13', + }, + { + id: 8, + first_name: 'Ailsun', + last_name: 'Krauze', + email: 'akrauze7@statcounter.com', + gender: 'Female', + ip_address: '26.107.123.94', + }, + { + id: 9, + first_name: 'Jelene', + last_name: 'Casement', + email: 'jcasement8@gov.uk', + gender: 'Female', + ip_address: '249.204.221.30', + }, + { + id: 10, + first_name: 'Konrad', + last_name: 'Puddle', + email: 'kpuddle9@example.com', + gender: 'Male', + ip_address: '137.140.236.67', + }, + { + id: 11, + first_name: 'Oralla', + last_name: 'Golledge', + email: 'ogolledgea@thetimes.co.uk', + gender: 'Female', + ip_address: '22.28.95.41', + }, + { + id: 12, + first_name: 'Link', + last_name: 'McCorry', + email: 'lmccorryb@sakura.ne.jp', + gender: 'Male', + ip_address: '209.195.215.111', + }, + { + id: 13, + first_name: 'Clark', + last_name: 'Krzyzanowski', + email: 'ckrzyzanowskic@facebook.com', + gender: 'Male', + ip_address: '95.145.90.228', + }, + { + id: 14, + first_name: 'Derick', + last_name: 'Gwilym', + email: 'dgwilymd@si.edu', + gender: 'Male', + ip_address: '17.143.231.151', + }, + { + id: 15, + first_name: 'Rees', + last_name: 'Whysall', + email: 'rwhysalle@instagram.com', + gender: 'Bigender', + ip_address: '120.203.27.246', + }, + { + id: 16, + first_name: 'Blakelee', + last_name: 'Itzkowicz', + email: 'bitzkowiczf@dmoz.org', + gender: 'Female', + ip_address: '223.24.251.3', + }, + { + id: 17, + first_name: 'Gill', + last_name: 'Dominka', + email: 'gdominkag@histats.com', + gender: 'Female', + ip_address: '108.91.213.240', + }, + { + id: 18, + first_name: 'Lorinda', + last_name: 'Deegin', + email: 'ldeeginh@dropbox.com', + gender: 'Female', + ip_address: '47.83.169.139', + }, + { + id: 19, + first_name: 'Rozanna', + last_name: 'Potte', + email: 'rpottei@amazon.co.uk', + gender: 'Female', + ip_address: '35.130.204.193', + }, + { + id: 20, + first_name: 'Iseabal', + last_name: 'Tregonna', + email: 'itregonnaj@microsoft.com', + gender: 'Female', + ip_address: '90.170.7.245', + }, +]; diff --git a/packages/react-utils/src/hooks/tests/useClientSideActionsDataGrid.test.tsx b/packages/react-utils/src/hooks/tests/useClientSideActionsDataGrid.test.tsx index 4a307500e..bd9702e3f 100644 --- a/packages/react-utils/src/hooks/tests/useClientSideActionsDataGrid.test.tsx +++ b/packages/react-utils/src/hooks/tests/useClientSideActionsDataGrid.test.tsx @@ -16,16 +16,12 @@ import { Input } from 'antd'; import TableLayout from '../../components/TableLayout'; import { Router, Route, Switch } from 'react-router'; import { useClientSideActionsDataGrid } from '../useClientSideActonsDataGrid'; -import { emptyPage, hugeSinglePageData, hugeSinglePageDataSummary } from './fixtures'; -import { renderHook, act } from '@testing-library/react-hooks'; -import flushPromises from 'flush-promises'; +import { hugeSinglePageData, hugeSinglePageDataSummary } from './fixtures'; jest.mock('fhirclient', () => { return jest.requireActual('fhirclient/lib/entry/browser'); }); -const history = createMemoryHistory(); - const rQClient = new QueryClient({ defaultOptions: { queries: { @@ -68,12 +64,10 @@ const SearchForm = (props: any) => { // minimal app to wrap our hook. const SampleApp = () => { const { baseUrl, endpoint } = options; - const matchesSearch = (obj, search) => obj.name.includes(search); const { tablePaginationProps, queryValues, searchFormProps } = useClientSideActionsDataGrid( baseUrl, endpoint, - {}, - matchesSearch + {} ); const { data, isFetching, isLoading } = queryValues; @@ -250,138 +244,3 @@ test('integrates correctly in component', async () => { expect(nock.pendingMocks()).toEqual([]); }); - -test('useClientSideActionsDataGrid hook work for filter state', async () => { - history.push('/'); - - const wrapper = ({ children }) => ( - <> - - - - -
{children}
-
-
-
-
- - ); - const fhirBaseURL = 'https://test.server'; - const resourceType = 'Location'; - - nock(fhirBaseURL) - .get(`/${resourceType}/_search`) - .query({ - _summary: 'count', - }) - .reply(200, emptyPage) - .persist(); - nock(fhirBaseURL).get(`/${resourceType}/_search`).query({}).reply(200, emptyPage).persist(); - - const { result } = renderHook(() => useClientSideActionsDataGrid(fhirBaseURL, resourceType, {}), { - wrapper, - }); - - // check initial state - expect(result.error).toBeUndefined(); - expect(result.current.filterOptions).toMatchObject({ - updateFilter: expect.any(Function), - currentFilters: {}, - }); - await flushPromises(); - await waitFor(() => { - // confirm that the request resolved - expect(result.current.queryValues.error).toBeNull(); - expect(result.current.queryValues.data).toEqual([]); - }); - - act(() => { - result.current.filterOptions.updateFilter('name', { - operand: 'includes', - value: 'petName', - caseSensitive: true, - }); - }); - await flushPromises(); - expect(result.current.filterOptions.currentFilters).toEqual({ - name: { - caseSensitive: true, - operand: 'includes', - value: 'petName', - }, - }); - - // with name sorted in ascend - act(() => { - result.current.filterOptions.updateFilter('name'); - }); - await flushPromises(); - expect(result.current.filterOptions.currentFilters).toEqual({}); - expect(nock.pendingMocks()).toEqual([]); -}); - -test('useClientSideActionsDataGrid retains initial filter values', async () => { - history.push('/'); - - const wrapper = ({ children }) => ( - <> - - - - -
{children}
-
-
-
-
- - ); - const fhirBaseURL = 'https://test.server'; - const resourceType = 'Location'; - - nock(fhirBaseURL) - .get(`/${resourceType}/_search`) - .query({ - _summary: 'count', - }) - .reply(200, emptyPage) - .persist(); - nock(fhirBaseURL).get(`/${resourceType}/_search`).query({}).reply(200, emptyPage).persist(); - - const { result } = renderHook( - () => - useClientSideActionsDataGrid(fhirBaseURL, resourceType, {}, undefined, undefined, { - name: { - operand: 'includes', - value: 'someValue', - }, - }), - { wrapper } - ); - - // check initial state - expect(result.error).toBeUndefined(); - expect(result.current.filterOptions).toMatchObject({ - updateFilter: expect.any(Function), - currentFilters: {}, - }); - await flushPromises(); - await waitFor(() => { - expect(nock.pendingMocks()).toEqual([]); - // confirm that the request resolved - expect(result.current.queryValues.error).toBeNull(); - expect(result.current.queryValues.data).toEqual([]); - }); - expect(result.current.filterOptions.currentFilters).toEqual({ - name: { operand: 'includes', value: 'someValue' }, - }); - - // with name sorted in ascend - act(() => { - result.current.filterOptions.updateFilter('name'); - }); - await flushPromises(); - expect(result.current.filterOptions.currentFilters).toEqual({}); - expect(nock.pendingMocks()).toEqual([]); -}); diff --git a/packages/react-utils/src/hooks/tests/useClientSideDataGridFilters.test.tsx b/packages/react-utils/src/hooks/tests/useClientSideDataGridFilters.test.tsx new file mode 100644 index 000000000..41cfd9bbf --- /dev/null +++ b/packages/react-utils/src/hooks/tests/useClientSideDataGridFilters.test.tsx @@ -0,0 +1,91 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useClientSideDataGridFilters } from '../useClientSideDataGridFilters'; +import React from 'react'; +import { sampleData } from './fixtures'; + +/** + * tests that need updating + * filters are added correctly and then removed + */ + +test('client data grid filters work correctly', async () => { + const wrapper = ({ children }) =>
{children}
; + + const { result } = renderHook(() => useClientSideDataGridFilters(sampleData, {}), { + wrapper, + }); + + // check initial state + expect(result.error).toBeUndefined(); + expect(result.current).toMatchObject({ + registerFilter: expect.any(Function), + filteredData: sampleData, + filterRegistry: {}, + deregisterFilter: expect.any(Function), + }); + + // Add first name filter + const firstNameFilter = (element) => element.first_name.includes('el'); + act(() => { + result.current.registerFilter('first_name', firstNameFilter, 'el'); + }); + expect(result.current).toMatchObject({ + registerFilter: expect.any(Function), + filteredData: sampleData.filter(firstNameFilter), + filterRegistry: { + first_name: { + filterFunc: expect.any(Function), + value: 'el', + }, + }, + deregisterFilter: expect.any(Function), + }); + + // Add last name filter + const lastNameFilter = (element) => element.last_name.includes('ob'); + act(() => { + result.current.registerFilter('last_name', lastNameFilter, 'ob'); + }); + expect(result.current).toMatchObject({ + registerFilter: expect.any(Function), + filteredData: sampleData.filter(firstNameFilter).filter(lastNameFilter), + filterRegistry: { + first_name: { + filterFunc: expect.any(Function), + value: 'el', + }, + last_name: { + filterFunc: expect.any(Function), + value: 'ob', + }, + }, + deregisterFilter: expect.any(Function), + }); + + // deregister filter + act(() => { + result.current.registerFilter('first_name'); + }); + expect(result.current).toMatchObject({ + registerFilter: expect.any(Function), + filteredData: sampleData.filter(lastNameFilter), + filterRegistry: { + last_name: { + filterFunc: expect.any(Function), + value: 'ob', + }, + }, + deregisterFilter: expect.any(Function), + }); + + // deregister filter + act(() => { + result.current.deregisterFilter('last_name'); + }); + expect(result.current).toMatchObject({ + registerFilter: expect.any(Function), + filteredData: sampleData, + filterRegistry: {}, + deregisterFilter: expect.any(Function), + }); +}); diff --git a/packages/react-utils/src/hooks/useClientSideActonsDataGrid.ts b/packages/react-utils/src/hooks/useClientSideActonsDataGrid.ts index 0f0ad9ff5..ed6149beb 100644 --- a/packages/react-utils/src/hooks/useClientSideActonsDataGrid.ts +++ b/packages/react-utils/src/hooks/useClientSideActonsDataGrid.ts @@ -9,7 +9,6 @@ import { URLParams } from '@opensrp/server-service'; import { loadAllResources } from '../helpers/fhir-utils'; import { Filter, - FilterDescription, checkFilter, getNextUrlOnSearch, getNumberParam, @@ -21,6 +20,7 @@ import { startingPage, startingPageSize, } from './utils'; +import { useClientSideDataGridFilters, FilterDescription } from './useClientSideDataGridFilters'; /** * Re-usable hook that abstracts search and table pagination for usual list view component @@ -36,17 +36,14 @@ export function useClientSideActionsDataGrid( fhirBaseUrl: string, resourceType: string, extraParams: URLParams | ((search: string | null) => URLParams) = {}, - matchesSearch: (obj: T, search: string) => boolean = matchesOnName, dataTransformer: (response: IBundle) => T[] = getResourcesFromBundle, - initialFilters: FilterDescription = {} + initialFilters: FilterDescription = {} ) { const location = useLocation(); const history = useHistory(); const match = useRouteMatch(); - const [filters, setFilters] = useState(initialFilters); const page = getNumberParam(location, pageQuery, startingPage) as number; - const search = getStringParam(location, searchQuery); const defaultPageSize = (getConfig('defaultTablesPageSize') as number | undefined) ?? startingPageSize; const pageSize = getNumberParam(location, pageSizeQuery, defaultPageSize) as number; @@ -72,32 +69,8 @@ export function useClientSideActionsDataGrid( }; const { data, ...restQueryValues } = useQuery(rQuery); - let filteredData = data; - if (search) { - filteredData = data?.filter((obj) => { - return matchesSearch(obj, search); - }); - } - - // Method to apply all filters to the data - filteredData = useMemo(() => { - const filtered = []; - for (const item of filteredData ?? []) { - let fullyChecked = true; - for (const accessor in filters) { - const filterDescription = filters[accessor] as Filter; - const filterResult = checkFilter(item, accessor, filterDescription); - if (filterResult) { - fullyChecked = false; - break; - } - } - if (fullyChecked) { - filtered.push(item); - } - } - return filtered; - }, [data, filters]); + const { filteredData, filterRegistry, registerFilter, deregisterFilter } = + useClientSideDataGridFilters(data, initialFilters); const searchFormProps = { defaultValue: getQueryParams(location)[searchQuery], @@ -107,22 +80,6 @@ export function useClientSideActionsDataGrid( }, }; - // Method to update the filters - const updateFilter = useCallback((accessor: string, filter?: Filter) => { - setFilters((prevFilters) => { - const newFilters = { ...prevFilters }; - if (filter === undefined) { - delete newFilters[accessor]; - return newFilters; - } else { - return { - ...newFilters, - [accessor]: filter, - }; - } - }); - }, []); - const tablePaginationProps = { current: page, pageSize, @@ -141,8 +98,9 @@ export function useClientSideActionsDataGrid( return { tablePaginationProps, filterOptions: { - updateFilter, - currentFilters: filters, + registerFilter, + filterRegistry, + deregisterFilter, }, queryValues: { data: filteredData, diff --git a/packages/react-utils/src/hooks/useClientSideDataGridFilters.ts b/packages/react-utils/src/hooks/useClientSideDataGridFilters.ts new file mode 100644 index 000000000..7e03f73fe --- /dev/null +++ b/packages/react-utils/src/hooks/useClientSideDataGridFilters.ts @@ -0,0 +1,62 @@ +import { useCallback, useMemo, useState } from 'react'; + +export type FilterFunc = (element: T) => boolean; +export interface FilterDescription { + [key: string]: { + value?: ValueT; + filterFunc: FilterFunc; + }; +} + +/** + * hook to dynamically manage data filters + * + * @param data - data to be filtered + * @param initialFilterState - + */ +export function useClientSideDataGridFilters( + data: T[] = [], + initialFilterState: FilterDescription = {} +) { + const [filterRegistry, setFilterRegistry] = useState>(initialFilterState); + + const registerFilter = useCallback((key: string, filterFunc?: FilterFunc, value?: unknown) => { + if (filterFunc) { + setFilterRegistry((prev) => ({ ...prev, [key]: { value, filterFunc } })); + } else { + setFilterRegistry((prev) => { + delete prev[key]; + return { ...prev }; + }); + } + }, []); + + const deregisterFilter = useCallback( + (key: string) => { + registerFilter(key); + }, + [registerFilter] + ); + + const filteredData = useMemo(() => { + const filters = Object.values(filterRegistry); + + const filteredData = []; + dataLoop: for (const item of data) { + for (const filter of filters) { + if (!filter.filterFunc(item)) { + continue dataLoop; + } + } + filteredData.push(item); + } + return filteredData; + }, [filterRegistry, data]); + + return { + registerFilter, + filteredData, + filterRegistry, + deregisterFilter, + }; +} From 368e2eb93f239286512ecb5ce3a9508fbf7b2d22 Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Thu, 25 Jul 2024 16:31:37 +0300 Subject: [PATCH 03/10] Add accountability filter to inventory view details --- .../ViewDetails/DetailsTabs/Inventory.tsx | 93 +++++++++++++++++-- .../DetailsTabs/tests/detailsTabs.test.tsx | 17 +++- .../fhir-location-management/src/constants.ts | 2 + 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx b/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx index 6bc56b3c0..24dc813a1 100644 --- a/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx +++ b/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ChangeEvent } from 'react'; import { useMls } from '../../../mls'; import { TableLayout, @@ -6,9 +6,9 @@ import { SearchForm, Column, } from '@opensrp/react-utils'; -import { Alert, Button, Col, Divider, Row } from 'antd'; +import { Alert, Button, Col, Divider, Radio, Row, Space } from 'antd'; import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; -import { listResourceType } from '../../../constants'; +import { accEndDateFilterKey, listResourceType, nameFilterKey } from '../../../constants'; import { IBundle } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IBundle'; import { RbacCheck } from '@opensrp/rbac'; import { Link, useHistory } from 'react-router-dom'; @@ -165,6 +165,22 @@ function matchesSearch(obj: TableData, search: string) { return (obj.productName ?? '').toLowerCase().includes(search.toLowerCase()); } +/** + * filter products based on accountability period + * + * @param obj - obj to filter + */ +function activeInventoryByAccEndDate(obj: TableData) { + if (obj.accountabilityEndDate === undefined) { + return true; + } + const currentAccEndDate = Date.parse(obj.accountabilityEndDate); + if (!isNaN(currentAccEndDate)) { + return Date.now() >= currentAccEndDate; + } + return false; +} + export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) => { const { t } = useMls(); const history = useHistory(); @@ -172,7 +188,8 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) = const { queryValues: { data, isLoading, error }, tablePaginationProps, - searchFormProps, + searchFormProps: rawSearchFormProps, + filterOptions: { registerFilter, deregisterFilter, filterRegistry }, } = useTabularViewWithLocalSearch( fhirBaseUrl, listResourceType, @@ -181,11 +198,18 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) = _include: 'List:item', '_include:recurse': 'Group:member', }, - matchesSearch, - dataTransformer + dataTransformer, + { + [accEndDateFilterKey]: { + value: 'active', + filterFunc: (el) => { + return activeInventoryByAccEndDate(el); + }, + }, + } ); - if (error && !data) { + if (error && !data.length) { return {t('An error occurred while fetching inventory')}; } @@ -264,8 +288,27 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) = }, ]; + const searchFormProps = { + ...rawSearchFormProps, + onChangeHandler: (event: ChangeEvent) => { + rawSearchFormProps.onChangeHandler(event); + const searchText = event.target.value; + if (searchText) { + registerFilter( + nameFilterKey, + (el) => { + return matchesSearch(el, searchText); + }, + searchText + ); + } else { + deregisterFilter(nameFilterKey); + } + }, + }; + const tableProps = { - datasource: data ?? [], + datasource: data, columns, loading: isLoading, size: 'small' as const, @@ -276,7 +319,39 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) =
- + + + { + const val = event.target.value; + switch (val) { + case 'active': + registerFilter( + accEndDateFilterKey, + (el) => { + return activeInventoryByAccEndDate(el); + }, + val + ); + break; + case 'inactive': + registerFilter( + accEndDateFilterKey, + (el) => { + return !activeInventoryByAccEndDate(el); + }, + val + ); + break; + } + }} + > + {t('Active')} + {t('Inactive')} + +
+
+ + + Parent Location: + + allowClear={true} + showSearch={true} + resourceType='Location' + baseUrl={fhirBaseURL} + transformOption={(resource) => { + return { + value: resource.id!, + label: resource.name!, + ref: resource + } + }} + onChange={(value) => { + console.log({value}) + if (!value) { + updateFilterParams({ "partof": undefined as any }) + } else { + updateFilterParams({ + "partOf": { + paramAccessor: "partof", + rawValue: value, + paramValue: value + } + }) + } + }} + /> + + + Status: + { - if (value === "*") { - updateFilterParams({ "status": undefined }) - return - } - updateFilterParams({ - "status": { - paramAccessor: "status", - rawValue: value, - paramValue: value - } - }) - }} - options={[ - { value: 'active', label: 'Active' }, - { value: 'inactive', label: 'Inactive' }, - { value: '*', label: 'Show all' }, - ]} - /> - - -
+
diff --git a/packages/fhir-location-management/src/components/AllLocationListFlat/dataGridFilterRow.tsx b/packages/fhir-location-management/src/components/AllLocationListFlat/dataGridFilterRow.tsx new file mode 100644 index 000000000..9cc6b9045 --- /dev/null +++ b/packages/fhir-location-management/src/components/AllLocationListFlat/dataGridFilterRow.tsx @@ -0,0 +1,86 @@ +import { ILocation } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/ILocation'; +import { Space, Select } from 'antd'; +import React from 'react'; +import { FilterParamState, PaginatedAsyncSelect } from '@opensrp/react-utils'; +import { locationResourceType } from '@opensrp/fhir-helpers'; +import { useMls } from '.././../mls'; +import { Trans } from '@opensrp/i18n'; + +export interface LocationGridFilterRowRenderProps { + fhirBaseUrl: string; + updateFilterParams: (filter: FilterParamState) => void; + currentFilters: FilterParamState; +} + +const partOfFilterDataIdx = 'partof'; +const statusFilterDataIdx = 'status'; + +export const LocationGridFilterRowRender = (props: LocationGridFilterRowRenderProps) => { + const { t } = useMls(); + const { fhirBaseUrl, updateFilterParams, currentFilters } = props; + return ( +
+ + + + Parent Location: + + allowClear={true} + showSearch={true} + resourceType={locationResourceType} + baseUrl={fhirBaseUrl} + transformOption={(resource) => { + return { + value: resource.id as string, + label: resource.name as string, + ref: resource, + }; + }} + onChange={(value) => { + if (!value) { + updateFilterParams({ [partOfFilterDataIdx]: undefined }); + } else { + updateFilterParams({ + [partOfFilterDataIdx]: { + paramAccessor: 'partof', + rawValue: value, + paramValue: value, + }, + }); + } + }} + /> + + + + + Status: + - - + +
); diff --git a/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx b/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx index 945d5e0ed..71f1ca64c 100644 --- a/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx +++ b/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx @@ -318,14 +318,6 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) = const activeValue = 'active'; const inactiveValue = 'inactive'; - const radioOptions = [ - { - value: activeValue, - label: t('Active'), - }, - { value: inactiveValue, label: t('Inactive') }, - ]; - return ( @@ -335,8 +327,33 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) = + onChange={(event) => { + const val = event.target.value; + switch (val) { + case activeValue: + registerFilter( + accEndDateFilterKey, + (el) => { + return activeInventoryByAccEndDate(el); + }, + val + ); + break; + case inactiveValue: + registerFilter( + accEndDateFilterKey, + (el) => { + return !activeInventoryByAccEndDate(el); + }, + val + ); + break; + } + }} + > + {t('Active')} + {t('Inactive')} +