From a3369fa7910d8a730900918fbded6d367a9f5468 Mon Sep 17 00:00:00 2001 From: UladzislauKutarkin <72550466+UladzislauKutarkin@users.noreply.github.com> Date: Fri, 26 Jan 2024 19:03:36 +0400 Subject: [PATCH] UIBULKED-403: Integrate query-plugin with bulk-edit API (#461) --- CHANGELOG.md | 1 + .../BulkEditList/BulkEditList.test.js | 27 +++- .../BulkEditListFilters.js | 26 +++- src/constants/core.js | 5 + src/hooks/api/index.js | 1 + src/hooks/api/useQueryPlugin.js | 50 ++++++++ src/hooks/api/useRecordTypes.js | 23 ++++ src/hooks/api/useRecordTypes.test.js | 63 ++++++++++ src/hooks/useLocationFilters.js | 1 + src/utils/getRecordType.js | 9 ++ src/utils/getRecordType.test.js | 18 +++ test/jest/__mock__/stripesCore.mock.js | 118 +++++++++--------- 12 files changed, 276 insertions(+), 66 deletions(-) create mode 100644 src/hooks/api/useQueryPlugin.js create mode 100644 src/hooks/api/useRecordTypes.js create mode 100644 src/hooks/api/useRecordTypes.test.js create mode 100644 src/utils/getRecordType.js create mode 100644 src/utils/getRecordType.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 842eae30..3f9affa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ * [UIBULKED-400](https://issues.folio.org/browse/UIBULKED-400) Enabling Build query button on Query tab. * [UIBULKED-399](https://issues.folio.org/browse/UIBULKED-399) Update label verbiage on the Identifier tab. * [UIBULKED-407](https://issues.folio.org/browse/UIBULKED-407) When selecting action "Find" in Bulk Edit, the last dropdown on the row moves to the right. +* [UIBULKED-403](https://issues.folio.org/browse/UIBULKED-403) Integrate query-plugin with bulk-edit API. ## [4.0.0](https://github.com/folio-org/ui-bulk-edit/tree/v4.0.0) (2023-10-12) diff --git a/src/components/BulkEditList/BulkEditList.test.js b/src/components/BulkEditList/BulkEditList.test.js index 1eb3908e..708f0622 100644 --- a/src/components/BulkEditList/BulkEditList.test.js +++ b/src/components/BulkEditList/BulkEditList.test.js @@ -5,17 +5,14 @@ import { QueryClientProvider } from 'react-query'; import '../../../test/jest/__mock__'; +import userEvent from '@testing-library/user-event'; +import { useOkapiKy } from '@folio/stripes/core'; import { queryClient } from '../../../test/jest/utils/queryClient'; import { CAPABILITIES, IDENTIFIERS, CRITERIA } from '../../constants'; import { BulkEditList } from './BulkEditList'; -jest.mock('./BulkEditListFilters/BulkEditListFilters', () => { - return { - BulkEditListFilters: jest.fn().mockReturnValue('BulkEditListFilters'), - }; -}); jest.mock('../BulkEditLogs/BulkEditLogs', () => { return jest.fn().mockReturnValue('BulkEditLogs'); }); @@ -24,6 +21,10 @@ jest.mock('./BulkEditListResult', () => { BulkEditListResult: jest.fn().mockReturnValue('BulkEditListResult'), }; }); + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: jest.fn(), +})); jest.mock('./BulkEditListResult/BulkEditManualUploadModal', () => { return { BulkEditManualUploadModal: jest.fn().mockReturnValue('BulkEditManualUploadModal'), @@ -53,7 +54,7 @@ describe('BulkEditList', () => { it('should display Filters pane', async () => { renderBulkEditList({ criteria: CRITERIA.LOGS }); - expect(screen.getByText('BulkEditListFilters')).toBeVisible(); + expect(screen.getByText(/holdings/i)).toBeVisible(); }); it('should display Logs pane when criteria is logs', async () => { @@ -67,4 +68,18 @@ describe('BulkEditList', () => { expect(screen.getByText(/BulkEditListResult/)).toBeVisible(); }); + + it('should display Bulk edit query', async () => { + useOkapiKy.mockReturnValue({ + get: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({}) }), + post: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({}) }), + delete: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({}) }), + }); + renderBulkEditList({ criteria: CRITERIA.QUERY }); + + userEvent.click(screen.getByText(/Get query/)); + userEvent.click(screen.getByText(/Cancel query/)); + + expect(screen.getByText(/holdings/i)).toBeVisible(); + }); }); diff --git a/src/components/BulkEditList/BulkEditListFilters/BulkEditListFilters.js b/src/components/BulkEditList/BulkEditListFilters/BulkEditListFilters.js index 499fd9cb..af6821e5 100644 --- a/src/components/BulkEditList/BulkEditListFilters/BulkEditListFilters.js +++ b/src/components/BulkEditList/BulkEditListFilters/BulkEditListFilters.js @@ -26,7 +26,7 @@ import { import { RootContext } from '../../../context/RootContext'; import { useUpload, - useBulkOperationStart, + useBulkOperationStart, useQueryPlugin, } from '../../../hooks/api'; import { useBulkPermissions, useLocationFilters } from '../../../hooks'; import { LogsFilters } from './LogsFilters/LogsFilters'; @@ -34,6 +34,8 @@ import { getCapabilityOptions, isCapabilityDisabled } from '../../../utils/helpe import FilterTabs from './FilterTabs/FilterTabs'; import Capabilities from './Capabilities/Capabilities'; import { getIsDisabledByPerm } from './utils/getIsDisabledByPerm'; +import { useRecordTypes } from '../../../hooks/api/useRecordTypes'; +import { getRecordType } from '../../../utils/getRecordType'; export const BulkEditListFilters = ({ filters, @@ -65,6 +67,7 @@ export const BulkEditListFilters = ({ const initialCapabilities = search.get('capabilities'); const initialFileName = search.get('fileName'); const initialStep = search.get('step'); + const initialRecordType = search.get('recordTypes'); const logFilters = Object.values(FILTERS).map((el) => search.getAll(el)); const isQuery = criteria === CRITERIA.QUERY; @@ -83,6 +86,7 @@ export const BulkEditListFilters = ({ criteria: CRITERIA.LOGS, fileName: initialFileName, step: initialStep, + recordTypes: initialRecordType, }; const [ @@ -105,6 +109,16 @@ export const BulkEditListFilters = ({ const { fileUpload, isLoading } = useUpload(); const { bulkOperationStart } = useBulkOperationStart(); + const { recordTypes } = useRecordTypes(); + + const { + entityTypeDataSource, + queryDetailsDataSource, + testQueryDataSource, + getParamsSource, + cancelQueryDataSource, + } = useQueryPlugin(initialRecordType); + const handleChange = (value, field) => setFilters(prev => ({ ...prev, [field]: value, })); @@ -118,6 +132,8 @@ export const BulkEditListFilters = ({ recordIdentifier: '', })); + const selected = recordTypes?.find(type => type.label === getRecordType(value))?.id; + history.replace({ pathname: '/bulk-edit', search: buildSearch({ @@ -125,6 +141,7 @@ export const BulkEditListFilters = ({ identifier: null, step: null, fileName: null, + recordTypes: selected }, location.search), }); @@ -312,6 +329,13 @@ export const BulkEditListFilters = ({ componentType="builder" type="query-builder" disabled={isQueryBuilderDisabled} + key={capabilities} + entityTypeDataSource={entityTypeDataSource} + testQueryDataSource={testQueryDataSource} + getParamsSource={getParamsSource} + queryDetailsDataSource={queryDetailsDataSource} + onQueryRunFail={() => {}} + cancelQueryDataSource={cancelQueryDataSource} /> )} diff --git a/src/constants/core.js b/src/constants/core.js index fd826bf5..03907aa0 100644 --- a/src/constants/core.js +++ b/src/constants/core.js @@ -20,6 +20,11 @@ export const CAPABILITIES = { USER: 'USER', }; +export const RECORD_TYPES = { + ITEM: 'Items', + USER: 'Users' +}; + export const IDENTIFIERS = { ID: 'ID', BARCODE: 'BARCODE', diff --git a/src/hooks/api/index.js b/src/hooks/api/index.js index 10f1a360..27ec39ca 100644 --- a/src/hooks/api/index.js +++ b/src/hooks/api/index.js @@ -9,3 +9,4 @@ export * from './useUserGroupsMap'; export * from './usePatronGroup'; export * from './useLoanTypes'; export * from './useBulkOperationDetails'; +export * from './useQueryPlugin'; diff --git a/src/hooks/api/useQueryPlugin.js b/src/hooks/api/useQueryPlugin.js new file mode 100644 index 00000000..6e63911b --- /dev/null +++ b/src/hooks/api/useQueryPlugin.js @@ -0,0 +1,50 @@ +import { useOkapiKy } from '@folio/stripes/core'; + +export const useQueryPlugin = (recordType) => { + const ky = useOkapiKy(); + + const entityTypeDataSource = async () => { + if (recordType) { + const response = ky.get(`entity-types/${recordType}`); + return response.json(); + } + return null; + }; + + const queryDetailsDataSource = async ({ queryId, includeContent, offset, limit }) => { + const searchParams = { + includeResults: includeContent, + offset, + limit + }; + + const response = ky.get(`query/${queryId}`, { searchParams }); + + return response.json(); + }; + + const testQueryDataSource = async ({ fqlQuery }) => { + const response = ky.post('query', { json: { + entityTypeId: recordType, + fqlQuery: JSON.stringify(fqlQuery) + } }); + return response.json(); + }; + + const getParamsSource = async ({ entityTypeId, columnName, searchValue }) => { + const response = ky.get(`entity-types/${entityTypeId}/columns/${columnName}/values?search=${searchValue}`); + return response.json(); + }; + + const cancelQueryDataSource = async ({ queryId }) => { + return ky.delete(`query/${queryId}`); + }; + + return { + entityTypeDataSource, + queryDetailsDataSource, + testQueryDataSource, + getParamsSource, + cancelQueryDataSource, + }; +}; diff --git a/src/hooks/api/useRecordTypes.js b/src/hooks/api/useRecordTypes.js new file mode 100644 index 00000000..19a30cce --- /dev/null +++ b/src/hooks/api/useRecordTypes.js @@ -0,0 +1,23 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; + +const ENTITY_TYPE_HASH = 'entityType'; + +export const useRecordTypes = () => { + const ky = useOkapiKy(); + const { data, isLoading, error } = useQuery({ + queryKey: [ENTITY_TYPE_HASH], + queryFn: async () => { + const response = await ky.get('entity-types'); + + return response.json(); + }, + refetchOnWindowFocus: false, + }); + + return ({ + recordTypes: data, + isLoading, + error + }); +}; diff --git a/src/hooks/api/useRecordTypes.test.js b/src/hooks/api/useRecordTypes.test.js new file mode 100644 index 00000000..15cef392 --- /dev/null +++ b/src/hooks/api/useRecordTypes.test.js @@ -0,0 +1,63 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { act } from '@testing-library/react'; +import { useRecordTypes } from './useRecordTypes'; + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: jest.fn(), +})); + +jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), + useQuery: jest.fn(), +})); + +describe('useRecordTypes', () => { + it('should return recordTypes, loading state, and error state', async () => { + useOkapiKy.mockReturnValue({ + get: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({}) }), + }); + + useQuery.mockReturnValue({ + data: [ + { + 'id': '1', + 'label': 'Loans' + }, + { + 'id': '2', + 'label': 'Users' + }, + { + 'id': '3', + 'label': 'Items' + } + ], + isLoading: false, + error: null, + }); + + let result; + await act(async () => { + result = renderHook(() => useRecordTypes()).result; + }); + + expect(result.current.recordTypes).toEqual([ + { + 'id': '1', + 'label': 'Loans' + }, + { + 'id': '2', + 'label': 'Users' + }, + { + 'id': '3', + 'label': 'Items' + } + ]); + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/src/hooks/useLocationFilters.js b/src/hooks/useLocationFilters.js index c3b4427c..2d6d8f4c 100644 --- a/src/hooks/useLocationFilters.js +++ b/src/hooks/useLocationFilters.js @@ -60,6 +60,7 @@ export const useLocationFilters = ({ criteria: initialFilter.criteria, step: initialFilter.step, fileName: initialFilter.fileName, + recordTypes: initialFilter.recordTypes }), }); }, diff --git a/src/utils/getRecordType.js b/src/utils/getRecordType.js new file mode 100644 index 00000000..04079057 --- /dev/null +++ b/src/utils/getRecordType.js @@ -0,0 +1,9 @@ +import { CAPABILITIES, RECORD_TYPES } from '../constants'; + +export const getRecordType = (capability) => { + if (Object.hasOwn(CAPABILITIES, capability)) { + return RECORD_TYPES[CAPABILITIES[capability]]; + } else { + return null; + } +}; diff --git a/src/utils/getRecordType.test.js b/src/utils/getRecordType.test.js new file mode 100644 index 00000000..1b587111 --- /dev/null +++ b/src/utils/getRecordType.test.js @@ -0,0 +1,18 @@ +import { getRecordType } from './getRecordType'; +import { CAPABILITIES, RECORD_TYPES } from '../constants'; + +describe('getRecordType', () => { + it('should return the correct record type for a valid capability', () => { + // Test each capability + Object.keys(CAPABILITIES).forEach((capability) => { + const result = getRecordType(capability); + expect(result).toEqual(RECORD_TYPES[CAPABILITIES[capability]]); + }); + }); + + it('should return null for an invalid capability', () => { + const invalidCapability = 'INVALID_CAPABILITY'; + const result = getRecordType(invalidCapability); + expect(result).toBeNull(); + }); +}); diff --git a/test/jest/__mock__/stripesCore.mock.js b/test/jest/__mock__/stripesCore.mock.js index a11e8cef..13f06292 100644 --- a/test/jest/__mock__/stripesCore.mock.js +++ b/test/jest/__mock__/stripesCore.mock.js @@ -1,14 +1,11 @@ -import React from 'react'; -import { noop } from 'lodash'; - export const buildStripes = (otherProperties = {}) => ({ actionNames: [], clone: buildStripes, - connect: component => component, + connect: Comp => Comp, config: {}, currency: 'USD', - hasInterface: () => true, - hasPerm: jest.fn(() => true), + hasInterface: jest.fn().mockReturnValue(true), + hasPerm: jest.fn().mockReturnValue(true), locale: 'en-US', logger: { log: () => { }, @@ -18,17 +15,17 @@ export const buildStripes = (otherProperties = {}) => ({ url: 'https://folio-testing-okapi.dev.folio.org', }, plugins: {}, - setBindings: noop, - setCurrency: noop, - setLocale: noop, - setSinglePlugin: noop, - setTimezone: noop, - setToken: noop, + setBindings: () => { }, + setCurrency: () => { }, + setLocale: () => { }, + setSinglePlugin: () => { }, + setTimezone: () => { }, + setToken: () => { }, store: { - getState: noop, - dispatch: noop, - subscribe: noop, - replaceReducer: noop, + getState: () => { }, + dispatch: () => { }, + subscribe: () => { }, + replaceReducer: () => { }, }, timezone: 'UTC', user: { @@ -36,17 +33,19 @@ export const buildStripes = (otherProperties = {}) => ({ user: { id: 'b1add99d-530b-5912-94f3-4091b4d87e2c', username: 'diku_admin', + consortium: { + centralTenantId: 'consortia', + }, }, }, withOkapi: true, ...otherProperties, }); -jest.mock('@folio/stripes/core', () => { - const STRIPES = buildStripes(); +const STRIPES = buildStripes(); - // eslint-disable-next-line react/prop-types - const stripesConnect = Component => ({ mutator, resources, stripes, ...rest }) => { +const mockStripesCore = { + stripesConnect: Component => ({ mutator, resources, stripes, ...rest }) => { const fakeMutator = mutator || Object.keys(Component.manifest).reduce((acc, mutatorName) => { const returnValue = Component.manifest[mutatorName].records ? [] : {}; @@ -56,6 +55,8 @@ jest.mock('@folio/stripes/core', () => { POST: jest.fn().mockReturnValue(Promise.resolve()), DELETE: jest.fn().mockReturnValue(Promise.resolve()), reset: jest.fn(), + update: jest.fn(), + replace: jest.fn(), }; return acc; @@ -71,44 +72,43 @@ jest.mock('@folio/stripes/core', () => { const fakeStripes = stripes || STRIPES; - // eslint-disable-next-line react/prop-types - return ( - - ); - }; - - const withStripes = Component => ({ stripes, ...rest }) => { + return ; + }, + + useOkapiKy: jest.fn(), + + useStripes: () => STRIPES, + + withStripes: Component => ({ stripes, ...rest }) => { const fakeStripes = stripes || STRIPES; - return ( - - ); - }; - - const useStripes = () => STRIPES; - - // eslint-disable-next-line react/jsx-no-useless-fragment - const IfPermission = props => <>{props.children}; - - // eslint-disable-next-line react/jsx-no-useless-fragment - const AppContextMenu = props => <>{props.children()}; - - STRIPES.connect = stripesConnect; - - return { - ...jest.requireActual('@folio/stripes/core'), - stripesConnect, - withStripes, - IfPermission, - AppContextMenu, - useStripes, - }; -}, { virtual: true }); + return ; + }, + + // eslint-disable-next-line react/prop-types + Pluggable: props => <> + + , + + // eslint-disable-next-line react/prop-types + IfPermission: jest.fn(props => <>{props.children}), + + // eslint-disable-next-line react/prop-types + IfInterface: jest.fn(props => <>{props.children}), + + useNamespace: () => ['@folio/inventory'], + + TitleManager: ({ children }) => <>{children}, + + checkIfUserInMemberTenant: () => true, +}; + +jest.mock('@folio/stripes/core', () => ({ + ...jest.requireActual('@folio/stripes/core'), + ...mockStripesCore +}), { virtual: true }); + +jest.mock('@folio/stripes-core', () => ({ + ...jest.requireActual('@folio/stripes-core'), + ...mockStripesCore +}), { virtual: true });