diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e1c706..abaf8ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Sort the list of countries based on the current locale. Refs UISACQCOMP-164. * Add `inputType` prop to ``. Refs UISACQCOMP-165. +* View the list of donors. Refs UISACQCOMP-166. ## [5.0.0](https://github.com/folio-org/stripes-acq-components/tree/v5.0.0) (2023-10-12) [Full Changelog](https://github.com/folio-org/stripes-acq-components/compare/v4.0.2...v5.0.0) diff --git a/lib/DonorsList/AddDonorButton.js b/lib/DonorsList/AddDonorButton.js new file mode 100644 index 00000000..f470de74 --- /dev/null +++ b/lib/DonorsList/AddDonorButton.js @@ -0,0 +1,60 @@ +import { map } from 'lodash'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +import { Pluggable } from '@folio/stripes/core'; + +import { + initialFilters, + modalLabel, + pluginVisibleColumns, + resultsPaneTitle, + searchableIndexes, + visibleFilters, +} from './constants'; + +const AddDonorButton = ({ onAddDonors, fields, stripes, name }) => { + const addDonors = (donors = []) => { + const addedDonorIds = new Set(fields.value); + const newDonorsIds = map(donors.filter(({ id }) => !addedDonorIds.has(id)), 'id'); + + if (newDonorsIds.length) { + onAddDonors([...addedDonorIds, ...newDonorsIds]); + newDonorsIds.forEach(contactId => fields.push(contactId)); + } + }; + + return ( + } + searchButtonStyle="default" + disableRecordCreation + stripes={stripes} + selectVendor={addDonors} + modalLabel={modalLabel} + resultsPaneTitle={resultsPaneTitle} + visibleColumns={pluginVisibleColumns} + initialFilters={initialFilters} + searchableIndexes={searchableIndexes} + visibleFilters={visibleFilters} + isMultiSelect + > + + + + + ); +}; + +AddDonorButton.propTypes = { + onAddDonors: PropTypes.func.isRequired, + fields: PropTypes.object, + stripes: PropTypes.object, + name: PropTypes.string.isRequired, +}; + +export default AddDonorButton; diff --git a/lib/DonorsList/AddDonorButton.test.js b/lib/DonorsList/AddDonorButton.test.js new file mode 100644 index 00000000..86465a20 --- /dev/null +++ b/lib/DonorsList/AddDonorButton.test.js @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; + +import AddDonorButton from './AddDonorButton'; + +const mockVendorData = { id: '1', name: 'Amazon' }; + +jest.mock('@folio/stripes/core', () => ({ + ...jest.requireActual('@folio/stripes/core'), + Pluggable: jest.fn(({ children, ...rest }) => { + return ( +
+ {children} + +
+ ); + }), +})); + +const mockOnAddDonors = jest.fn(); + +const defaultProps = { + onAddDonors: mockOnAddDonors, + fields: { + name: 'donors', + }, + name: 'donors', +}; + +const renderComponent = (props = defaultProps) => (render( + , +)); + +describe('AddDonorButton', () => { + it('should render component', async () => { + renderComponent({ + fields: { + name: 'donors', + push: jest.fn(), + }, + name: 'donors', + onAddDonors: mockOnAddDonors, + }); + + const addDonorsButton = screen.getByText('Add donor'); + + expect(addDonorsButton).toBeDefined(); + + await user.click(addDonorsButton); + + expect(mockOnAddDonors).toHaveBeenCalledWith([mockVendorData.id]); + }); +}); diff --git a/lib/DonorsList/DonorsContainer.js b/lib/DonorsList/DonorsContainer.js new file mode 100644 index 00000000..3c914f5e --- /dev/null +++ b/lib/DonorsList/DonorsContainer.js @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { FieldArray } from 'react-final-form-arrays'; + +import { + Col, + Loading, + Row, +} from '@folio/stripes/components'; + +import DonorsList from './DonorsList'; +import { useFetchDonors } from './hooks'; + +function DonorsContainer({ name, donorOrganizationIds }) { + const [donorIds, setDonorIds] = useState(donorOrganizationIds); + const { donors, isLoading } = useFetchDonors(donorIds); + + const donorsMap = donors.reduce((acc, contact) => { + acc[contact.id] = contact; + + return acc; + }, {}); + + if (isLoading) { + return ; + } + + return ( + + + + + + ); +} + +DonorsContainer.propTypes = { + name: PropTypes.string.isRequired, + donorOrganizationIds: PropTypes.arrayOf(PropTypes.string), +}; + +DonorsContainer.defaultProps = { + donorOrganizationIds: [], +}; + +export default DonorsContainer; diff --git a/lib/DonorsList/DonorsContainer.test.js b/lib/DonorsList/DonorsContainer.test.js new file mode 100644 index 00000000..e1433fad --- /dev/null +++ b/lib/DonorsList/DonorsContainer.test.js @@ -0,0 +1,89 @@ +import { MemoryRouter } from 'react-router-dom'; +import { render, screen } from '@testing-library/react'; + +import stripesFinalForm from '@folio/stripes/final-form'; + +import DonorsContainer from './DonorsContainer'; +import { useFetchDonors } from './hooks'; + +jest.mock('@folio/stripes/components', () => ({ + ...jest.requireActual('@folio/stripes/components'), + Loading: jest.fn(() => 'Loading'), +})); + +jest.mock('./DonorsList', () => jest.fn(({ donorsMap }) => { + if (!Object.values(donorsMap).length) { + return 'stripes-components.tableEmpty'; + } + + return Object.values(donorsMap).map(({ name }) =>
{name}
); +})); + +jest.mock('./hooks', () => ({ + useFetchDonors: jest.fn().mockReturnValue({ + donors: [], + isLoading: false, + }), +})); + +const defaultProps = { + name: 'donors', + donorOrganizationIds: [], +}; + +const renderForm = (props = {}) => ( +
+ + + +); + +const FormCmpt = stripesFinalForm({})(renderForm); + +const renderComponent = (props = {}) => (render( + + { }} {...props} /> + , +)); + +describe('DonorsContainer', () => { + beforeEach(() => { + useFetchDonors.mockClear().mockReturnValue({ + donors: [], + isLoading: false, + }); + }); + + it('should render component', () => { + renderComponent(); + + expect(screen.getByText('stripes-components.tableEmpty')).toBeDefined(); + }); + + it('should render Loading component', () => { + useFetchDonors.mockClear().mockReturnValue({ + donors: [], + isLoading: true, + }); + + renderComponent(); + + expect(screen.getByText('Loading')).toBeDefined(); + }); + + it('should call `useFetchDonors` with `donorOrganizationIds`', () => { + const mockData = [{ name: 'Amazon', code: 'AMAZ', id: '1' }]; + + useFetchDonors.mockClear().mockReturnValue({ + donors: mockData, + isLoading: false, + }); + + renderComponent({ donorOrganizationIds: ['1'] }); + + expect(screen.getByText(mockData[0].name)).toBeDefined(); + }); +}); diff --git a/lib/DonorsList/DonorsList.js b/lib/DonorsList/DonorsList.js new file mode 100644 index 00000000..c17e54a1 --- /dev/null +++ b/lib/DonorsList/DonorsList.js @@ -0,0 +1,103 @@ +import React, { useMemo } from 'react'; +import { sortBy } from 'lodash'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; + +import { + Button, + Icon, + MultiColumnList, + TextLink, +} from '@folio/stripes/components'; +import { useStripes } from '@folio/stripes/core'; + +import AddDonorButton from './AddDonorButton'; +import { + alignRowProps, + columnMapping, + columnWidths, + visibleColumns, +} from './constants'; + +const getDonorUrl = (orgId) => { + if (orgId) { + return `/organizations/view/${orgId}`; + } + + return undefined; +}; + +const getResultsFormatter = ({ + canViewOrganizations, + fields, + intl, +}) => ({ + name: donor => {donor.name}, + code: donor => donor.code, + unassignDonor: donor => ( + + ), +}); + +const DonorsList = ({ setDonorIds, fields, donorsMap, id }) => { + const intl = useIntl(); + const stripes = useStripes(); + const canViewOrganizations = stripes.hasPerm('ui-organizations.view'); + + const donors = useMemo(() => (fields.value || []) + .map((contactId, _index) => { + const contact = donorsMap?.[contactId]; + + return { + ...(contact || { isDeleted: true }), + _index, + }; + }), [donorsMap, fields.value]); + + const contentData = useMemo(() => sortBy(donors, [({ lastName }) => lastName?.toLowerCase()]), [donors]); + + const resultsFormatter = useMemo(() => { + return getResultsFormatter({ intl, fields, canViewOrganizations }); + }, [canViewOrganizations, fields, intl]); + + return ( + <> + +
+ + + ); +}; + +DonorsList.propTypes = { + setDonorIds: PropTypes.func.isRequired, + fields: PropTypes.object, + donorsMap: PropTypes.object, + id: PropTypes.string.isRequired, +}; + +export default DonorsList; diff --git a/lib/DonorsList/DonorsList.test.js b/lib/DonorsList/DonorsList.test.js new file mode 100644 index 00000000..2dd4647c --- /dev/null +++ b/lib/DonorsList/DonorsList.test.js @@ -0,0 +1,53 @@ +import { MemoryRouter } from 'react-router-dom'; +import { render, screen } from '@testing-library/react'; + +import DonorsList from './DonorsList'; + +const mockSetDonorIds = jest.fn(); + +const defaultProps = { + setDonorIds: mockSetDonorIds, + fields: {}, + donorsMap: {}, + id: 'donors', +}; + +const wrapper = ({ children }) => ( + + {children} + +); + +const renderComponent = (props = {}) => (render( + , + { wrapper }, +)); + +describe('DonorsList', () => { + it('should render component', () => { + renderComponent(); + + expect(screen.getByText('stripes-components.tableEmpty')).toBeDefined(); + }); + + it('should render the list of donor organizations', () => { + renderComponent({ + fields: { + value: [ + '1', + '2', + ], + }, + donorsMap: { + 1: { id: '1', name: 'Amazon' }, + 2: { id: '2', name: 'Google' }, + }, + }); + + expect(screen.getByText('Amazon')).toBeDefined(); + expect(screen.getByText('Google')).toBeDefined(); + }); +}); diff --git a/lib/DonorsList/constants.js b/lib/DonorsList/constants.js new file mode 100644 index 00000000..2fb6138c --- /dev/null +++ b/lib/DonorsList/constants.js @@ -0,0 +1,61 @@ +import { FormattedMessage } from 'react-intl'; + +export const columnMapping = { + name: , + code: , + unassignDonor: null, +}; + +export const visibleColumns = [ + 'name', + 'code', + 'unassignDonor', +]; + +export const alignRowProps = { alignLastColToEnd: true }; + +export const columnWidths = { + name: '45%', + code: '45%', + unassignDonor: '10%', +}; + +export const modalLabel = ; +export const resultsPaneTitle = ; + +export const pluginVisibleColumns = ['name', 'code']; + +export const DONORS_SORT_MAP = { + name: 'name', + code: 'code', +}; + +const ORGANIZATION_STATUS = { + active: 'Active', + inactive: 'Inactive', + pending: 'Pending', +}; + +const FILTERS = { + IS_VENDOR: 'isVendor', + IS_DONOR: 'isDonor', + STATUS: 'status', + TAGS: 'tags', + TYPES: 'organizationTypes', +}; + +export const visibleFilters = [ + FILTERS.IS_VENDOR, + FILTERS.TAGS, + FILTERS.TYPES, +]; + +export const initialFilters = { + [FILTERS.IS_DONOR]: ['true'], + [FILTERS.STATUS]: [ORGANIZATION_STATUS.active], +}; + +export const searchableIndexes = pluginVisibleColumns.map(column => ({ + labelId: `ui-organizations.search.${column}`, + value: column, +})); diff --git a/lib/DonorsList/hooks/index.js b/lib/DonorsList/hooks/index.js new file mode 100644 index 00000000..eed71fe5 --- /dev/null +++ b/lib/DonorsList/hooks/index.js @@ -0,0 +1 @@ +export { useFetchDonors } from './useFetchDonors'; diff --git a/lib/DonorsList/hooks/useFetchDonors/constants.js b/lib/DonorsList/hooks/useFetchDonors/constants.js new file mode 100644 index 00000000..6dfde324 --- /dev/null +++ b/lib/DonorsList/hooks/useFetchDonors/constants.js @@ -0,0 +1 @@ +export const DEFAULT_DATA = []; diff --git a/lib/DonorsList/hooks/useFetchDonors/index.js b/lib/DonorsList/hooks/useFetchDonors/index.js new file mode 100644 index 00000000..eed71fe5 --- /dev/null +++ b/lib/DonorsList/hooks/useFetchDonors/index.js @@ -0,0 +1 @@ +export { useFetchDonors } from './useFetchDonors'; diff --git a/lib/DonorsList/hooks/useFetchDonors/useFetchDonors.js b/lib/DonorsList/hooks/useFetchDonors/useFetchDonors.js new file mode 100644 index 00000000..ae7c10e1 --- /dev/null +++ b/lib/DonorsList/hooks/useFetchDonors/useFetchDonors.js @@ -0,0 +1,34 @@ +import { useQuery } from 'react-query'; + +import { + useNamespace, + useOkapiKy, +} from '@folio/stripes/core'; + +import { VENDORS_API } from '../../../constants'; +import { batchRequest } from '../../../utils'; +import { DEFAULT_DATA } from './constants'; + +export const useFetchDonors = (donorOrganizationIds = DEFAULT_DATA) => { + const ky = useOkapiKy(); + const namespace = useNamespace({ key: 'fetch-donors-list' }); + + const { isLoading, data } = useQuery( + [namespace, donorOrganizationIds], + () => { + return batchRequest( + ({ params: searchParams }) => ky + .get(VENDORS_API, { searchParams }) + .json() + .then(({ organizations }) => organizations), + donorOrganizationIds, + ); + }, + { enabled: Boolean(donorOrganizationIds.length) }, + ); + + return ({ + donors: data || DEFAULT_DATA, + isLoading, + }); +}; diff --git a/lib/DonorsList/hooks/useFetchDonors/useFetchDonors.test.js b/lib/DonorsList/hooks/useFetchDonors/useFetchDonors.test.js new file mode 100644 index 00000000..29481fb3 --- /dev/null +++ b/lib/DonorsList/hooks/useFetchDonors/useFetchDonors.test.js @@ -0,0 +1,60 @@ +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useOkapiKy } from '@folio/stripes/core'; + +import { useFetchDonors } from './useFetchDonors'; + +jest.mock('@folio/stripes/core', () => ({ + ...jest.requireActual('@folio/stripes/core'), + useOkapiKy: jest.fn(), + useNamespace: jest.fn(() => ['NameSpace']), +})); + +const queryClient = new QueryClient(); + +const wrapper = ({ children }) => ( + + {children} + +); + +const org = { id: 'orgId', name: 'VENDOR' }; + +const getMock = jest.fn().mockReturnValue({ + json: () => Promise.resolve(({ organizations: [org], totalRecords: 1 })), +}); + +describe('useDonors', () => { + beforeEach(() => { + getMock.mockClear(); + + useOkapiKy + .mockClear() + .mockReturnValue({ + get: getMock, + }); + }); + + it('should make a get a request with default search params', async () => { + const { result, waitFor } = renderHook(() => useFetchDonors(), { wrapper }); + + await waitFor(() => !result.current.isLoading); + + expect(getMock).not.toHaveBeenCalled(); + }); + + it('should make a get a request with default search params', async () => { + const { result, waitFor } = renderHook(() => useFetchDonors([org.id]), { wrapper }); + + await waitFor(() => !result.current.isLoading); + + expect(getMock).toHaveBeenCalledWith( + 'organizations/organizations', + { 'searchParams': { 'limit': 1000, 'query': `id==${org.id}` } }, + ); + }); +}); diff --git a/lib/DonorsList/index.js b/lib/DonorsList/index.js new file mode 100644 index 00000000..66ae435f --- /dev/null +++ b/lib/DonorsList/index.js @@ -0,0 +1 @@ +export { default as DonorsList } from './DonorsContainer'; diff --git a/lib/index.js b/lib/index.js index ea8509f8..7c0140a3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -16,6 +16,7 @@ export * from './Currency'; export * from './CurrencyExchangeRateFields'; export * from './CurrencySymbol'; export * from './DeleteHoldingsModal'; +export * from './DonorsList'; export * from './DragDropMCL'; export * from './DynamicSelection'; export * from './DynamicSelectionFilter'; diff --git a/translations/stripes-acq-components/en.json b/translations/stripes-acq-components/en.json index 5b2d35a1..a49024e5 100644 --- a/translations/stripes-acq-components/en.json +++ b/translations/stripes-acq-components/en.json @@ -187,5 +187,12 @@ "acquisition_method.other": "Other", "acquisition_method.purchase": "Purchase", "acquisition_method.purchaseAtVendorSystem": "Purchase at vendor system", - "acquisition_method.technical": "Technical" + "acquisition_method.technical": "Technical", + "donors.button.addDonor": "Add donor", + "donors.column.code": "Code", + "donors.column.name": "Name", + "donors.modal.title": "Add donors", + "donors.modal.resultsTitle": "Donors", + "donors.noFindOrganizationPlugin": "no find-organization plugin", + "donors.button.unassign": "Unassign" }