Skip to content

Commit

Permalink
UISACQCOMP-166: view list of donors (#724)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
alisher-epam authored Nov 3, 2023
1 parent fc2f33d commit 5bbcf17
Show file tree
Hide file tree
Showing 16 changed files with 585 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

* Sort the list of countries based on the current locale. Refs UISACQCOMP-164.
* Add `inputType` prop to `<SingleSearchForm>`. 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)
Expand Down
60 changes: 60 additions & 0 deletions lib/DonorsList/AddDonorButton.js
Original file line number Diff line number Diff line change
@@ -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 (
<Pluggable
id={`${name}-plugin`}
aria-haspopup="true"
type="find-organization"
dataKey="organization"
searchLabel={<FormattedMessage id="stripes-acq-components.donors.button.addDonor" />}
searchButtonStyle="default"
disableRecordCreation
stripes={stripes}
selectVendor={addDonors}
modalLabel={modalLabel}
resultsPaneTitle={resultsPaneTitle}
visibleColumns={pluginVisibleColumns}
initialFilters={initialFilters}
searchableIndexes={searchableIndexes}
visibleFilters={visibleFilters}
isMultiSelect
>
<span data-test-add-donor>
<FormattedMessage id="stripes-acq-components.donors.noFindOrganizationPlugin" />
</span>
</Pluggable>
);
};

AddDonorButton.propTypes = {
onAddDonors: PropTypes.func.isRequired,
fields: PropTypes.object,
stripes: PropTypes.object,
name: PropTypes.string.isRequired,
};

export default AddDonorButton;
59 changes: 59 additions & 0 deletions lib/DonorsList/AddDonorButton.test.js
Original file line number Diff line number Diff line change
@@ -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 (
<div>
{children}
<button
type="button"
id={rest?.name}
onClick={() => rest?.selectVendor([mockVendorData])}
>
Add donor
</button>
</div>
);
}),
}));

const mockOnAddDonors = jest.fn();

const defaultProps = {
onAddDonors: mockOnAddDonors,
fields: {
name: 'donors',
},
name: 'donors',
};

const renderComponent = (props = defaultProps) => (render(
<AddDonorButton {...props} />,
));

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]);
});
});
52 changes: 52 additions & 0 deletions lib/DonorsList/DonorsContainer.js
Original file line number Diff line number Diff line change
@@ -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 <Loading />;
}

return (
<Row>
<Col xs={12}>
<FieldArray
name={name}
id={name}
component={DonorsList}
setDonorIds={setDonorIds}
donorsMap={donorsMap}
/>
</Col>
</Row>
);
}

DonorsContainer.propTypes = {
name: PropTypes.string.isRequired,
donorOrganizationIds: PropTypes.arrayOf(PropTypes.string),
};

DonorsContainer.defaultProps = {
donorOrganizationIds: [],
};

export default DonorsContainer;
89 changes: 89 additions & 0 deletions lib/DonorsList/DonorsContainer.test.js
Original file line number Diff line number Diff line change
@@ -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 }) => <div key={name}>{name}</div>);
}));

jest.mock('./hooks', () => ({
useFetchDonors: jest.fn().mockReturnValue({
donors: [],
isLoading: false,
}),
}));

const defaultProps = {
name: 'donors',
donorOrganizationIds: [],
};

const renderForm = (props = {}) => (
<form>
<DonorsContainer
{...defaultProps}
{...props}
/>
<button type="submit">Submit</button>
</form>
);

const FormCmpt = stripesFinalForm({})(renderForm);

const renderComponent = (props = {}) => (render(
<MemoryRouter>
<FormCmpt onSubmit={() => { }} {...props} />
</MemoryRouter>,
));

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();
});
});
103 changes: 103 additions & 0 deletions lib/DonorsList/DonorsList.js
Original file line number Diff line number Diff line change
@@ -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 => <TextLink to={getDonorUrl(canViewOrganizations && donor.id)}>{donor.name}</TextLink>,
code: donor => donor.code,
unassignDonor: donor => (
<Button
align="end"
aria-label={intl.formatMessage({ id: 'stripes-acq-components.donors.button.unassign' })}
buttonStyle="fieldControl"
type="button"
onClick={(e) => {
e.preventDefault();
fields.remove(donor._index);
}}
>
<Icon icon="times-circle" />
</Button>
),
});

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 (
<>
<MultiColumnList
id={id}
columnMapping={columnMapping}
contentData={contentData}
formatter={resultsFormatter}
rowProps={alignRowProps}
visibleColumns={visibleColumns}
columnWidths={columnWidths}
/>
<br />
<AddDonorButton
onAddDonors={setDonorIds}
fields={fields}
stripes={stripes}
name={id}
/>
</>
);
};

DonorsList.propTypes = {
setDonorIds: PropTypes.func.isRequired,
fields: PropTypes.object,
donorsMap: PropTypes.object,
id: PropTypes.string.isRequired,
};

export default DonorsList;
Loading

0 comments on commit 5bbcf17

Please sign in to comment.