Skip to content

Commit 5bbcf17

Browse files
authored
UISACQCOMP-166: view list of donors (#724)
* UISACQCOMP-166: view list of donors * tests: add test coverages * tests: add test coverage and Changelog.md * refactor: change the order of import files * add column width for better table view * refactor: add config properties for plugin to implement donors * fix: add missing IS_DONOR filter prop * improve code quality regarding to the comments * refactor: upgrade mutation to query for better caching * fix: failing tests * update callback names for better readability * improve and optimize the code * update state initializer * tests: add test cases for the button * remove unused packages and imports * tests: add test cases
1 parent fc2f33d commit 5bbcf17

File tree

16 files changed

+585
-1
lines changed

16 files changed

+585
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
* Sort the list of countries based on the current locale. Refs UISACQCOMP-164.
66
* Add `inputType` prop to `<SingleSearchForm>`. Refs UISACQCOMP-165.
7+
* View the list of donors. Refs UISACQCOMP-166.
78

89
## [5.0.0](https://github.com/folio-org/stripes-acq-components/tree/v5.0.0) (2023-10-12)
910
[Full Changelog](https://github.com/folio-org/stripes-acq-components/compare/v4.0.2...v5.0.0)

lib/DonorsList/AddDonorButton.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { map } from 'lodash';
2+
import PropTypes from 'prop-types';
3+
import { FormattedMessage } from 'react-intl';
4+
5+
import { Pluggable } from '@folio/stripes/core';
6+
7+
import {
8+
initialFilters,
9+
modalLabel,
10+
pluginVisibleColumns,
11+
resultsPaneTitle,
12+
searchableIndexes,
13+
visibleFilters,
14+
} from './constants';
15+
16+
const AddDonorButton = ({ onAddDonors, fields, stripes, name }) => {
17+
const addDonors = (donors = []) => {
18+
const addedDonorIds = new Set(fields.value);
19+
const newDonorsIds = map(donors.filter(({ id }) => !addedDonorIds.has(id)), 'id');
20+
21+
if (newDonorsIds.length) {
22+
onAddDonors([...addedDonorIds, ...newDonorsIds]);
23+
newDonorsIds.forEach(contactId => fields.push(contactId));
24+
}
25+
};
26+
27+
return (
28+
<Pluggable
29+
id={`${name}-plugin`}
30+
aria-haspopup="true"
31+
type="find-organization"
32+
dataKey="organization"
33+
searchLabel={<FormattedMessage id="stripes-acq-components.donors.button.addDonor" />}
34+
searchButtonStyle="default"
35+
disableRecordCreation
36+
stripes={stripes}
37+
selectVendor={addDonors}
38+
modalLabel={modalLabel}
39+
resultsPaneTitle={resultsPaneTitle}
40+
visibleColumns={pluginVisibleColumns}
41+
initialFilters={initialFilters}
42+
searchableIndexes={searchableIndexes}
43+
visibleFilters={visibleFilters}
44+
isMultiSelect
45+
>
46+
<span data-test-add-donor>
47+
<FormattedMessage id="stripes-acq-components.donors.noFindOrganizationPlugin" />
48+
</span>
49+
</Pluggable>
50+
);
51+
};
52+
53+
AddDonorButton.propTypes = {
54+
onAddDonors: PropTypes.func.isRequired,
55+
fields: PropTypes.object,
56+
stripes: PropTypes.object,
57+
name: PropTypes.string.isRequired,
58+
};
59+
60+
export default AddDonorButton;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { render, screen } from '@testing-library/react';
2+
import user from '@testing-library/user-event';
3+
4+
import AddDonorButton from './AddDonorButton';
5+
6+
const mockVendorData = { id: '1', name: 'Amazon' };
7+
8+
jest.mock('@folio/stripes/core', () => ({
9+
...jest.requireActual('@folio/stripes/core'),
10+
Pluggable: jest.fn(({ children, ...rest }) => {
11+
return (
12+
<div>
13+
{children}
14+
<button
15+
type="button"
16+
id={rest?.name}
17+
onClick={() => rest?.selectVendor([mockVendorData])}
18+
>
19+
Add donor
20+
</button>
21+
</div>
22+
);
23+
}),
24+
}));
25+
26+
const mockOnAddDonors = jest.fn();
27+
28+
const defaultProps = {
29+
onAddDonors: mockOnAddDonors,
30+
fields: {
31+
name: 'donors',
32+
},
33+
name: 'donors',
34+
};
35+
36+
const renderComponent = (props = defaultProps) => (render(
37+
<AddDonorButton {...props} />,
38+
));
39+
40+
describe('AddDonorButton', () => {
41+
it('should render component', async () => {
42+
renderComponent({
43+
fields: {
44+
name: 'donors',
45+
push: jest.fn(),
46+
},
47+
name: 'donors',
48+
onAddDonors: mockOnAddDonors,
49+
});
50+
51+
const addDonorsButton = screen.getByText('Add donor');
52+
53+
expect(addDonorsButton).toBeDefined();
54+
55+
await user.click(addDonorsButton);
56+
57+
expect(mockOnAddDonors).toHaveBeenCalledWith([mockVendorData.id]);
58+
});
59+
});

lib/DonorsList/DonorsContainer.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React, { useState } from 'react';
2+
import PropTypes from 'prop-types';
3+
import { FieldArray } from 'react-final-form-arrays';
4+
5+
import {
6+
Col,
7+
Loading,
8+
Row,
9+
} from '@folio/stripes/components';
10+
11+
import DonorsList from './DonorsList';
12+
import { useFetchDonors } from './hooks';
13+
14+
function DonorsContainer({ name, donorOrganizationIds }) {
15+
const [donorIds, setDonorIds] = useState(donorOrganizationIds);
16+
const { donors, isLoading } = useFetchDonors(donorIds);
17+
18+
const donorsMap = donors.reduce((acc, contact) => {
19+
acc[contact.id] = contact;
20+
21+
return acc;
22+
}, {});
23+
24+
if (isLoading) {
25+
return <Loading />;
26+
}
27+
28+
return (
29+
<Row>
30+
<Col xs={12}>
31+
<FieldArray
32+
name={name}
33+
id={name}
34+
component={DonorsList}
35+
setDonorIds={setDonorIds}
36+
donorsMap={donorsMap}
37+
/>
38+
</Col>
39+
</Row>
40+
);
41+
}
42+
43+
DonorsContainer.propTypes = {
44+
name: PropTypes.string.isRequired,
45+
donorOrganizationIds: PropTypes.arrayOf(PropTypes.string),
46+
};
47+
48+
DonorsContainer.defaultProps = {
49+
donorOrganizationIds: [],
50+
};
51+
52+
export default DonorsContainer;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { MemoryRouter } from 'react-router-dom';
2+
import { render, screen } from '@testing-library/react';
3+
4+
import stripesFinalForm from '@folio/stripes/final-form';
5+
6+
import DonorsContainer from './DonorsContainer';
7+
import { useFetchDonors } from './hooks';
8+
9+
jest.mock('@folio/stripes/components', () => ({
10+
...jest.requireActual('@folio/stripes/components'),
11+
Loading: jest.fn(() => 'Loading'),
12+
}));
13+
14+
jest.mock('./DonorsList', () => jest.fn(({ donorsMap }) => {
15+
if (!Object.values(donorsMap).length) {
16+
return 'stripes-components.tableEmpty';
17+
}
18+
19+
return Object.values(donorsMap).map(({ name }) => <div key={name}>{name}</div>);
20+
}));
21+
22+
jest.mock('./hooks', () => ({
23+
useFetchDonors: jest.fn().mockReturnValue({
24+
donors: [],
25+
isLoading: false,
26+
}),
27+
}));
28+
29+
const defaultProps = {
30+
name: 'donors',
31+
donorOrganizationIds: [],
32+
};
33+
34+
const renderForm = (props = {}) => (
35+
<form>
36+
<DonorsContainer
37+
{...defaultProps}
38+
{...props}
39+
/>
40+
<button type="submit">Submit</button>
41+
</form>
42+
);
43+
44+
const FormCmpt = stripesFinalForm({})(renderForm);
45+
46+
const renderComponent = (props = {}) => (render(
47+
<MemoryRouter>
48+
<FormCmpt onSubmit={() => { }} {...props} />
49+
</MemoryRouter>,
50+
));
51+
52+
describe('DonorsContainer', () => {
53+
beforeEach(() => {
54+
useFetchDonors.mockClear().mockReturnValue({
55+
donors: [],
56+
isLoading: false,
57+
});
58+
});
59+
60+
it('should render component', () => {
61+
renderComponent();
62+
63+
expect(screen.getByText('stripes-components.tableEmpty')).toBeDefined();
64+
});
65+
66+
it('should render Loading component', () => {
67+
useFetchDonors.mockClear().mockReturnValue({
68+
donors: [],
69+
isLoading: true,
70+
});
71+
72+
renderComponent();
73+
74+
expect(screen.getByText('Loading')).toBeDefined();
75+
});
76+
77+
it('should call `useFetchDonors` with `donorOrganizationIds`', () => {
78+
const mockData = [{ name: 'Amazon', code: 'AMAZ', id: '1' }];
79+
80+
useFetchDonors.mockClear().mockReturnValue({
81+
donors: mockData,
82+
isLoading: false,
83+
});
84+
85+
renderComponent({ donorOrganizationIds: ['1'] });
86+
87+
expect(screen.getByText(mockData[0].name)).toBeDefined();
88+
});
89+
});

lib/DonorsList/DonorsList.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React, { useMemo } from 'react';
2+
import { sortBy } from 'lodash';
3+
import PropTypes from 'prop-types';
4+
import { useIntl } from 'react-intl';
5+
6+
import {
7+
Button,
8+
Icon,
9+
MultiColumnList,
10+
TextLink,
11+
} from '@folio/stripes/components';
12+
import { useStripes } from '@folio/stripes/core';
13+
14+
import AddDonorButton from './AddDonorButton';
15+
import {
16+
alignRowProps,
17+
columnMapping,
18+
columnWidths,
19+
visibleColumns,
20+
} from './constants';
21+
22+
const getDonorUrl = (orgId) => {
23+
if (orgId) {
24+
return `/organizations/view/${orgId}`;
25+
}
26+
27+
return undefined;
28+
};
29+
30+
const getResultsFormatter = ({
31+
canViewOrganizations,
32+
fields,
33+
intl,
34+
}) => ({
35+
name: donor => <TextLink to={getDonorUrl(canViewOrganizations && donor.id)}>{donor.name}</TextLink>,
36+
code: donor => donor.code,
37+
unassignDonor: donor => (
38+
<Button
39+
align="end"
40+
aria-label={intl.formatMessage({ id: 'stripes-acq-components.donors.button.unassign' })}
41+
buttonStyle="fieldControl"
42+
type="button"
43+
onClick={(e) => {
44+
e.preventDefault();
45+
fields.remove(donor._index);
46+
}}
47+
>
48+
<Icon icon="times-circle" />
49+
</Button>
50+
),
51+
});
52+
53+
const DonorsList = ({ setDonorIds, fields, donorsMap, id }) => {
54+
const intl = useIntl();
55+
const stripes = useStripes();
56+
const canViewOrganizations = stripes.hasPerm('ui-organizations.view');
57+
58+
const donors = useMemo(() => (fields.value || [])
59+
.map((contactId, _index) => {
60+
const contact = donorsMap?.[contactId];
61+
62+
return {
63+
...(contact || { isDeleted: true }),
64+
_index,
65+
};
66+
}), [donorsMap, fields.value]);
67+
68+
const contentData = useMemo(() => sortBy(donors, [({ lastName }) => lastName?.toLowerCase()]), [donors]);
69+
70+
const resultsFormatter = useMemo(() => {
71+
return getResultsFormatter({ intl, fields, canViewOrganizations });
72+
}, [canViewOrganizations, fields, intl]);
73+
74+
return (
75+
<>
76+
<MultiColumnList
77+
id={id}
78+
columnMapping={columnMapping}
79+
contentData={contentData}
80+
formatter={resultsFormatter}
81+
rowProps={alignRowProps}
82+
visibleColumns={visibleColumns}
83+
columnWidths={columnWidths}
84+
/>
85+
<br />
86+
<AddDonorButton
87+
onAddDonors={setDonorIds}
88+
fields={fields}
89+
stripes={stripes}
90+
name={id}
91+
/>
92+
</>
93+
);
94+
};
95+
96+
DonorsList.propTypes = {
97+
setDonorIds: PropTypes.func.isRequired,
98+
fields: PropTypes.object,
99+
donorsMap: PropTypes.object,
100+
id: PropTypes.string.isRequired,
101+
};
102+
103+
export default DonorsList;

0 commit comments

Comments
 (0)