From eb5910a1ff22c6ca47230724fac1fc9347e2068f Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Sat, 13 Jan 2024 23:00:11 -0500 Subject: [PATCH 1/6] Add PatronNoticePrintJobs --- .../PatronNoticePrintJobsLink.js | 35 ++++ .../PatronNoticePrintJobsLink/index.js | 1 + src/index.js | 1 + src/routes/PatronNoticePrintJobsContainer.js | 67 +++++++ src/routes/index.js | 1 + .../PatronNoticePrintJobs.css | 0 .../PatronNoticePrintJobs.js | 167 ++++++++++++++++++ src/views/PatronNoticePrintJobs/index.js | 1 + src/views/UserSearch/UserSearch.js | 2 + translations/ui-users/en.json | 11 +- 10 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src/components/PatronNoticePrintJobsLink/PatronNoticePrintJobsLink.js create mode 100644 src/components/PatronNoticePrintJobsLink/index.js create mode 100644 src/routes/PatronNoticePrintJobsContainer.js create mode 100644 src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.css create mode 100644 src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js create mode 100644 src/views/PatronNoticePrintJobs/index.js diff --git a/src/components/PatronNoticePrintJobsLink/PatronNoticePrintJobsLink.js b/src/components/PatronNoticePrintJobsLink/PatronNoticePrintJobsLink.js new file mode 100644 index 000000000..1f9c795dd --- /dev/null +++ b/src/components/PatronNoticePrintJobsLink/PatronNoticePrintJobsLink.js @@ -0,0 +1,35 @@ +import { FormattedMessage } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import { IfPermission } from '@folio/stripes/core'; +import { + Button, + Icon, +} from '@folio/stripes/components'; + +const PatronNoticePrintJobsLink = () => { + const history = useHistory(); + + return ( + + + + ); +}; + + +export default PatronNoticePrintJobsLink; diff --git a/src/components/PatronNoticePrintJobsLink/index.js b/src/components/PatronNoticePrintJobsLink/index.js new file mode 100644 index 000000000..64e420610 --- /dev/null +++ b/src/components/PatronNoticePrintJobsLink/index.js @@ -0,0 +1 @@ +export { default } from './PatronNoticePrintJobsLink'; diff --git a/src/index.js b/src/index.js index c6beac6f0..800ea1b37 100644 --- a/src/index.js +++ b/src/index.js @@ -215,6 +215,7 @@ class UsersRouting extends React.Component { + diff --git a/src/routes/PatronNoticePrintJobsContainer.js b/src/routes/PatronNoticePrintJobsContainer.js new file mode 100644 index 000000000..2ac09397d --- /dev/null +++ b/src/routes/PatronNoticePrintJobsContainer.js @@ -0,0 +1,67 @@ + +import PropTypes from 'prop-types'; +import { useHistory } from 'react-router-dom'; + +import { stripesConnect } from '@folio/stripes/core'; + +import PatronNoticePrintJobs from '../views/PatronNoticePrintJobs'; + +const PatronNoticePrintJobsContainer = (props) => { + const { mutator, resources } = props; + const records = resources?.entries?.records; + const history = useHistory(); + + const onClose = () => { + const { location } = props; + if (location.state) { + history.goBack(); + } else { + history.push('/users?sort=name'); + } + }; + + return ( + + ); +}; + +PatronNoticePrintJobsContainer.manifest = { + entries: { + type: 'okapi', + path: 'print/entries', + params: { + query: 'type="BATCH"', + sortby: 'created/sort.descending' + }, + records: 'items', + throwErrors: false, + }, + printingJob: { + type: 'okapi', + path: 'print/entries', + accumulate: 'true', + fetch: false, + throwErrors: false, + }, +}; + +PatronNoticePrintJobsContainer.propTypes = { + resources: PropTypes.shape({ + entries: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object), + }), + }).isRequired, + mutator: PropTypes.shape({ + printingJob: PropTypes.shape({ + GET: PropTypes.func, + reset: PropTypes.func, + }), + }).isRequired, + location: PropTypes.object, +}; + +export default stripesConnect(PatronNoticePrintJobsContainer); diff --git a/src/routes/index.js b/src/routes/index.js index 52d8e2644..9d4965702 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -9,3 +9,4 @@ export { default as LoansListingContainer } from './LoansListingContainer'; export { default as LoanDetailContainer } from './LoanDetailContainer'; export { default as AccountDetailsContainer } from './AccountDetailsContainer'; export { default as LostItemsContainer } from './LostItemsContainer'; +export { default as PatronNoticePrintJobsContainer } from './PatronNoticePrintJobsContainer'; diff --git a/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.css b/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js b/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js new file mode 100644 index 000000000..edcee4173 --- /dev/null +++ b/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js @@ -0,0 +1,167 @@ + +import { orderBy } from 'lodash'; +import { Button, Pane, MenuSection, MultiColumnList, Checkbox, FormattedDate, FormattedTime, TextLink } from '@folio/stripes/components'; +import { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { useOkapiKy, useCallout } from '@folio/stripes/core'; + +import css from './PatronNoticePrintJobs.css'; + +const ASC = 'ascending'; +const DESC = 'descending'; +const visibleColumns = ['id', 'created']; + +export const generateFormatter = (markPrintJobForDeletion, openPDF) => { + return { + id: (item) => ( + markPrintJobForDeletion(item)} + /> + ), + created: (item) => ( + openPDF(item)}> + + + ) + }; +}; + +const PatronNoticePrintJobs = (props) => { + const { records, mutator, onClose } = props; + const [contentData, setContentData] = useState([]); + const [sortOrder, setSortOrder] = useState(DESC); + const [allSelected, toggleSelectAll] = useState(false); + const sort = () => setSortOrder(sortOrder === DESC ? ASC : DESC); + const ky = useOkapiKy(); + const callout = useCallout(); + + const markPrintJobForDeletion = (item) => { + const clonedData = [...contentData]; + const index = clonedData.findIndex(el => el.id === item.id); + clonedData[index] = { ...item, selected: !item.selected }; + + setContentData(clonedData); + }; + + const markAllPrintJobForDeletions = () => { + toggleSelectAll(!allSelected); + const clonedData = contentData.map(el => ({ ...el, selected: !allSelected })); + setContentData(clonedData); + }; + + const openPDF = async (item) => { + try { + mutator.printingJob.reset(); + const { content } = await mutator.printingJob.GET({ path: `print/entries/${item.id}` }); + const bytes = new Uint8Array(content.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); + const blob = new Blob([bytes], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + } catch (error) { + callout.sendCallout({ + message: , + type: 'error', + }); + } + }; + + useEffect(() => { + const updatedRecords = + orderBy(records, (item) => item.created, sortOrder === DESC ? 'desc' : 'asc') + .map(record => ({ + ...record, + selected: !!record.selected, + })); + + setContentData(updatedRecords); + }, [records, sortOrder]); + + const formatter = generateFormatter(markPrintJobForDeletion, openPDF); + const columnMapping = { + id: markAllPrintJobForDeletions()} />, + created: , + }; + + const actionMenu = ({ onToggle }) => { + const removeSelectedPrintJobs = async () => { + const selectedJobs = contentData.filter(item => item.selected); + const ids = selectedJobs.map(job => job.id).join(','); + + await ky.delete(`print/entries?ids=${ids}`); + + const filtered = contentData.filter(item => !item.selected); + + setContentData(filtered); + onToggle(); + toggleSelectAll(false); + }; + + return ( + }> + + + ); + }; + + return ( + + } + defaultWidth="fill" + dismissible + actionMenu={actionMenu} + onClose={onClose} + > + + + ); +}; + +PatronNoticePrintJobs.manifest = { + entries: { + type: 'okapi', + path: 'print/entries', + params: { + query: 'type="BATCH"', + sortby: 'created/sort.descending' + }, + records: 'items', + throwErrors: false, + }, + printingJob: { + type: 'okapi', + path: 'print/entries', + accumulate: 'true', + fetch: false, + throwErrors: false, + }, +}; + +PatronNoticePrintJobs.propTypes = { + records: PropTypes.arrayOf(PropTypes.object), + onClose: PropTypes.func, + mutator: PropTypes.shape({ + printingJob: PropTypes.shape({ + GET: PropTypes.func, + reset: PropTypes.func, + }), + }).isRequired, +}; + +export default PatronNoticePrintJobs; diff --git a/src/views/PatronNoticePrintJobs/index.js b/src/views/PatronNoticePrintJobs/index.js new file mode 100644 index 000000000..8578b7841 --- /dev/null +++ b/src/views/PatronNoticePrintJobs/index.js @@ -0,0 +1 @@ +export { default } from './PatronNoticePrintJobs'; diff --git a/src/views/UserSearch/UserSearch.js b/src/views/UserSearch/UserSearch.js index b28287d40..6d1648761 100644 --- a/src/views/UserSearch/UserSearch.js +++ b/src/views/UserSearch/UserSearch.js @@ -45,6 +45,7 @@ import CashDrawerReconciliationReportPDF from '../../components/data/reports/cas import CashDrawerReconciliationReportCSV from '../../components/data/reports/cashDrawerReconciliationReportCSV'; import FinancialTransactionsReport from '../../components/data/reports/FinancialTransactionsReport'; import LostItemsLink from '../../components/LostItemsLink'; +import PatronNoticePrintJobsLink from '../../components/PatronNoticePrintJobsLink'; import Filters from './Filters'; import css from './UserSearch.css'; @@ -292,6 +293,7 @@ class UserSearch extends React.Component { + {amount} has been successfully charged to {patronName}", "lostItems.notification.billedBefore": "{patronName} has already been billed for this item by another user", "lostItems.notification.cancelled": "A lost item fee will not be charged to {patronName}", - "lostItems.notification.serverError": "A system error has occurred - please try again later or contact your FOLIO support staff" + "lostItems.notification.serverError": "A system error has occurred - please try again later or contact your FOLIO support staff", + "patronNoticePrintJobs.label": "Patron notice print jobs", + "patronNoticePrintJobs.actions": "Actions", + "patronNoticePrintJobs.actions.delete": "Delete selected print jobs", + "patronNoticePrintJobs.name": "Name", + "patronNoticePrintJobs.email": "Email", + "patronNoticePrintJobs.updated": "Updated", + "patronNoticePrintJobs.created": "Created", + "patronNoticePrintJobs.errors.pdf": "'PDF generation failed" } From c709b927f33dc346afad4136e140c76c9dcba8ba Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 23 Jan 2024 18:48:07 -0500 Subject: [PATCH 2/6] Add tests --- CHANGELOG.md | 1 + .../PatronNoticePrintJobs.js | 20 ---- .../PatronNoticePrintJobs.test.js | 93 +++++++++++++++++++ 3 files changed, 94 insertions(+), 20 deletions(-) create mode 100644 src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c2e7cf4..3c0481920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * Create new permission 'Users: Can view profile pictures'. Refs UIU-3018. * Format currency values as currencies, not numbers. Refs UIU-2026. * Show country name in user address instead of country id. Refs UIU-2976. +* Add patron notice print jobs to action menu. Refs UIU-3029. ## [10.0.4](https://github.com/folio-org/ui-users/tree/v10.0.4) (2023-11-10) [Full Changelog](https://github.com/folio-org/ui-users/compare/v10.0.3...v10.0.4) diff --git a/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js b/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js index edcee4173..f0a0387b2 100644 --- a/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js +++ b/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js @@ -133,26 +133,6 @@ const PatronNoticePrintJobs = (props) => { ); }; -PatronNoticePrintJobs.manifest = { - entries: { - type: 'okapi', - path: 'print/entries', - params: { - query: 'type="BATCH"', - sortby: 'created/sort.descending' - }, - records: 'items', - throwErrors: false, - }, - printingJob: { - type: 'okapi', - path: 'print/entries', - accumulate: 'true', - fetch: false, - throwErrors: false, - }, -}; - PatronNoticePrintJobs.propTypes = { records: PropTypes.arrayOf(PropTypes.object), onClose: PropTypes.func, diff --git a/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.test.js b/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.test.js new file mode 100644 index 000000000..4b78ffea6 --- /dev/null +++ b/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.test.js @@ -0,0 +1,93 @@ + +import { render, waitFor, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import PatronNoticePrintJobs, { generateFormatter } from './PatronNoticePrintJobs'; + +jest.unmock('@folio/stripes/components'); + +jest.mock('@folio/stripes/core', () => ({ + stripesConnect: jest.fn(Component => Component), + useOkapiKy: jest.fn(), + useCallout: jest.fn(), +})); + +global.URL.createObjectURL = jest.fn(); +global.window.open = jest.fn(); + +const PDF_IN_HEX = '255044462d312e330a25e2e3cfd30a312030206f626a0a3c3c2f57696474682032203020522f4865696768742033203020522f547970652033203020522f537562747970652034203020522f46696c7465722035203020522f436f6c6f7253706163652036203020522f4c656e6774682037203020522f4865696768742038203020522f417373656d626c792035203020522f50726f632033203020'; + +describe('PatronNoticePrintJobs', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + const mockMutator = { + printingJob: { + GET: jest.fn(() => ({ content: PDF_IN_HEX })), + reset: jest.fn(), + }, + entries: { + reset: jest.fn(), + } + }; + + const mockRecords = [ + { id: '1', created: '2022-01-01T12:00:00Z', selected: false }, + { id: '2', created: '2022-01-02T12:00:00Z', selected: false }, + ]; + + it('renders data', async () => { + const { container } = render(); + expect(container).toHaveTextContent('2022-01-01'); + }); + + it('calls openPDF', async () => { + render(); + + const textElement = screen.getByText(/2022-01-01/i); + const printJobLinkElement = textElement.closest('.printJobLink'); + + userEvent.click(printJobLinkElement); + + await waitFor(() => { + expect(mockMutator.printingJob.GET).toHaveBeenCalledTimes(1); + }); + }); + + + it('calls markAllPrintJobForDeletions', async () => { + const { getAllByRole } = render(); + const checkboxes = getAllByRole('checkbox'); + + userEvent.click(checkboxes[0]); + + await waitFor(() => { + checkboxes.slice(1).forEach((checkbox) => { + expect(checkbox).toBeChecked(); + }); + }); + }); + + it('calls markPrintJobForDeletion', async () => { + const { getAllByRole } = render(); + const checkboxes = getAllByRole('checkbox'); + const checkbox = checkboxes[1]; + + userEvent.click(checkbox); + + await waitFor(() => { + expect(checkbox).toBeChecked(); + }); + }); +}); + +describe('generateFormatter', () => { + it('returns correct formatter', () => { + const markPrintJobForDeletion = jest.fn(); + const openPDF = jest.fn(); + const formatter = generateFormatter(markPrintJobForDeletion, openPDF); + + expect(typeof formatter.id).toBe('function'); + expect(typeof formatter.created).toBe('function'); + }); +}); From 79b6a4f846236cfad4301e299a1af1fb8eb7813d Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 23 Jan 2024 19:01:51 -0500 Subject: [PATCH 3/6] Cleanup --- src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js b/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js index f0a0387b2..ac4164fd8 100644 --- a/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js +++ b/src/views/PatronNoticePrintJobs/PatronNoticePrintJobs.js @@ -41,8 +41,8 @@ const PatronNoticePrintJobs = (props) => { const markPrintJobForDeletion = (item) => { const clonedData = [...contentData]; const index = clonedData.findIndex(el => el.id === item.id); - clonedData[index] = { ...item, selected: !item.selected }; + clonedData[index] = { ...item, selected: !item.selected }; setContentData(clonedData); }; @@ -59,6 +59,7 @@ const PatronNoticePrintJobs = (props) => { const bytes = new Uint8Array(content.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); const blob = new Blob([bytes], { type: 'application/pdf' }); const url = URL.createObjectURL(blob); + window.open(url, '_blank'); } catch (error) { callout.sendCallout({ @@ -89,11 +90,10 @@ const PatronNoticePrintJobs = (props) => { const removeSelectedPrintJobs = async () => { const selectedJobs = contentData.filter(item => item.selected); const ids = selectedJobs.map(job => job.id).join(','); + const filtered = contentData.filter(item => !item.selected); await ky.delete(`print/entries?ids=${ids}`); - const filtered = contentData.filter(item => !item.selected); - setContentData(filtered); onToggle(); toggleSelectAll(false); From 9c82b9f0fe6fcfeffa5b0d36a7db5d397c98d8d3 Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 23 Jan 2024 19:16:28 -0500 Subject: [PATCH 4/6] Add permissions --- package.json | 24 ++++++++++++++++++- .../PatronNoticePrintJobsLink.js | 2 +- .../PatronNoticePrintJobs.js | 7 ++++-- .../PatronNoticePrintJobs.test.js | 6 ----- translations/ui-users/en.json | 2 ++ 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 19f061521..5a19c366c 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "loan-policy-storage": "1.0 2.0", "loan-storage": "4.0 5.0 6.0 7.0", "notes": "2.0 3.0", - "request-storage": "2.5 3.0 4.0 5.0 6.0" + "request-storage": "2.5 3.0 4.0 5.0 6.0", + "batch-print": "1.0" + }, "permissionSets": [ { @@ -1022,6 +1024,26 @@ "ui-users.settings.patron-blocks.view" ], "visible": true + }, + { + "permissionName": "ui-users.view-patron-notice-print-jobs", + "displayName": "Users: View patron notice print jobs", + "subPermissions": [ + "ui-users.view", + "mod-batch-print.entries.collection.get", + "mod-batch-print.entries.item.get", + "mod-batch-print.print.read" + ], + "visible": true + }, + { + "permissionName": "ui-users.remove-patron-notice-print-jobs", + "displayName": "Users: View and remove patron notice print jobs", + "subPermissions": [ + "ui-circulation.settings.view-patron-notice-print-jobs", + "mod-batch-print.entries.item.delete" + ], + "visible": true } ] }, diff --git a/src/components/PatronNoticePrintJobsLink/PatronNoticePrintJobsLink.js b/src/components/PatronNoticePrintJobsLink/PatronNoticePrintJobsLink.js index 1f9c795dd..54489c633 100644 --- a/src/components/PatronNoticePrintJobsLink/PatronNoticePrintJobsLink.js +++ b/src/components/PatronNoticePrintJobsLink/PatronNoticePrintJobsLink.js @@ -11,7 +11,7 @@ const PatronNoticePrintJobsLink = () => { const history = useHistory(); return ( - + )); }); -const mockResources = { - entries: { - records: [], - }, -}; +jest.mock('history', () => { + return { + createMemoryHistory: jest.fn(() => ({ + push: jest.fn(), + location: {}, + listen: jest.fn(), + goBack: jest.fn(), + })), + }; +}); const mockMutator = { printingJob: { @@ -20,16 +26,44 @@ const mockMutator = { }, }; -const props = { - resources: mockResources, - mutator: mockMutator +const mockResources = { + entries: { + records: [], + }, }; -const renderPatronNoticePrintJobsContainer = () => renderWithRouter(); +const renderPatronNoticePrintJobsContainer = (extraProps) => renderWithRouter( + +); describe('PatronNoticePrintJobsContainer', () => { - it('renders without crashing', () => { + it('should render PatronNoticePrintJobs', () => { const { container } = renderPatronNoticePrintJobsContainer(); expect(container).toBeInTheDocument(); }); + + it('should go back if location state exists', async () => { + const { getByTestId, history } = renderPatronNoticePrintJobsContainer({ + location: { state: { from: '/previous-page' } } + }); + + fireEvent.click(getByTestId('close-button')); + + await waitFor(() => { + expect(history.goBack).toHaveBeenCalled(); + }); + }); + + + it('should redirect to /users?sort=name if location state does not exist', async () => { + const { getByTestId, history } = renderPatronNoticePrintJobsContainer({ + location: {} + }); + + fireEvent.click(getByTestId('close-button')); + + await waitFor(() => { + expect(history.push).toHaveBeenCalled(); + }); + }); }); diff --git a/test/jest/helpers/renderWithRouter.js b/test/jest/helpers/renderWithRouter.js index 0584f20b3..b4f96f633 100644 --- a/test/jest/helpers/renderWithRouter.js +++ b/test/jest/helpers/renderWithRouter.js @@ -7,8 +7,9 @@ import { createMemoryHistory } from 'history'; let rtlApi; -const history = createMemoryHistory(); + const renderWithRouter = (children, options = {}) => { + const history = createMemoryHistory(); const renderFn = options.rerender ? rtlApi.rerender : render; rtlApi = renderFn( @@ -22,7 +23,7 @@ const renderWithRouter = (children, options = {}) => { ); - return rtlApi; + return { ...rtlApi, history }; }; export default renderWithRouter;