Skip to content

Commit

Permalink
UIIN-2760: Add Bound items accordion in item record (#2509)
Browse files Browse the repository at this point in the history
* UIIN-2760: Add Bound items accordion in item record

* add change log

* test: add test coverage

* refactor mutation name

* hide confirmation modal on error

* test: update test cases
  • Loading branch information
alisher-epam committed Jun 19, 2024
1 parent e9fd5ab commit aa7cf04
Show file tree
Hide file tree
Showing 15 changed files with 402 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
* Instance record > Classification accordion > Display a clipboard icon next to classification number. Refs UIIN-2580.
* Populate Acquisitions accordion on item when central ordering is active. Refs UIIN-2818.
* Import facets and the function for building a search query from `stripes-inventory-components`.
* Add Bound items accordion in item record. Refs UIIN-2760.

## [11.0.4](https://github.com/folio-org/ui-inventory/tree/v11.0.4) (2024-04-30)
[Full Changelog](https://github.com/folio-org/ui-inventory/compare/v11.0.3...v11.0.4)
Expand Down
2 changes: 2 additions & 0 deletions src/common/hooks/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export { default as useBoundPieces } from './useBoundPieces';
export { default as useControlledAccordion } from './useControlledAccordion';
export { default as useInstance } from './useInstance';
export { default as useGoBack } from './useGoBack';
export { default as useHolding } from './useHolding';
export { default as useInstanceQuery } from './useInstanceQuery';
export { default as usePiecesMutation } from './usePiecesMutation';
export { default as useSearchInstanceByIdQuery } from './useSearchInstanceByIdQuery';
export { default as useTenantKy } from './useTenantKy';
export { default as useDefaultJobProfile } from './useDefaultJobProfile';
Expand Down
1 change: 1 addition & 0 deletions src/common/hooks/useBoundPieces/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './useBoundPieces';
45 changes: 45 additions & 0 deletions src/common/hooks/useBoundPieces/useBoundPieces.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useQuery } from 'react-query';

import {
LIMIT_MAX,
ORDER_PIECES_API,
} from '@folio/stripes-acq-components';
import {
useNamespace,
useOkapiKy,
} from '@folio/stripes/core';

const DEFAULT_DATA = [];

const useBoundPieces = (itemId, options = {}) => {
const { enabled = true, ...otherOptions } = options;
const ky = useOkapiKy();
const [namespace] = useNamespace({ key: 'bound-pieces' });
const filterQuery = `itemId==${itemId} and isBound==true`;

const searchParams = {
limit: LIMIT_MAX,
query: `${filterQuery} sortby receivedDate`,
};

const {
data,
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: [namespace, itemId],
queryFn: () => ky.get(ORDER_PIECES_API, { searchParams }).json(),
enabled: Boolean(enabled && itemId),
...otherOptions,
});

return ({
isLoading,
isFetching,
refetch,
boundPieces: data?.pieces || DEFAULT_DATA,
});
};

export default useBoundPieces;
1 change: 1 addition & 0 deletions src/common/hooks/usePiecesMutation/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './usePiecesMutation';
20 changes: 20 additions & 0 deletions src/common/hooks/usePiecesMutation/usePiecesMutation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useMutation } from 'react-query';

import { ORDER_PIECES_API } from '@folio/stripes-acq-components';
import { useOkapiKy } from '@folio/stripes/core';

const usePiecesMutation = (options = {}) => {
const ky = useOkapiKy();

const { mutateAsync, isLoading } = useMutation({
mutationFn: (piece) => ky.put(`${ORDER_PIECES_API}/${piece.id}`, { json: piece }),
...options,
});

return {
isLoading,
updatePiece: mutateAsync,
};
};

export default usePiecesMutation;
110 changes: 110 additions & 0 deletions src/components/BoundPiecesList/BoundPiecesList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import omit from 'lodash/omit';
import PropTypes from 'prop-types';
import {
useMemo,
useRef,
} from 'react';
import { FormattedMessage } from 'react-intl';

import {
acqRowFormatter,
useShowCallout,
useToggle,
} from '@folio/stripes-acq-components';
import {
ConfirmationModal,
Loading,
MultiColumnList,
} from '@folio/stripes/components';
import { useStripes } from '@folio/stripes/core';

import {
useBoundPieces,
usePiecesMutation,
} from '../../common/hooks';
import {
PIECE_COLUMN_MAPPING,
VISIBLE_COLUMNS,
} from './constants';
import { getColumnFormatter } from './utils';

const BoundPiecesList = ({ id, itemId }) => {
const stripes = useStripes();
const showCallout = useShowCallout();
const { updatePiece } = usePiecesMutation();
const {
boundPieces,
isFetching,
refetch,
} = useBoundPieces(itemId);

const [open, toggleOpen] = useToggle(false);
const selectedPieceRef = useRef(null);

const hasViewReceivingPermissions = stripes.hasPerm('ui-receiving.view');

const onRemove = pieceData => {
selectedPieceRef.current = {
...omit(pieceData, ['rowIndex']),
isBound: false,
};

toggleOpen();
};

const handleRemove = () => {
return updatePiece(selectedPieceRef.current)
.then(() => {
refetch();
toggleOpen();
showCallout({
messageId: 'ui-inventory.boundPieces.remove.success',
});
})
.catch(() => {
toggleOpen();
showCallout({
messageId: 'ui-inventory.boundPieces.remove.error',
type: 'error',
});
});
};

const formatter = useMemo(() => {
return getColumnFormatter({ onRemove, hasViewReceivingPermissions });
}, [hasViewReceivingPermissions]);

if (isFetching) return <Loading />;

return (
<>
<MultiColumnList
id={id}
contentData={boundPieces}
totalCount={boundPieces.length}
columnMapping={PIECE_COLUMN_MAPPING}
visibleColumns={VISIBLE_COLUMNS}
formatter={formatter}
interactive={false}
rowFormatter={acqRowFormatter}
/>

<ConfirmationModal
id="delete-confirmation-modal"
open={open}
showHeader={false}
onConfirm={handleRemove}
onCancel={toggleOpen}
message={<FormattedMessage id="ui-inventory.boundPieces.remove.message" />}
confirmLabel={<FormattedMessage id="ui-inventory.boundPieces.remove.button" />}
/>
</>
);
};

BoundPiecesList.propTypes = {
id: PropTypes.string,
itemId: PropTypes.string.isRequired,
};

export default BoundPiecesList;
117 changes: 117 additions & 0 deletions src/components/BoundPiecesList/BoundPiecesList.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { screen } from '@folio/jest-config-stripes/testing-library/react';
import userEvent from '@folio/jest-config-stripes/testing-library/user-event';
import { ConfirmationModal } from '@folio/stripes/components';

import '@folio/stripes-acq-components/test/jest/__mock__';

import {
renderWithIntl,
translationsProperties,
} from '../../../test/jest/helpers';
import {
useBoundPieces,
usePiecesMutation,
} from '../../common/hooks';

import BoundPiecesList from './BoundPiecesList';

jest.mock('@folio/stripes/components', () => ({
...jest.requireActual('@folio/stripes/components'),
ConfirmationModal: jest.fn(),
TextLink: jest.fn().mockImplementation(({ children, to }) => <a href={to} data-testid="textLink">{children}</a>),
}));
jest.mock('@folio/stripes/core', () => ({
...jest.requireActual('@folio/stripes/core'),
useStripes: jest.fn().mockReturnValue({ hasPerm: jest.fn().mockReturnValue(true) }),
}));
jest.mock('../../common/hooks', () => ({
useBoundPieces: jest.fn(),
usePiecesMutation: jest.fn(),
}));

const boundPieces = [{
isBound: true,
displaySummary: 'Electronic item',
status: { name: 'Available' },
itemId: 'itemId',
id: 'id',
}];

const mockUpdatePiece = jest.fn().mockResolvedValue(() => Promise.resolve());

const renderBoundPiecesList = (props = {}) => renderWithIntl(
<BoundPiecesList
id="boundPiecesListId"
itemId={boundPieces[0].itemId}
{...props}
/>,
translationsProperties
);

describe('BoundPiecesList', () => {
beforeEach(() => {
useBoundPieces.mockClear().mockReturnValue({
boundPieces,
totalCount: boundPieces.length,
isFetching: false,
});
usePiecesMutation.mockClear().mockReturnValue({
updatePiece: mockUpdatePiece,
isLoading: false,
});
});

it('should render component', () => {
renderBoundPiecesList();

expect(screen.getByText('ui-inventory.displaySummary')).toBeInTheDocument();
expect(screen.getByText('ui-inventory.barcode')).toBeInTheDocument();
});

it('should render barcode link', () => {
useBoundPieces.mockClear().mockReturnValue({
boundPieces: [{
...boundPieces[0],
barcode: 'barcode',
titleId: 'titleId',
}],
totalCount: boundPieces.length,
isFetching: false,
});

renderBoundPiecesList();

expect(screen.getByText('ui-inventory.barcode')).toBeInTheDocument();
expect(screen.getByTestId('textLink')).toBeInTheDocument();
});

it('should call `updatePiece` mutation on click remove button', async () => {
useBoundPieces.mockClear().mockReturnValue({
boundPieces: [{
...boundPieces[0],
barcode: 'barcode',
}],
totalCount: boundPieces.length,
isFetching: false,
});

renderBoundPiecesList();

await userEvent.click(screen.getByRole('button'));

ConfirmationModal.mock.calls[0][0].onConfirm();
expect(mockUpdatePiece).toHaveBeenCalled();
});

it('should not render component when pieces are not fetched', () => {
useBoundPieces.mockReturnValue({
boundPieces: [],
totalCount: 0,
isFetching: false,
});

renderBoundPiecesList();

expect(screen.queryByText('ui-inventory.displaySummary')).not.toBeInTheDocument();
});
});
31 changes: 31 additions & 0 deletions src/components/BoundPiecesList/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { FormattedMessage } from 'react-intl';

export const PIECE_COLUMNS = {
displaySummary: 'displaySummary',
chronology: 'chronology',
copyNumber: 'copyNumber',
enumeration: 'enumeration',
receiptDate: 'receiptDate',
barcode: 'barcode',
remove: 'remove',
};

export const PIECE_COLUMN_MAPPING = {
[PIECE_COLUMNS.barcode]: <FormattedMessage id="ui-inventory.barcode" />,
[PIECE_COLUMNS.displaySummary]: <FormattedMessage id="ui-inventory.displaySummary" />,
[PIECE_COLUMNS.chronology]: <FormattedMessage id="ui-inventory.chronology" />,
[PIECE_COLUMNS.copyNumber]: <FormattedMessage id="ui-inventory.copyNumber" />,
[PIECE_COLUMNS.enumeration]: <FormattedMessage id="ui-inventory.enumeration" />,
[PIECE_COLUMNS.receiptDate]: <FormattedMessage id="ui-inventory.receiptDate" />,
[PIECE_COLUMNS.remove]: '',
};

export const VISIBLE_COLUMNS = [
PIECE_COLUMNS.barcode,
PIECE_COLUMNS.displaySummary,
PIECE_COLUMNS.chronology,
PIECE_COLUMNS.copyNumber,
PIECE_COLUMNS.enumeration,
PIECE_COLUMNS.receiptDate,
PIECE_COLUMNS.remove,
];
1 change: 1 addition & 0 deletions src/components/BoundPiecesList/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './BoundPiecesList';
44 changes: 44 additions & 0 deletions src/components/BoundPiecesList/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { FolioFormattedDate } from '@folio/stripes-acq-components';
import {
Button,
Icon,
NoValue,
TextLink,
} from '@folio/stripes/components';

import { PIECE_COLUMNS } from './constants';

// eslint-disable-next-line import/prefer-default-export
export const getColumnFormatter = ({ hasViewReceivingPermissions, onRemove }) => {
return ({
[PIECE_COLUMNS.barcode]: record => {
const { barcode, titleId } = record;

if (!barcode) return <NoValue />;

if (!hasViewReceivingPermissions) return barcode;

if (titleId) {
return <TextLink target="_blank" to={`/receiving/${titleId}/view`}>{barcode}</TextLink>;
}

return barcode;
},
[PIECE_COLUMNS.displaySummary]: record => record.displaySummary || <NoValue />,
[PIECE_COLUMNS.chronology]: record => record.chronology || <NoValue />,
[PIECE_COLUMNS.copyNumber]: record => record.copyNumber || <NoValue />,
[PIECE_COLUMNS.enumeration]: record => record.enumeration || <NoValue />,
[PIECE_COLUMNS.receiptDate]: record => <FolioFormattedDate value={record.receiptDate} />,
[PIECE_COLUMNS.remove]: record => (
<Button
buttonStyle="fieldControl"
align="end"
type="button"
id={`clickable-remove-user-${record.id}`}
onClick={() => onRemove(record)}
>
<Icon icon="times-circle" />
</Button>
),
});
};
Loading

0 comments on commit aa7cf04

Please sign in to comment.