From 494872438f5bbe520b4504d03a1475eb5ecc83e5 Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Sat, 5 Oct 2024 09:24:54 +0300 Subject: [PATCH 1/6] Update gh actions (#1475) * Update gh actions * Update created security issue labels --- .github/workflows/cd-test.yml | 4 +- .github/workflows/docker-docs.yml | 6 +- .github/workflows/docker-publish.yml | 91 ++++++++++++++++------------ 3 files changed, 56 insertions(+), 45 deletions(-) diff --git a/.github/workflows/cd-test.yml b/.github/workflows/cd-test.yml index 5d609804a..8fa7dad40 100644 --- a/.github/workflows/cd-test.yml +++ b/.github/workflows/cd-test.yml @@ -38,12 +38,12 @@ jobs: run: yarn lerna:prepublish - name: Run all tests - run: yarn test --verbose --collectCoverage=true --forceExit --detectOpenHandles + run: yarn test --verbose --collectCoverage=true --forceExit env: NODE_OPTIONS: --max_old_space_size=5120 - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} directory: ./coverage diff --git a/.github/workflows/docker-docs.yml b/.github/workflows/docker-docs.yml index f3572b180..bd110563c 100644 --- a/.github/workflows/docker-docs.yml +++ b/.github/workflows/docker-docs.yml @@ -5,16 +5,16 @@ on: paths: - "docs/fhir-web-docker-deployment.md" branches: - - master + - main jobs: update-docker-hub-documentation: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Update Docker Hub ReadMe - uses: peter-evans/dockerhub-description@v3 + uses: peter-evans/dockerhub-description@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5c02e8b17..9be7928cc 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,7 +7,7 @@ on: # Publish `master` as Docker `master` tag. # See also https://github.com/crazy-max/ghaction-docker-meta#basic branches: - - master + - main # Publish `v1.2.3` tags as releases. tags: @@ -31,7 +31,7 @@ jobs: if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: submodules: recursive @@ -43,63 +43,74 @@ jobs: if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: submodules: recursive -# - name: Set up QEMU -# uses: docker/setup-qemu-action@v1 - - - name: Cache Docker layers - uses: actions/cache@v2 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 - - name: Docker meta - id: docker_meta - uses: crazy-max/ghaction-docker-meta@v1 + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 with: - images: opensrp/web - tag-custom: ${{ github.event.inputs.customTag }} + images: | + opensrp/web + tags: | + type=ref,event=branch,key=main,tag=latest + type=ref,event=branch,pattern=release/*,group=1 + type=ref,event=tag + type=sha + # Add a custom tag if provided through workflow_dispatch input + type=raw,value=${{ github.event.inputs.customTag }} - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Push to Docker Image Repositories - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 id: docker_build with: push: true -# platforms: linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm64 tags: | ${{ steps.docker_meta.outputs.tags }} - ghcr.io/${{ steps.docker_meta.outputs.tags }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache-new - - # Temp fix - # https://github.com/docker/build-push-action/issues/252 - # https://github.com/moby/buildkit/issues/1896 - - name: Move cache - run: | - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache + cache-from: type=gha,scope=${{ github.workflow }} + cache-to: type=gha,mode=max,scope=${{ github.workflow }} - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} + + - name: Scan Docker Image with Docker Scout and Save Report + id: scout + run: | + # Save the Docker Scout report as JSON and Markdown + docker scout cves ${{ steps.meta.outputs.tags }} --output json > scout-report.json + docker scout cves ${{ steps.meta.outputs.tags }} --output markdown > scout-report.md + + - name: Check Docker Scout Scan Result + id: check-scout-result + run: | + # Check if any vulnerabilities are reported in the JSON output + if grep -q '"severity":' scout-report.json; then + echo "Vulnerabilities found in Docker Scout report." + echo "found_vulnerabilities=true" >> $GITHUB_ENV + else + echo "No vulnerabilities found." + echo "found_vulnerabilities=false" >> $GITHUB_ENV + + - name: Create GitHub Issue for Vulnerabilities + if: env.found_vulnerabilities == 'true' + uses: peter-evans/create-issue-from-file@v4 + with: + title: "Docker Scout Vulnerability Report for Image ${{ steps.meta.outputs.tags }}" + content-filepath: scout-report.md + labels: | + "Security Support" + "Bug Report" From db461261841aba08eb4820324cbf5c87b3506b00 Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Sat, 5 Oct 2024 09:30:12 +0300 Subject: [PATCH 2/6] Fix yaml lint issue (#1481) --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9be7928cc..4b79df33e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -47,8 +47,8 @@ jobs: with: submodules: recursive - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 From 8fc3179cdf9d9e3963dc7f7cd9d738868b6bd45f Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Sat, 5 Oct 2024 09:50:32 +0300 Subject: [PATCH 3/6] Fix workflow (#1482) * Fix yaml lint issue * Fix error in step docker metadata workflow --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4b79df33e..46dca1a08 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -80,7 +80,7 @@ jobs: push: true platforms: linux/amd64,linux/arm64 tags: | - ${{ steps.docker_meta.outputs.tags }} + ${{ steps.meta.outputs.tags }} cache-from: type=gha,scope=${{ github.workflow }} cache-to: type=gha,mode=max,scope=${{ github.workflow }} From b152cac558bdebb1e77dd01cacc29fbda9f2bcdf Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Fri, 11 Oct 2024 15:41:33 +0300 Subject: [PATCH 4/6] Revert ci (#1486) * Revert "Fix workflow (#1482)" This reverts commit 8fc3179cdf9d9e3963dc7f7cd9d738868b6bd45f. * Revert "Fix yaml lint issue (#1481)" This reverts commit db461261841aba08eb4820324cbf5c87b3506b00. * Revert "Update gh actions (#1475)" This reverts commit 494872438f5bbe520b4504d03a1475eb5ecc83e5. --- .github/workflows/cd-test.yml | 4 +- .github/workflows/docker-docs.yml | 6 +- .github/workflows/docker-publish.yml | 93 ++++++++++++---------------- 3 files changed, 46 insertions(+), 57 deletions(-) diff --git a/.github/workflows/cd-test.yml b/.github/workflows/cd-test.yml index 8fa7dad40..5d609804a 100644 --- a/.github/workflows/cd-test.yml +++ b/.github/workflows/cd-test.yml @@ -38,12 +38,12 @@ jobs: run: yarn lerna:prepublish - name: Run all tests - run: yarn test --verbose --collectCoverage=true --forceExit + run: yarn test --verbose --collectCoverage=true --forceExit --detectOpenHandles env: NODE_OPTIONS: --max_old_space_size=5120 - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v2 with: token: ${{ secrets.CODECOV_TOKEN }} directory: ./coverage diff --git a/.github/workflows/docker-docs.yml b/.github/workflows/docker-docs.yml index bd110563c..f3572b180 100644 --- a/.github/workflows/docker-docs.yml +++ b/.github/workflows/docker-docs.yml @@ -5,16 +5,16 @@ on: paths: - "docs/fhir-web-docker-deployment.md" branches: - - main + - master jobs: update-docker-hub-documentation: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v2 - name: Update Docker Hub ReadMe - uses: peter-evans/dockerhub-description@v4 + uses: peter-evans/dockerhub-description@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 46dca1a08..5c02e8b17 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,7 +7,7 @@ on: # Publish `master` as Docker `master` tag. # See also https://github.com/crazy-max/ghaction-docker-meta#basic branches: - - main + - master # Publish `v1.2.3` tags as releases. tags: @@ -31,7 +31,7 @@ jobs: if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v2 with: submodules: recursive @@ -43,74 +43,63 @@ jobs: if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v2 with: submodules: recursive - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 +# - name: Set up QEMU +# uses: docker/setup-qemu-action@v1 + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v1 - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 + - name: Docker meta + id: docker_meta + uses: crazy-max/ghaction-docker-meta@v1 with: - images: | - opensrp/web - tags: | - type=ref,event=branch,key=main,tag=latest - type=ref,event=branch,pattern=release/*,group=1 - type=ref,event=tag - type=sha - # Add a custom tag if provided through workflow_dispatch input - type=raw,value=${{ github.event.inputs.customTag }} + images: opensrp/web + tag-custom: ${{ github.event.inputs.customTag }} - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v1 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Push to Docker Image Repositories - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v2 id: docker_build with: push: true - platforms: linux/amd64,linux/arm64 +# platforms: linux/amd64,linux/arm64 tags: | - ${{ steps.meta.outputs.tags }} - cache-from: type=gha,scope=${{ github.workflow }} - cache-to: type=gha,mode=max,scope=${{ github.workflow }} + ${{ steps.docker_meta.outputs.tags }} + ghcr.io/${{ steps.docker_meta.outputs.tags }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + # Temp fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} - - - name: Scan Docker Image with Docker Scout and Save Report - id: scout - run: | - # Save the Docker Scout report as JSON and Markdown - docker scout cves ${{ steps.meta.outputs.tags }} --output json > scout-report.json - docker scout cves ${{ steps.meta.outputs.tags }} --output markdown > scout-report.md - - - name: Check Docker Scout Scan Result - id: check-scout-result - run: | - # Check if any vulnerabilities are reported in the JSON output - if grep -q '"severity":' scout-report.json; then - echo "Vulnerabilities found in Docker Scout report." - echo "found_vulnerabilities=true" >> $GITHUB_ENV - else - echo "No vulnerabilities found." - echo "found_vulnerabilities=false" >> $GITHUB_ENV - - - name: Create GitHub Issue for Vulnerabilities - if: env.found_vulnerabilities == 'true' - uses: peter-evans/create-issue-from-file@v4 - with: - title: "Docker Scout Vulnerability Report for Image ${{ steps.meta.outputs.tags }}" - content-filepath: scout-report.md - labels: | - "Security Support" - "Bug Report" From c781da0c4915fc40cac51cd5280f6c7b707bfbfc Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Fri, 11 Oct 2024 15:42:27 +0300 Subject: [PATCH 5/6] Fix query cache invalidation (#1485) --- .../fhir-group-management/src/components/ProductForm/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fhir-group-management/src/components/ProductForm/index.tsx b/packages/fhir-group-management/src/components/ProductForm/index.tsx index 2d6893873..bd07a3997 100644 --- a/packages/fhir-group-management/src/components/ProductForm/index.tsx +++ b/packages/fhir-group-management/src/components/ProductForm/index.tsx @@ -103,7 +103,7 @@ function CommodityForm< await postSuccess?.(mutationEffectResponse, isEdit).catch((err) => { sendErrorNotification(err.message); }); - queryClient.refetchQueries([groupResourceType]).catch(() => { + queryClient.invalidateQueries([groupResourceType]).catch(() => { sendInfoNotification(t('Failed to refresh data, please refresh the page')); }); goTo(successUrl); From bf6dad9908472f3bec01dd63c77d988641accc76 Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Fri, 11 Oct 2024 15:43:01 +0300 Subject: [PATCH 6/6] Inventory product select update (#1484) * Fix pagination bug in PaginatedAsyncSelect component * Update Async Select component To include one with client side filters and search * Update select component used on the location inventory view --- .../src/components/LocationInventory/form.tsx | 8 +- .../LocationInventory/tests/form.test.tsx | 97 ++++++---- .../ClientSideActionsSelect/index.tsx | 90 +++++++++ .../tests/index.test.tsx | 143 ++++++++++++++ .../PaginatedAsyncSelect/index.tsx | 106 ++++++----- .../PaginatedAsyncSelect/tests/fixtures.ts | 180 ++++++++++++++++++ .../PaginatedAsyncSelect/tests/index.test.tsx | 98 +++++++++- .../src/components/AsyncSelect/index.tsx | 2 + .../{PaginatedAsyncSelect => }/utils.ts | 34 +++- 9 files changed, 659 insertions(+), 99 deletions(-) create mode 100644 packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/index.tsx create mode 100644 packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/tests/index.test.tsx rename packages/react-utils/src/components/AsyncSelect/{PaginatedAsyncSelect => }/utils.ts (65%) diff --git a/packages/fhir-group-management/src/components/LocationInventory/form.tsx b/packages/fhir-group-management/src/components/LocationInventory/form.tsx index 42429566c..0268e7000 100644 --- a/packages/fhir-group-management/src/components/LocationInventory/form.tsx +++ b/packages/fhir-group-management/src/components/LocationInventory/form.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react'; import { Form, Button, Input, DatePicker, Space, Switch } from 'antd'; import { - PaginatedAsyncSelect, formItemLayout, tailLayout, SelectOption as ProductSelectOption, ValueSetAsyncSelect, + ClientSideActionsSelect, } from '@opensrp/react-utils'; import { useTranslation } from '../../mls'; import { useQueryClient, useMutation } from 'react-query'; @@ -162,12 +162,12 @@ const AddLocationInventoryForm = (props: LocationInventoryFormProps) => { initialValues={initialValues} > - - baseUrl={fhirBaseURL} + + fhirBaseUrl={fhirBaseURL} resourceType={groupResourceType} transformOption={processProductOptions} extraQueryParams={productQueryFilters} - showSearch={false} + showSearch={true} placeholder={t('Select product')} getFullOptionOnChange={productChangeHandler} disabled={editMode} diff --git a/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx b/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx index 9ae6f8b83..b6fed5610 100644 --- a/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx +++ b/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx @@ -152,7 +152,13 @@ test('creates new inventory as expected', async () => { const preFetchScope = nock(props.fhirBaseURL) .get(`/${groupResourceType}/_search`) .query({ - _getpagesoffset: 0, + _summary: 'count', + code: 'http://snomed.info/sct|386452003', + '_has:List:item:_id': props.commodityListId, + }) + .reply(200, { total: 20 }) + .get(`/${groupResourceType}/_search`) + .query({ _count: 20, code: 'http://snomed.info/sct|386452003', '_has:List:item:_id': props.commodityListId, @@ -194,29 +200,32 @@ test('creates new inventory as expected', async () => { render(); await waitFor(() => { - expect(preFetchScope.isDone()).toBeTruthy(); + expect(preFetchScope.pendingMocks()).toEqual([]); }); - // simulate value selection for product - const productSelectComponent = document.querySelector(`input#${product}`)!; - fireEvent.mouseDown(productSelectComponent); - - const optionTexts = [ - ...document.querySelectorAll( - `#${product}_list+div.rc-virtual-list .ant-select-item-option-content` - ), - ].map((option) => { - return option.textContent; + await waitFor(() => { + // simulate value selection for product + const productSelectComponent = document.querySelector(`input#${product}`)!; + fireEvent.mouseDown(productSelectComponent); + + const optionTexts = [ + ...document.querySelectorAll( + `#${product}_list+div.rc-virtual-list .ant-select-item-option-content` + ), + ].map((option) => { + return option.textContent; + }); + + expect(optionTexts).toEqual([ + 'Yellow sunshine', + 'Fig tree', + 'Lumpy nuts', + 'Happy Feet', + 'Lilly Flowers', + 'Smartphone TEST', + ]); }); - expect(optionTexts).toEqual([ - 'Yellow sunshine', - 'Fig tree', - 'Lumpy nuts', - 'Happy Feet', - 'Lilly Flowers', - 'Smartphone TEST', - ]); fireEvent.click(document.querySelector(`[title="${'Lumpy nuts'}"]`)!); const quantity = screen.getByLabelText('Quantity'); @@ -269,7 +278,13 @@ test('#1384 - correctly updates location inventory', async () => { const preFetchScope = nock(props.fhirBaseURL) .get(`/${groupResourceType}/_search`) .query({ - _getpagesoffset: 0, + _summary: 'count', + code: 'http://snomed.info/sct|386452003', + '_has:List:item:_id': props.commodityListId, + }) + .reply(200, { total: 20 }) + .get(`/${groupResourceType}/_search`) + .query({ _count: 20, code: 'http://snomed.info/sct|386452003', '_has:List:item:_id': props.commodityListId, @@ -322,26 +337,28 @@ test('#1384 - correctly updates location inventory', async () => { // serial number is initially not shown on the form expect(screen.queryByText('Serial number')).not.toBeInTheDocument(); - // simulate value selection for product - const productSelectComponent = document.querySelector(`input#${product}`)!; - fireEvent.mouseDown(productSelectComponent); - - const optionTexts = [ - ...document.querySelectorAll( - `#${product}_list+div.rc-virtual-list .ant-select-item-option-content` - ), - ].map((option) => { - return option.textContent; + await waitFor(() => { + // simulate value selection for product + const productSelectComponent = document.querySelector(`input#${product}`)!; + fireEvent.mouseDown(productSelectComponent); + + const optionTexts = [ + ...document.querySelectorAll( + `#${product}_list+div.rc-virtual-list .ant-select-item-option-content` + ), + ].map((option) => { + return option.textContent; + }); + + expect(optionTexts).toEqual([ + 'Yellow sunshine', + 'Fig tree', + 'Lumpy nuts', + 'Happy Feet', + 'Lilly Flowers', + 'Smartphone TEST', + ]); }); - - expect(optionTexts).toEqual([ - 'Yellow sunshine', - 'Fig tree', - 'Lumpy nuts', - 'Happy Feet', - 'Lilly Flowers', - 'Smartphone TEST', - ]); fireEvent.click(document.querySelector(`[title="${'Lumpy nuts'}"]`)!); const quantity = screen.getByLabelText('Quantity'); diff --git a/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/index.tsx b/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/index.tsx new file mode 100644 index 000000000..3c576fdaa --- /dev/null +++ b/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/index.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { URLParams } from '@opensrp/server-service'; +import { useQuery } from 'react-query'; +import { Divider, Select, Empty, Spin, Alert } from 'antd'; +import { IResource } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IResource'; +import { getResourcesFromBundle } from '../../../helpers/utils'; +import { useTranslation } from '../../../mls'; +import { loadAllResources } from '../../../helpers/fhir-utils'; +import { + AbstractedSelectOptions, + defaultSelectFilterFunction, + SelectOption, + TransformOptions, +} from '../utils'; + +export interface ClientSideActionsSelectProps + extends AbstractedSelectOptions { + fhirBaseUrl: string; + resourceType: string; + extraQueryParams?: URLParams; + transformOption: TransformOptions; + getFullOptionOnChange?: (obj: SelectOption | SelectOption[]) => void; +} + +/** + * Select component that loads all options as a single resource + * + * @param props - component props + */ +export function ClientSideActionsSelect( + props: ClientSideActionsSelectProps +) { + const { + fhirBaseUrl, + resourceType, + extraQueryParams = {}, + transformOption, + onChange, + getFullOptionOnChange, + ...restProps + } = props; + + const { t } = useTranslation(); + + const { + data: options, + isLoading, + error, + } = useQuery({ + queryKey: [ClientSideActionsSelect.name, resourceType], + queryFn: async () => { + return await loadAllResources(fhirBaseUrl, resourceType, extraQueryParams); + }, + refetchOnWindowFocus: false, + select: (bundle) => { + const options = getResourcesFromBundle(bundle).map((resource) => + transformOption(resource) + ); + return options as SelectOption[]; + }, + }); + + const changeHandler = ( + value: string, + fullOption: SelectOption | SelectOption[] + ) => { + const saneFullOption = Array.isArray(fullOption) ? fullOption.slice() : fullOption; + props.onChange?.(value, saneFullOption); + getFullOptionOnChange?.(saneFullOption); + }; + + const propsToSelect = { + className: 'asyncSelect', + filterOption: defaultSelectFilterFunction, + ...restProps, + onChange: changeHandler, + loading: isLoading, + notFoundContent: isLoading ? : , + options, + dropdownRender: (menu: React.ReactNode) => ( + <> + {!error && options?.length && menu} + + {error && } + + ), + }; + + return ; +} diff --git a/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/tests/index.test.tsx b/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/tests/index.test.tsx new file mode 100644 index 000000000..11488bd00 --- /dev/null +++ b/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/tests/index.test.tsx @@ -0,0 +1,143 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import React from 'react'; +import * as reactQuery from 'react-query'; +import { ClientSideActionsSelect } from '../index'; +import { IOrganization } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IOrganization'; +import nock from 'nock'; +import { store } from '@opensrp/store'; +import { authenticateUser } from '@onaio/session-reducer'; +import flushPromises from 'flush-promises'; +import { + organizationsPage1, + organizationsPage1Summary, +} from '../../PaginatedAsyncSelect/tests/fixtures'; +import userEvent from '@testing-library/user-event'; + +const organizationResourceType = 'Organization'; + +jest.mock('fhirclient', () => { + return jest.requireActual('fhirclient/lib/entry/browser'); +}); + +const { QueryClient, QueryClientProvider } = reactQuery; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +export const QueryWrapper = ({ children }: { children: JSX.Element }) => ( + {children} +); + +beforeAll(() => { + nock.disableNetConnect(); + store.dispatch( + authenticateUser( + true, + { + email: 'bob@example.com', + name: 'Bobbie', + username: 'RobertBaratheon', + }, + { api_token: 'hunter2', oAuth2Data: { access_token: 'sometoken', state: 'abcde' } } + ) + ); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +afterEach(() => { + nock.cleanAll(); + cleanup(); + jest.resetAllMocks(); +}); + +const commonProps = { + fhirBaseUrl: 'https://sample.com', + resourceType: organizationResourceType, + transformOption: (resource: IOrganization) => { + const { name } = resource; + const id = resource.id as string; + return { + label: name ?? id, + value: id, + ref: resource, + }; + }, +}; + +test('works correctly nominal case', async () => { + nock(commonProps.fhirBaseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _count: '10' }) + .reply(200, organizationsPage1); + + nock(commonProps.fhirBaseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _summary: 'count' }) + .reply(200, organizationsPage1Summary); + + const changeMock = jest.fn(); + const fullOptionHandlerMock = jest.fn(); + + const props = { + ...commonProps, + onChange: changeMock, + getFullOptionOnChange: fullOptionHandlerMock, + }; + + render( + + {...props}> + + ); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // click on input. - should see the first 5 records by default + const input = document.querySelector('.ant-select-selector') as Element; + + // simulate click on select - to show dropdown items + fireEvent.mouseDown(input); + + // find antd select options + const selectOptions = document.querySelectorAll('.ant-select-item-option-content'); + + await flushPromises(); + // expect all practitioners (except inactive ones) + expect([...selectOptions].map((opt) => opt.textContent)).toStrictEqual([ + '高雄榮民總醫院', + 'Blok Operacyjny Chirurgii Naczyń', + 'Volunteer virtual hospital 志工虛擬醫院', + 'Volunteer virtual hospital 志工虛擬醫院', + 'Volunteer virtual hospital 志工虛擬醫院', + ]); + + // search and then select. + userEvent.type(input.querySelector('input') as Element, 'Blok'); + + fireEvent.click(screen.getByTitle('Blok Operacyjny Chirurgii Naczyń') as Element); + + const blokOrgId = '22332'; + const blokOrganizationFullOption = { + value: '22332', + ref: organizationsPage1.entry[1].resource, + label: 'Blok Operacyjny Chirurgii Naczyń', + }; + + expect(changeMock).toHaveBeenCalledWith(blokOrgId, blokOrganizationFullOption); + expect(fullOptionHandlerMock).toHaveBeenCalledWith(blokOrganizationFullOption); +}); diff --git a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/index.tsx b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/index.tsx index 25ad2497e..9597c5d6b 100644 --- a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/index.tsx +++ b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/index.tsx @@ -4,27 +4,18 @@ import { IBundle } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IBundle'; import { useInfiniteQuery, useQuery } from 'react-query'; import { VerticalAlignBottomOutlined } from '@ant-design/icons'; import { Button, Divider, Select, Empty, Space, Spin, Alert } from 'antd'; -import type { SelectProps } from 'antd'; import { IResource } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IResource'; import { debounce } from 'lodash'; import { getResourcesFromBundle } from '../../../helpers/utils'; import { useTranslation } from '../../../mls'; -import { loadResources, getTotalRecordsInBundles, getTotalRecordsOnApi } from './utils'; - -export type SelectOption = { - label: string; - value: string | number; - ref: T; -}; - -export interface TransformOptions { - (resource: T): SelectOption | undefined; -} - -export type AbstractedSelectOptions = Omit< - SelectProps>, - 'loading' | 'options' | 'searchValue' ->; +import { + loadSearchableResources, + getTotalRecordsInBundles, + getTotalRecordsOnApi, + AbstractedSelectOptions, + SelectOption, + TransformOptions, +} from '../utils'; export interface PaginatedAsyncSelectProps extends AbstractedSelectOptions { @@ -76,38 +67,51 @@ export function PaginatedAsyncSelect( }); }, [searchValue]); - const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage, isFetching, error } = - useInfiniteQuery({ - queryKey: [resourceType, debouncedSearchValue, page, pageSize], - queryFn: async ({ pageParam = page }) => { - const response = await loadResources( - baseUrl, - resourceType, - { page: pageParam, pageSize, search: debouncedSearchValue ?? null }, - extraQueryParams - ); - return response; - }, - getNextPageParam: (lastGroup: IBundle, allGroups: IBundle[]) => { - const totalFetched = getTotalRecordsInBundles(allGroups); - const total = lastGroup.total as number; - if (totalFetched < total) { - return page + 1; - } else { - return false; - } - }, - getPreviousPageParam: () => { - if (page === 1) { - return undefined; - } else { - return page - 1; - } - }, - refetchOnWindowFocus: false, - }); + type PageResponse = { res: IBundle; page: number; pageSize: number }; + const { + data: rawData, + fetchNextPage, + hasNextPage, + isLoading, + isFetchingNextPage, + isFetching, + error, + } = useInfiniteQuery({ + queryKey: [resourceType, debouncedSearchValue, page, pageSize], + queryFn: async ({ pageParam = page }) => { + const response = await loadSearchableResources( + baseUrl, + resourceType, + { page: pageParam, pageSize, search: debouncedSearchValue ?? null }, + extraQueryParams + ).then((res) => ({ res, page: pageParam, pageSize })); + return response; + }, + getNextPageParam: (lastGroup: PageResponse, allGroups: PageResponse[]) => { + const allBundles = allGroups.map((group) => group.res); + const totalFetched = getTotalRecordsInBundles(allBundles); + const total = lastGroup.res.total as number; + const nextPage = lastGroup.page + 1; + if (totalFetched < total) { + return nextPage; + } else { + return false; + } + }, + getPreviousPageParam: (lastGroup: PageResponse) => { + const nextPage = lastGroup.page - 1; + if (nextPage === 1) { + return undefined; + } else { + return nextPage; + } + }, + refetchOnWindowFocus: false, + }); + + const data = rawData?.pages.map((page) => page.res) ?? []; - const options = ((data?.pages ?? []) as IBundle[]).flatMap((resourceBundle: IBundle) => { + const options = data.flatMap((resourceBundle: IBundle) => { const resources = getResourcesFromBundle(resourceBundle); const allOptions = resources.map(transformOption); const saneOptions = allOptions.filter((option) => option !== undefined); @@ -167,7 +171,7 @@ export function PaginatedAsyncSelect( setSearchValue(value); }; - const pages = (data?.pages ?? []) as IBundle[]; + const pages = data; const recordsFetchedNum = getTotalRecordsInBundles(pages); const totalPossibleRecords = getTotalRecordsOnApi(pages); const remainingRecords = totalPossibleRecords - recordsFetchedNum; @@ -184,13 +188,13 @@ export function PaginatedAsyncSelect( searchValue, dropdownRender: (menu: React.ReactNode) => ( <> - {!error && data && menu} + {!error && data.length && menu} {error ? ( ) : ( - {data && ( + {data.length && ( {t('Showing {{recordsFetchedNum}}; {{remainingRecords}} more records.', { recordsFetchedNum, diff --git a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/fixtures.ts b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/fixtures.ts index 897af2d52..d64fc4626 100644 --- a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/fixtures.ts +++ b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/fixtures.ts @@ -376,3 +376,183 @@ export const organizationsPage1Summary = { type: 'searchset', total: 10, }; + +export const pageSummary = { + resourceType: 'Bundle', + id: '73d0c5dd-8446-453f-a7be-badb4bac22c8', + meta: { + lastUpdated: '2023-01-31T09:06:30.352+00:00', + tag: [ + { + system: 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue', + code: 'SUBSETTED', + display: 'Resource encoded in summary mode', + }, + ], + }, + type: 'searchset', + total: 18, +}; + +export const firstDefaultPage = { + resourceType: 'Bundle', + id: 'b3fcfa95-cc95-4a44-8bf5-abd68292945e', + meta: { + lastUpdated: '2023-01-31T08:38:11.517+00:00', + }, + type: 'searchset', + total: 18, + entry: [ + { + fullUrl: 'https://hapi.fhir.org/baseR4/Organization/1839', + resource: { + resourceType: 'Organization', + id: '1839', + meta: { + versionId: '1', + lastUpdated: '2019-09-21T01:13:54.367+00:00', + source: '#899bf40a941da002', + }, + type: [ + { + coding: [ + { + system: 'http://hl7.org/fhir/organization-type', + code: 'prov', + display: 'Healthcare Provider', + }, + ], + text: 'Healthcare Provider', + }, + ], + name: '高雄榮民總醫院', + }, + search: { + mode: 'match', + }, + }, + ], +}; + +export const secondPage = { + resourceType: 'Bundle', + id: 'b3fcfa95-cc95-4a44-8bf5-abd68292945e', + meta: { + lastUpdated: '2023-01-31T08:38:11.517+00:00', + }, + type: 'searchset', + total: 18, + entry: [ + { + fullUrl: 'https://hapi.fhir.org/baseR4/Organization/30099', + resource: { + resourceType: 'Organization', + id: '30099', + meta: { + versionId: '1', + lastUpdated: '2019-09-26T13:14:11.303+00:00', + source: '#20dc8ea0e407f070', + }, + active: true, + name: 'Hospital Krel Tarron', + }, + search: { + mode: 'match', + }, + }, + ], +}; + +export const thirdPage = { + resourceType: 'Bundle', + id: 'b3fcfa95-cc95-4a44-8bf5-abd68292945e', + meta: { + lastUpdated: '2023-01-31T08:38:11.517+00:00', + }, + type: 'searchset', + total: 18, + entry: [ + { + fullUrl: 'https://hapi.fhir.org/baseR4/Organization/31863', + resource: { + resourceType: 'Organization', + id: '31863', + meta: { + versionId: '1', + lastUpdated: '2019-09-27T12:41:52.007+00:00', + source: '#6b29cdf4ae6b69bd', + }, + text: { + status: 'generated', + div: '
\n Health Level Seven International\n
\n\t\t\t\t3300 Washtenaw Avenue, Suite 227\n
\n\t\t\t\tAnn Arbor, MI 48104\n
\n\t\t\t\tUSA\n
\n\t\t\t\t(+1) 734-677-7777 (phone)\n
\n\t\t\t\t(+1) 734-677-6622 (fax)\n
\n\t\t\t\tE-mail: \n hq@HL7.org\n \n
', + }, + name: 'Health Level Seven International', + alias: ['HL7 International'], + telecom: [ + { + system: 'phone', + value: '(+1) 734-677-7777', + }, + { + system: 'fax', + value: '(+1) 734-677-6622', + }, + { + system: 'email', + value: 'hq@HL7.org', + }, + ], + address: [ + { + line: ['3300 Washtenaw Avenue, Suite 227'], + city: 'Ann Arbor', + state: 'MI', + postalCode: '48104', + country: 'USA', + }, + ], + }, + search: { + mode: 'match', + }, + }, + ], +}; + +export const fourthPage = { + resourceType: 'Bundle', + id: 'b3fcfa95-cc95-4a44-8bf5-abd68292945e', + meta: { + lastUpdated: '2023-01-31T08:38:11.517+00:00', + }, + type: 'searchset', + total: 18, + entry: [ + { + fullUrl: 'https://hapi.fhir.org/baseR4/Organization/30165', + resource: { + resourceType: 'Organization', + id: '30165', + meta: { + versionId: '1', + lastUpdated: '2019-09-26T14:34:41.185+00:00', + source: '#8c99c9b0e07e31fd', + }, + text: { + status: 'generated', + div: '
clinFhir
', + }, + identifier: [ + { + system: 'http://fhir.hl7.org.nz/identifier', + value: 'cf', + }, + ], + name: 'clinFHIR Sample creator', + }, + search: { + mode: 'match', + }, + }, + ], +}; diff --git a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/index.test.tsx b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/index.test.tsx index c9877d6a8..8fd2761fc 100644 --- a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/index.test.tsx +++ b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/index.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, + screen, waitFor, waitForElementToBeRemoved, } from '@testing-library/react'; @@ -12,7 +13,15 @@ import { IOrganization } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IOrgani import nock from 'nock'; import { store } from '@opensrp/store'; import { authenticateUser } from '@onaio/session-reducer'; -import { organizationsPage1, organizationsPage1Summary, organizationsPage2 } from './fixtures'; +import { + firstDefaultPage, + fourthPage, + organizationsPage1, + organizationsPage1Summary, + organizationsPage2, + secondPage, + thirdPage, +} from './fixtures'; import flushPromises from 'flush-promises'; const organizationResourceType = 'Organization'; @@ -205,6 +214,93 @@ test('works correctly nominal case', async () => { expect(fullOptionHandlerMock).toHaveBeenCalledWith(tarronHospital); }); +test('paginating the infinity query works ok.', async () => { + nock(commonProps.baseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _getpagesoffset: '0', _count: '5' }) + .reply(200, firstDefaultPage); + + const props = { + ...commonProps, + }; + + render( + + {...props}> + + ); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // // click on input. - should see the first 5 records by default + const input = document.querySelector('.ant-select-selector') as Element; + // simulate click on select - to show dropdown items + fireEvent.mouseDown(input); + + // load more button + const loadMoreButton = screen.getByRole('button', { name: /Load more options/ }); + + // load second page of data. + nock(commonProps.baseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _getpagesoffset: '5', _count: '5' }) + .reply(200, secondPage); + + fireEvent.click(loadMoreButton); + + await waitFor(() => { + expect(screen.queryByText(/Fetching next page/)).toBeInTheDocument(); + }); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // load third page of data. + nock(commonProps.baseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _getpagesoffset: '10', _count: '5' }) + .reply(200, thirdPage); + + fireEvent.click(loadMoreButton); + + await waitFor(() => { + expect(screen.queryByText(/Fetching next page/)).toBeInTheDocument(); + }); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // load fourth and last page of data. + nock(commonProps.baseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _getpagesoffset: '15', _count: '5' }) + .reply(200, fourthPage); + + fireEvent.click(loadMoreButton); + + await waitFor(() => { + expect(screen.queryByText(/Fetching next page/)).toBeInTheDocument(); + }); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // find antd select options + const selectOptions = document.querySelectorAll('.ant-select-item-option-content'); + + await flushPromises(); + // expect all practitioners (except inactive ones) + expect([...selectOptions].map((opt) => opt.textContent)).toStrictEqual([ + '高雄榮民總醫院', + 'Hospital Krel Tarron', + 'clinFHIR Sample creator', + 'Health Level Seven International', + ]); + + // how many records + const recordsText = screen.queryByText(/Showing\s*4\s*\s*;\s*14\s*more records/); + expect(recordsText).toBeInTheDocument(); + + expect(nock.pendingMocks()).toEqual([]); +}); + test('handles error in request', async () => { nock(commonProps.baseUrl) .get(`/${organizationResourceType}/_search`) diff --git a/packages/react-utils/src/components/AsyncSelect/index.tsx b/packages/react-utils/src/components/AsyncSelect/index.tsx index fde192088..26eed9f86 100644 --- a/packages/react-utils/src/components/AsyncSelect/index.tsx +++ b/packages/react-utils/src/components/AsyncSelect/index.tsx @@ -1,3 +1,5 @@ export * from './BaseAsyncSelect'; export * from './PaginatedAsyncSelect'; export * from './ValueSetAsyncSelect'; +export * from './ClientSideActionsSelect'; +export * from './utils'; diff --git a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/utils.ts b/packages/react-utils/src/components/AsyncSelect/utils.ts similarity index 65% rename from packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/utils.ts rename to packages/react-utils/src/components/AsyncSelect/utils.ts index 19326ff6d..bd350e528 100644 --- a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/utils.ts +++ b/packages/react-utils/src/components/AsyncSelect/utils.ts @@ -1,7 +1,10 @@ import { URLParams } from '@opensrp/server-service'; import { IBundle } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IBundle'; -import { FHIRServiceClass } from '../../../helpers/dataLoaders'; -import { FhirApiFilter } from '../../../helpers/utils'; +import { FHIRServiceClass } from '../../helpers/dataLoaders'; +import { FhirApiFilter } from '../../helpers/utils'; +import { DefaultOptionType } from 'antd/lib/select'; +import type { SelectProps } from 'antd'; +import { IResource } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IResource'; /** * Unified function that gets a list of FHIR resources from a FHIR hapi server @@ -11,7 +14,7 @@ import { FhirApiFilter } from '../../../helpers/utils'; * @param params - our params * @param extraParams - any extra user-defined params */ -export const loadResources = async ( +export const loadSearchableResources = async ( baseUrl: string, resourceType: string, params: FhirApiFilter, @@ -64,3 +67,28 @@ export const getTotalRecordsInBundles = (bundles: IBundle[]) => { .reduce((a, v) => a + v, 0) ); }; + +/** + * filter select on search + * + * @param inputValue search term + * @param option select option to filter against + */ +export const defaultSelectFilterFunction = (inputValue: string, option?: DefaultOptionType) => { + return !!option?.label?.toString()?.toLowerCase().includes(inputValue.toLowerCase()); +}; + +export type SelectOption = { + label: string; + value: string | number; + ref: T; +}; + +export interface TransformOptions { + (resource: T): SelectOption | undefined; +} + +export type AbstractedSelectOptions = Omit< + SelectProps>, + 'loading' | 'options' | 'searchValue' +>;