diff --git a/CHANGELOG.md b/CHANGELOG.md index c223ef9fd..a1f70b4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * Multi-year prepayments fixes and improvements. Refs UIOR-1572. * Integrate the Connected Tasks/Jobs plugin with Purchase Order and PO Line detail views. Refs UIOR-1578. * Set initial sorting column for "Related invoice lines" MCL. Refs UIOR-1581. +* Add Order email templates settings with token picker and Handlebars backend-rendered preview. Refs UIOR-1492, UIOR-1493, UIOR-1495. ## [9.0.5](https://github.com/folio-org/ui-orders/tree/v9.0.5) (2026-07-20) [Full Changelog](https://github.com/folio-org/ui-orders/compare/v9.0.4...v9.0.5) diff --git a/package.json b/package.json index aa070d293..23f55f2cb 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "settings": "1.0", "search-inventory": "1.0", "tags": "1.0", - "template-engine": "2.0", + "template-engine": "2.3", "tenant-addresses": "1.0", "titles": "1.2", "users": "15.0 16.0" @@ -400,6 +400,48 @@ "orders.order-templates.item.delete" ] }, + { + "permissionName": "ui-orders.settings.email-templates.view", + "displayName": "Settings (Orders): Can view Email Templates", + "description": "", + "visible": true, + "subPermissions": [ + "settings.orders.enabled", + "templates.collection.get", + "templates.item.get", + "template-request.preview.post" + ] + }, + { + "permissionName": "ui-orders.settings.email-templates.edit", + "displayName": "Settings (Orders): Can view and edit Email Templates", + "description": "", + "visible": true, + "subPermissions": [ + "ui-orders.settings.email-templates.view", + "templates.item.put" + ] + }, + { + "permissionName": "ui-orders.settings.email-templates.create", + "displayName": "Settings (Orders): Can create Email Templates", + "description": "", + "visible": true, + "subPermissions": [ + "ui-orders.settings.email-templates.edit", + "templates.item.post" + ] + }, + { + "permissionName": "ui-orders.settings.email-templates.delete", + "displayName": "Settings (Orders): Can delete Email Templates", + "description": "", + "visible": true, + "subPermissions": [ + "ui-orders.settings.email-templates.edit", + "templates.item.delete" + ] + }, { "permissionName": "mod-settings.global.read.ui-orders.custom-fields.manage", "displayName": "Settings (Orders): Read the settings in the 'custom fields' section", @@ -482,7 +524,8 @@ "ui-orders.third-party-services.execute", "orders.acquisition-methods.collection.get", "ui-orders.settings.custom-fields.view", - "templates.item.get" + "templates.item.get", + "ui-orders.settings.email-templates.view" ] }, { @@ -522,7 +565,9 @@ "ui-orders.settings.custom-fields.edit", "ui-orders.settings.custom-fields.delete", "templates.item.post", - "templates.item.put" + "templates.item.put", + "ui-orders.settings.email-templates.create", + "ui-orders.settings.email-templates.delete" ] }, { @@ -591,7 +636,7 @@ "dependencies": { "@folio/plugin-find-po-line": "^7.0.0", "@folio/stripes-acq-components": "~7.2.0", - "@folio/stripes-template-editor": "^3.2.0", + "@folio/stripes-template-editor": "^3.6.0", "classnames": "^2.2.5", "dompurify": "^3.0.9", "final-form": "^4.19.1", diff --git a/src/settings/EmailTemplates/EmailTemplateDetail.js b/src/settings/EmailTemplates/EmailTemplateDetail.js new file mode 100644 index 000000000..101b2b23b --- /dev/null +++ b/src/settings/EmailTemplates/EmailTemplateDetail.js @@ -0,0 +1,152 @@ +import { useMemo, useState } from 'react'; +import { Parser } from 'html-to-react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +import { + Accordion, + AccordionSet, + AccordionStatus, + Button, + Col, + ExpandAllButton, + KeyValue, + Row, +} from '@folio/stripes/components'; +import { ViewMetaData } from '@folio/stripes/smart-components'; +import { + PreviewModal, + sanitize, + tokensReducer, +} from '@folio/stripes-template-editor'; + +import { ORDER_EMAIL_TOKENS } from './constants'; +import { SAMPLE_PREVIEW_CONTEXT } from './samplePreviewContext'; + +const parser = new Parser(); +const PREVIEW_FORMAT = tokensReducer(ORDER_EMAIL_TOKENS); + +const EmailTemplateDetail = ({ initialValues = {} }) => { + const [openPreview, setOpenPreview] = useState(false); + + const { + name, + description, + active, + localizedTemplates, + metadata, + templateResolver, + } = initialValues; + + const { header: subject, body } = localizedTemplates?.en || {}; + + // Same sanitizer as the editor, so the stored template is shown as saved. + const parsedBody = useMemo(() => parser.parse(sanitize(body || '')), [body]); + + const togglePreviewDialog = () => { + setOpenPreview(!openPreview); + }; + + return ( + + + + + + + + } + > + + {metadata && } + + + + } + value={name} + /> + + + } + value={active ? : } + /> + + + + + } + value={description} + /> + + + + + } + > + + + } + value={subject} + /> + + + + + + + + } + value={parsedBody} + /> + + + + + + + } + previewTemplate={body} + previewFormat={PREVIEW_FORMAT} + previewRenderer="backend" + previewContext={SAMPLE_PREVIEW_CONTEXT} + previewSubject={subject ?? ''} + previewTemplateResolver={templateResolver} + onClose={togglePreviewDialog} + /> + + ); +}; + +EmailTemplateDetail.propTypes = { + initialValues: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + description: PropTypes.string, + active: PropTypes.bool, + localizedTemplates: PropTypes.shape({ + en: PropTypes.shape({ + header: PropTypes.string, + body: PropTypes.string, + }), + }), + metadata: PropTypes.object, + templateResolver: PropTypes.string, + }), +}; + +export default EmailTemplateDetail; diff --git a/src/settings/EmailTemplates/EmailTemplateDetail.test.js b/src/settings/EmailTemplates/EmailTemplateDetail.test.js new file mode 100644 index 000000000..88663a890 --- /dev/null +++ b/src/settings/EmailTemplates/EmailTemplateDetail.test.js @@ -0,0 +1,119 @@ +import { + act, + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import { + PreviewModal, + tokensReducer, +} from '@folio/stripes-template-editor'; + +import { ORDER_EMAIL_TOKENS } from './constants'; +import EmailTemplateDetail from './EmailTemplateDetail'; +import { SAMPLE_PREVIEW_CONTEXT } from './samplePreviewContext'; + +jest.mock('@folio/stripes-template-editor', () => ({ + ...jest.requireActual('@folio/stripes-template-editor'), + PreviewModal: jest.fn(() => null), +})); + +const initialValues = { + id: 'template-id', + name: 'Standing orders', + description: 'Sent for ongoing orders', + active: true, + templateResolver: 'handlebars', + localizedTemplates: { + en: { + header: 'Order {{order.poNumber}}', + body: '
Dear {{organization.name}} terms
', + }, + }, +}; + +const renderDetail = (props = {}) => render( + , +); + +describe('EmailTemplateDetail', () => { + beforeEach(() => { + PreviewModal.mockClear(); + }); + + it('should display the general information', () => { + renderDetail(); + + expect(screen.getByText('Standing orders')).toBeInTheDocument(); + expect(screen.getByText('Sent for ongoing orders')).toBeInTheDocument(); + expect(screen.getByText('ui-orders.settings.emailTemplates.active.yes')).toBeInTheDocument(); + }); + + it('should show the record metadata when the template has some', () => { + renderDetail({ initialValues: { ...initialValues, metadata: { createdDate: '2026-10-08T13:22:38.215Z' } } }); + + expect(screen.getByText('ViewMetaData')).toBeInTheDocument(); + }); + + it('should not show record metadata for a template without any', () => { + renderDetail(); + + expect(screen.queryByText('ViewMetaData')).not.toBeInTheDocument(); + }); + + it('should display an inactive template as such', () => { + renderDetail({ initialValues: { ...initialValues, active: false } }); + + expect(screen.getByText('ui-orders.settings.emailTemplates.active.no')).toBeInTheDocument(); + }); + + it('should display the subject and the body as sanitized HTML', () => { + renderDetail(); + + expect(screen.getByText('Order {{order.poNumber}}')).toBeInTheDocument(); + expect(screen.getByText('{{organization.name}}').tagName).toBe('STRONG'); + expect(document.querySelector('script')).toBeNull(); + }); + + it('should keep the link attributes the editor allows', () => { + renderDetail(); + + expect(screen.getByRole('link', { name: 'terms' })).toHaveAttribute('target', '_blank'); + }); + + it('should render without a template', () => { + render(); + + expect(screen.getByText('ui-orders.settings.emailTemplates.body')).toBeInTheDocument(); + }); + + it('should open the backend-rendered preview of the stored template', async () => { + renderDetail(); + + expect(PreviewModal.mock.lastCall[0].open).toBe(false); + + await userEvent.click(screen.getByRole('button', { name: 'ui-orders.settings.emailTemplates.preview' })); + + expect(PreviewModal.mock.lastCall[0]).toEqual(expect.objectContaining({ + open: true, + previewRenderer: 'backend', + previewTemplate: initialValues.localizedTemplates.en.body, + previewSubject: 'Order {{order.poNumber}}', + previewTemplateResolver: 'handlebars', + previewContext: SAMPLE_PREVIEW_CONTEXT, + previewFormat: tokensReducer(ORDER_EMAIL_TOKENS), + })); + }); + + it('should close the preview', async () => { + renderDetail(); + + await userEvent.click(screen.getByRole('button', { name: 'ui-orders.settings.emailTemplates.preview' })); + act(() => PreviewModal.mock.lastCall[0].onClose()); + + expect(PreviewModal.mock.lastCall[0].open).toBe(false); + }); +}); diff --git a/src/settings/EmailTemplates/EmailTemplateForm.css b/src/settings/EmailTemplates/EmailTemplateForm.css new file mode 100644 index 000000000..f28ad396b --- /dev/null +++ b/src/settings/EmailTemplates/EmailTemplateForm.css @@ -0,0 +1,3 @@ +.emailTemplateForm { + height: inherit; +} diff --git a/src/settings/EmailTemplates/EmailTemplateForm.js b/src/settings/EmailTemplates/EmailTemplateForm.js new file mode 100644 index 000000000..df97c991d --- /dev/null +++ b/src/settings/EmailTemplates/EmailTemplateForm.js @@ -0,0 +1,226 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + FormattedMessage, + injectIntl, +} from 'react-intl'; +import { Field } from 'react-final-form'; + +import { + Accordion, + AccordionSet, + AccordionStatus, + Button, + Checkbox, + Col, + ExpandAllButton, + Pane, + PaneFooter, + PaneHeader, + Paneset, + Row, + TextArea, + TextField, +} from '@folio/stripes/components'; +import stripesFinalForm from '@folio/stripes/final-form'; +import { ViewMetaData } from '@folio/stripes/smart-components'; +import { TemplateEditor } from '@folio/stripes-template-editor'; + +import { ORDER_EMAIL_TOKENS } from './constants'; +import { SAMPLE_PREVIEW_CONTEXT } from './samplePreviewContext'; +import TokensList from './TokensList'; +import validate from './validate'; + +import css from './EmailTemplateForm.css'; + +const EmailTemplateForm = ({ + handleSubmit, + initialValues, + intl: { formatMessage }, + onCancel, + pristine, + submitting, + values, +}) => { + const [editAsHtml, setEditAsHtml] = useState(false); + + const paneTitle = initialValues?.id + ? initialValues?.name + : formatMessage({ id: 'ui-orders.settings.emailTemplates.new' }); + + const renderHeader = (paneHeaderProps) => ( + + ); + + const renderFooter = () => { + const saveButton = ( + + ); + + const cancelButton = ( + + ); + + return ( + + ); + }; + + return ( +
+ + + + + + + + + + } + > + + {initialValues?.metadata && } + + + + } + component={TextField} + required + autoFocus + /> + + + } + component={Checkbox} + type="checkbox" + /> + + + + + } + component={TextArea} + /> + + + + + } + > + + + } + component={TextField} + required + /> + + + + + } + checked={editAsHtml} + onChange={(e) => setEditAsHtml(e.target.checked)} + /> + + + + + } + component={TemplateEditor} + editAsHtml={editAsHtml} + tokens={ORDER_EMAIL_TOKENS} + tokensList={TokensList} + previewModalHeader={( + + )} + previewRenderer="backend" + previewContext={SAMPLE_PREVIEW_CONTEXT} + previewSubject={values?.localizedTemplates?.en?.header ?? ''} + previewTemplateResolver={values?.templateResolver} + required + /> + + + + + + + +
+ ); +}; + +EmailTemplateForm.propTypes = { + handleSubmit: PropTypes.func.isRequired, + initialValues: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + metadata: PropTypes.object, + }), + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + onCancel: PropTypes.func.isRequired, + pristine: PropTypes.bool, + submitting: PropTypes.bool, + values: PropTypes.shape({ + name: PropTypes.string, + localizedTemplates: PropTypes.object, + templateResolver: PropTypes.string, + }), +}; + +export default stripesFinalForm({ + navigationCheck: true, + subscription: { + values: true, + }, + validate, +})(injectIntl(EmailTemplateForm)); diff --git a/src/settings/EmailTemplates/EmailTemplateForm.test.js b/src/settings/EmailTemplates/EmailTemplateForm.test.js new file mode 100644 index 000000000..287580e9c --- /dev/null +++ b/src/settings/EmailTemplates/EmailTemplateForm.test.js @@ -0,0 +1,191 @@ +import { MemoryRouter } from 'react-router-dom'; + +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import { TemplateEditor } from '@folio/stripes-template-editor'; + +import EmailTemplateForm from './EmailTemplateForm'; +import { SAMPLE_PREVIEW_CONTEXT } from './samplePreviewContext'; + +jest.mock('@folio/stripes-template-editor', () => ({ + TemplateEditor: jest.fn(({ input, label }) => ( + <> + +