diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e80a847..cb73c41eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ * *BREAKING* bump `react-intl` to `v6.4.4`. Refs UIU-2946. * Generate "Create request" url for users without barcode. Refs UIU-2869. * Add auto focus to textarea on staff and patron info modal. Fixes UIU-2932. +* ECS - Filter users by "User Type". Refs UIU-2943. ## [9.0.0](https://github.com/folio-org/ui-users/tree/v9.0.0) (2023-02-20) [Full Changelog](https://github.com/folio-org/ui-users/compare/v8.1.0...v9.0.0) diff --git a/src/components/util/util.js b/src/components/util/util.js index 52d58b736..53a4da7ba 100644 --- a/src/components/util/util.js +++ b/src/components/util/util.js @@ -2,6 +2,7 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import { every } from 'lodash'; import queryString from 'query-string'; + import { NoValue } from '@folio/stripes/components'; import { @@ -176,11 +177,15 @@ export function checkUserActive(user) { export const getContributors = (account, instance) => { const contributors = account?.contributors || instance?.contributors; - return contributors && contributors.map(({ name }) => name); + return contributors?.map(({ name }) => name); }; export const isConsortiumEnabled = stripes => { - return stripes.hasInterface('consortia'); + return stripes?.hasInterface('consortia'); +}; + +export const getCentralTenantId = stripes => { + return stripes?.user?.user?.consortium?.centralTenantId; }; export const getRequestUrl = (barcode, userId) => { diff --git a/src/components/util/util.test.js b/src/components/util/util.test.js index 1279351ab..8418dd7c5 100644 --- a/src/components/util/util.test.js +++ b/src/components/util/util.test.js @@ -18,9 +18,23 @@ import { retrieveNoteReferredEntityDataFromLocationState, getClosedRequestStatusesFilterString, getOpenRequestStatusesFilterString, + getCentralTenantId, + isConsortiumEnabled, getRequestUrl, } from './util'; +const STRIPES = { + hasPerm: jest.fn().mockReturnValue(true), + hasInterface: jest.fn().mockReturnValue(true), + user: { + user: { + consortium: { + centralTenantId: 'centralTenantId' + } + } + } +}; + describe('accountsMatchStatus', () => { it('returns true if all accounts match', () => { const status = 'monkey'; @@ -366,6 +380,30 @@ describe('getContributors', () => { }); }); +describe('isConsortiumEnabled', () => { + it('should return false', () => { + const data = isConsortiumEnabled(); + expect(data).toBeFalsy(); + }); + + it('should return true', () => { + const data = isConsortiumEnabled(STRIPES); + expect(data).toBe(true); + }); +}); + +describe('getCentralTenantId ', () => { + it('should return undefined if consortium object is absent', () => { + const data = getCentralTenantId({ ...STRIPES, user: { user: { } } }); + expect(data).toBe(undefined); + }); + + it('should return centralTenantId if consortium object and id is present', () => { + const data = getCentralTenantId(STRIPES); + expect(data).toBe(STRIPES.user.user.consortium.centralTenantId); + }); +}); + describe('getRequestUrl', () => { it('should return url with user barcode', () => { const userBarcode = 'userBarcode'; diff --git a/src/routes/UserSearchContainer.js b/src/routes/UserSearchContainer.js index 6752e829a..dd7f5648f 100644 --- a/src/routes/UserSearchContainer.js +++ b/src/routes/UserSearchContainer.js @@ -4,27 +4,22 @@ import { get, template, } from 'lodash'; -import { stripesConnect } from '@folio/stripes/core'; +import { stripesConnect } from '@folio/stripes/core'; import { makeQueryFunction, StripesConnectedSource, buildUrl, } from '@folio/stripes/smart-components'; -import filterConfig from './filterConfig'; import { UserSearch } from '../views'; -import { - MAX_RECORDS, - USER_TYPES, -} from '../constants'; +import { MAX_RECORDS } from '../constants'; +import filterConfig from './filterConfig'; import { buildFilterConfig } from './utils'; const INITIAL_RESULT_COUNT = 30; const RESULT_COUNT_INCREMENT = 30; -export const NOT_SHADOW_USER_CQL = `((cql.allRecords=1 NOT type ="") or type<>"${USER_TYPES.SHADOW}")`; - const searchFields = [ 'username="%{query}*"', 'personal.firstName="%{query}*"', @@ -42,7 +37,7 @@ const compileQuery = template(`(${searchFields.join(' or ')})`, { interpolate: / export function buildQuery(queryParams, pathComponents, resourceData, logger, props) { const customFilterConfig = buildFilterConfig(queryParams.filters); - const mainQuery = makeQueryFunction( + return makeQueryFunction( 'cql.allRecords=1', // TODO: Refactor/remove this after work on FOLIO-2066 and RMB-385 is done (parsedQuery, _, localProps) => localProps.query.query.trim().replace('*', '').split(/\s+/) @@ -59,8 +54,6 @@ export function buildQuery(queryParams, pathComponents, resourceData, logger, pr [...filterConfig, ...customFilterConfig], 2, )(queryParams, pathComponents, resourceData, logger, props); - - return mainQuery && `${NOT_SHADOW_USER_CQL} and ${mainQuery}`; } class UserSearchContainer extends React.Component { diff --git a/src/routes/UserSearchContainer.test.js b/src/routes/UserSearchContainer.test.js index 5dfa0bed1..bfe40c9ee 100644 --- a/src/routes/UserSearchContainer.test.js +++ b/src/routes/UserSearchContainer.test.js @@ -1,7 +1,4 @@ -import { - buildQuery, - NOT_SHADOW_USER_CQL, -} from './UserSearchContainer'; +import { buildQuery } from './UserSearchContainer'; const queryParams = { filters: 'active.active', @@ -15,9 +12,20 @@ const resourceData = { const logger = { log: jest.fn(), }; +const mockHasInterface = jest.fn().mockReturnValue(false); +const props = { + stripes: { + hasInterface: mockHasInterface, + } +}; describe('buildQuery', () => { - it('should exclude shadow users when building CQL query', () => { - expect(buildQuery(queryParams, pathComponents, resourceData, logger)).toEqual(expect.stringContaining(NOT_SHADOW_USER_CQL)); + it('should return empty CQL query', () => { + expect(buildQuery({}, pathComponents, { query: {} }, logger, props)).toBeFalsy(); + }); + + it('should include username when building CQL query', () => { + mockHasInterface.mockReturnValue(true); + expect(buildQuery(queryParams, pathComponents, resourceData, logger, props)).toEqual(expect.stringContaining('username="Joe*"')); }); }); diff --git a/src/routes/filterConfig.js b/src/routes/filterConfig.js index 3b7b23f15..f178cf0d6 100644 --- a/src/routes/filterConfig.js +++ b/src/routes/filterConfig.js @@ -24,6 +24,12 @@ const filterConfig = [ values: [], operator: '=', }, + { + name: 'userType', + cql: 'type', + values: [], + operator: '=', + } ]; export default filterConfig; diff --git a/src/views/UserSearch/Filters.js b/src/views/UserSearch/Filters.js index 98807e825..e4e0a51d5 100644 --- a/src/views/UserSearch/Filters.js +++ b/src/views/UserSearch/Filters.js @@ -4,24 +4,24 @@ import { FormattedMessage, injectIntl, } from 'react-intl'; -import { - get, -} from 'lodash'; +import { get } from 'lodash'; import { Accordion, AccordionSet, FilterAccordionHeader, } from '@folio/stripes/components'; - +import { stripesConnect } from '@folio/stripes/core'; import { CheckboxFilter, MultiSelectionFilter, } from '@folio/stripes/smart-components'; -import { statusFilter } from '../../constants'; - import CustomFieldsFilters from '../../components/CustomFieldsFilters'; +import { isConsortiumEnabled } from '../../components/util'; +import { USER_TYPES, statusFilter } from '../../constants'; + +const ACCORDION_ID_PREFIX = 'users-filter-accordion'; class Filters extends React.Component { static propTypes = { @@ -34,6 +34,7 @@ class Filters extends React.Component { resultOffset: PropTypes.shape({ replace: PropTypes.func.isRequired, }), + stripes: PropTypes.object.isRequired, }; static defaultProps = { @@ -85,21 +86,44 @@ class Filters extends React.Component { onChangeHandlers: { clearGroup }, intl: { formatMessage }, resources, + stripes, } = this.props; const { active = [], pg = [], tags = [], departments = [], + userType, } = activeFilters; const departmentsAreNotEmpty = !!resources.departments?.records?.length; + const isConsortium = isConsortiumEnabled(stripes); + const { PATRON, SHADOW, STAFF, SYSTEM } = USER_TYPES; + const userTypeOptions = [ + { + value: PATRON, + label: formatMessage({ id: 'ui-users.information.userType.patron' }), + }, + { + value: STAFF, + label: formatMessage({ id: 'ui-users.information.userType.staff' }), + }, + { + value: SHADOW, + label: formatMessage({ id: 'ui-users.information.userType.shadow' }), + }, + { + value: SYSTEM, + label: formatMessage({ id: 'ui-users.information.userType.system' }), + } + ]; + return ( + { + isConsortium && ( + clearGroup('userType')} + > + + + ) + } { useCustomFields: jest.fn(() => [[customField]]), }; }); +jest.mock('../../components/util', () => ({ + isConsortiumEnabled: jest.fn(), +})); const stateMock = jest.fn(); const filterHandlers = { @@ -43,6 +47,9 @@ const initialProps = { resultOffset: { replace: jest.fn(), }, + stripes: { + hasInterface: jest.fn(), + }, }; describe('Filters', () => { @@ -77,4 +84,16 @@ describe('Filters', () => { renderFilters(initialProps); expect(screen.getByText('ui-users.departments')).toBeInTheDocument(); }); + + it('should display user-type filter for consortia tenants', () => { + isConsortiumEnabled.mockReturnValue(true); + renderFilters(initialProps); + expect(screen.getByText('ui-users.userType')).toBeInTheDocument(); + }); + + it('should hide user-types filter for non-consortia tenants', () => { + isConsortiumEnabled.mockReturnValue(false); + renderFilters(initialProps); + expect(screen.queryByText('ui-users.userType')).not.toBeInTheDocument(); + }); }); diff --git a/src/views/UserSearch/UserSearch.js b/src/views/UserSearch/UserSearch.js index 11114789f..0fd166341 100644 --- a/src/views/UserSearch/UserSearch.js +++ b/src/views/UserSearch/UserSearch.js @@ -39,14 +39,15 @@ import { import RefundsReportModal from '../../components/ReportModals/RefundsReportModal'; import CashDrawerReportModal from '../../components/ReportModals/CashDrawerReportModal'; import FinancialTransactionsReportModal from '../../components/ReportModals/FinancialTransactionsReportModal'; - import CsvReport from '../../components/data/reports'; import RefundsReport from '../../components/data/reports/RefundReport'; import CashDrawerReconciliationReportPDF from '../../components/data/reports/cashDrawerReconciliationReportPDF'; import CashDrawerReconciliationReportCSV from '../../components/data/reports/cashDrawerReconciliationReportCSV'; import FinancialTransactionsReport from '../../components/data/reports/FinancialTransactionsReport'; -import Filters from './Filters'; import LostItemsLink from '../../components/LostItemsLink'; +import { getCentralTenantId, isConsortiumEnabled } from '../../components/util'; +import { USER_TYPES } from '../../constants'; +import Filters from './Filters'; import css from './UserSearch.css'; @@ -651,6 +652,7 @@ class UserSearch extends React.Component { onNeedMoreData, resources, contentRef, + stripes, mutator: { resultOffset, cashDrawerReportSources }, stripes: { timezone }, okapi: { currentUser }, @@ -717,6 +719,12 @@ class UserSearch extends React.Component { email: user => get(user, ['personal', 'email']), }; + const { PATRON, SHADOW, STAFF, SYSTEM } = USER_TYPES; + const isCentralTenant = getCentralTenantId(stripes); + const isConsortium = isConsortiumEnabled(stripes); + const defaultSelectedUserTypes = isCentralTenant ? [STAFF, SHADOW, SYSTEM] : [STAFF, PATRON, SYSTEM]; + const initialFilters = isConsortium ? { userType: defaultSelectedUserTypes } : {}; + return (
@@ -726,6 +734,7 @@ class UserSearch extends React.Component { onComponentWillUnmount={onComponentWillUnmount} initialSearch={initialSearch} initialSearchState={{ query: '' }} + initialFilterState={initialFilters} > { ({ diff --git a/src/views/UserSearch/UserSearch.test.js b/src/views/UserSearch/UserSearch.test.js new file mode 100644 index 000000000..ecedd82ac --- /dev/null +++ b/src/views/UserSearch/UserSearch.test.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { screen } from '@folio/jest-config-stripes/testing-library/react'; + +import renderWithRouter from 'helpers/renderWithRouter'; +import '../../../test/jest/__mock__/matchMedia.mock'; + +import UserSearch from './UserSearch'; + +jest.unmock('@folio/stripes/components'); +jest.unmock('@folio/stripes/smart-components'); + +jest.mock('@folio/stripes/smart-components', () => { + // eslint-disable-next-line global-require + const customField = require('fixtures/multiSelectCustomField'); + return { + // eslint-disable-next-line global-require + ...jest.requireActual('@folio/stripes/smart-components'), + useCustomFields: jest.fn(() => [[customField]]), + }; +}); + +const defaultProps = { + mutator: {}, + resources: { + owners: { + records: [], + }, + }, + stripes: { + hasInterface: jest.fn(), + }, + location: {}, + history: {}, + match: {}, + intl: { + formatMessage: jest.fn(), + }, + okapi: { + currentUser: { + id: 'test', + }, + } +}; + +const renderComponent = (props) => renderWithRouter(); + +describe('UserSearch', () => { + it('should render component', () => { + renderComponent(); + expect(screen.getByText('ui-users.status')).toBeTruthy(); + }); +}); diff --git a/translations/ui-users/en.json b/translations/ui-users/en.json index 9104eb377..4edec5d76 100644 --- a/translations/ui-users/en.json +++ b/translations/ui-users/en.json @@ -402,6 +402,7 @@ "bulkActions.tooltip": "Multiple loans can be processed at the same time. Please select loans first.", "loanNotRenewed": "Loan not renewed", "status": "Status", + "userType": "User type", "selectColumns": "Select Columns", "accounts.title.feeFine": "Fees/fines", "accounts.header": "Fee/fine details - {userName} ({patronGroup})",