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 (
+
+ );
+};
+
+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 }) => (
+ <>
+
+
+ >
+ )),
+ TokensSection: jest.fn(() => null),
+}));
+
+const newTemplate = {
+ active: true,
+ outputFormats: ['text/html'],
+ templateResolver: 'handlebars',
+ scope: 'orders-email',
+};
+
+const existingTemplate = {
+ ...newTemplate,
+ id: 'template-id',
+ name: 'Standing orders',
+ description: 'Sent for ongoing orders',
+ localizedTemplates: {
+ en: {
+ header: 'Order {{order.poNumber}}',
+ body: 'Dear vendor
',
+ },
+ },
+};
+
+const onSubmit = jest.fn();
+const onCancel = jest.fn();
+
+const renderForm = (initialValues = newTemplate) => render(
+
+
+ ,
+);
+
+const getNameField = () => screen.getByRole('textbox', { name: 'ui-orders.settings.emailTemplates.name' });
+const getSubjectField = () => screen.getByRole('textbox', { name: 'ui-orders.settings.emailTemplates.subject' });
+const getBodyField = () => screen.getByRole('textbox', { name: 'ui-orders.settings.emailTemplates.body' });
+const getSaveButton = () => screen.getByRole('button', { name: 'stripes-components.saveAndClose' });
+
+describe('EmailTemplateForm', () => {
+ beforeEach(() => {
+ onSubmit.mockClear();
+ onCancel.mockClear();
+ TemplateEditor.mockClear();
+ });
+
+ it('should title the pane for a new template and hide the record metadata', () => {
+ renderForm();
+
+ expect(screen.getByText('ui-orders.settings.emailTemplates.new')).toBeInTheDocument();
+ expect(screen.getByRole('checkbox', { name: 'ui-orders.settings.emailTemplates.active' })).toBeChecked();
+ expect(screen.queryByText('ViewMetaData')).not.toBeInTheDocument();
+ });
+
+ it('should title the pane with the name of an existing template and show its metadata', () => {
+ renderForm({ ...existingTemplate, metadata: { createdDate: '2026-10-08T13:22:38.215Z' } });
+
+ expect(screen.getByText('Standing orders')).toBeInTheDocument();
+ expect(screen.getByText('ViewMetaData')).toBeInTheDocument();
+ expect(getSubjectField()).toHaveValue('Order {{order.poNumber}}');
+ expect(getBodyField()).toHaveValue('Dear vendor
');
+ });
+
+ it('should title the preview with the template name', async () => {
+ renderForm(existingTemplate);
+
+ expect(TemplateEditor.mock.lastCall[0].previewModalHeader.props.values.name).toBe('Standing orders');
+
+ await userEvent.type(getNameField(), '!');
+
+ expect(TemplateEditor.mock.lastCall[0].previewModalHeader.props.values.name).toBe('Standing orders!');
+ });
+
+ it('should fall back to the pane title in the preview header of a new template', () => {
+ renderForm();
+
+ expect(TemplateEditor.mock.lastCall[0].previewModalHeader.props.values.name).toBe('ui-orders.settings.emailTemplates.new');
+ });
+
+ it('should keep the save button disabled until something changes', async () => {
+ renderForm(existingTemplate);
+
+ expect(getSaveButton()).toBeDisabled();
+
+ await userEvent.type(getNameField(), ' (vendor)');
+
+ expect(getSaveButton()).toBeEnabled();
+ });
+
+ it('should not submit a template without subject and body', async () => {
+ renderForm();
+
+ await userEvent.type(getNameField(), 'Monographs');
+ await userEvent.click(getSaveButton());
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(screen.getByText('ui-orders.settings.emailTemplates.validation.subjectRequired')).toBeInTheDocument();
+ });
+
+ it('should submit the filled form together with the preset values', async () => {
+ renderForm();
+
+ await userEvent.type(getNameField(), 'Monographs');
+ await userEvent.type(getSubjectField(), 'New order');
+ await userEvent.type(getBodyField(), 'Dear vendor');
+ await userEvent.click(getSaveButton());
+
+ expect(onSubmit).toHaveBeenCalled();
+ expect(onSubmit.mock.calls[0][0]).toEqual({
+ ...newTemplate,
+ name: 'Monographs',
+ localizedTemplates: {
+ en: {
+ header: 'New order',
+ body: 'Dear vendor',
+ },
+ },
+ });
+ });
+
+ it('should configure the editor for the backend-rendered preview', async () => {
+ renderForm(existingTemplate);
+
+ expect(TemplateEditor.mock.lastCall[0]).toEqual(expect.objectContaining({
+ editAsHtml: false,
+ previewRenderer: 'backend',
+ previewContext: SAMPLE_PREVIEW_CONTEXT,
+ previewSubject: 'Order {{order.poNumber}}',
+ previewTemplateResolver: 'handlebars',
+ }));
+
+ await userEvent.type(getSubjectField(), '!');
+
+ expect(TemplateEditor.mock.lastCall[0].previewSubject).toBe('Order {{order.poNumber}}!');
+ });
+
+ it('should send an empty subject to the preview of a new template', () => {
+ renderForm();
+
+ expect(TemplateEditor.mock.lastCall[0].previewSubject).toBe('');
+ });
+
+ it('should switch the editor to raw HTML', async () => {
+ renderForm(existingTemplate);
+
+ await userEvent.click(screen.getByRole('checkbox', { name: 'ui-orders.settings.emailTemplates.editAsHtml' }));
+
+ expect(TemplateEditor.mock.lastCall[0].editAsHtml).toBe(true);
+ });
+
+ it('should cancel from the footer button', async () => {
+ renderForm(existingTemplate);
+
+ await userEvent.click(screen.getByRole('button', { name: 'stripes-core.button.cancel' }));
+
+ expect(onCancel).toHaveBeenCalled();
+ });
+
+ it('should cancel from the close icon of the pane', async () => {
+ renderForm(existingTemplate);
+
+ await userEvent.click(screen.getByRole('button', { name: /closeItem/ }));
+
+ expect(onCancel).toHaveBeenCalled();
+ });
+});
diff --git a/src/settings/EmailTemplates/EmailTemplates.js b/src/settings/EmailTemplates/EmailTemplates.js
new file mode 100644
index 000000000..91de9262e
--- /dev/null
+++ b/src/settings/EmailTemplates/EmailTemplates.js
@@ -0,0 +1,100 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl } from 'react-intl';
+import ReactRouterPropTypes from 'react-router-prop-types';
+import { sortBy } from 'lodash';
+
+import { EntryManager } from '@folio/stripes/smart-components';
+import { stripesConnect, TitleManager } from '@folio/stripes/core';
+import { LIMIT_MAX } from '@folio/stripes-acq-components';
+
+import EmailTemplateDetail from './EmailTemplateDetail';
+import EmailTemplateForm from './EmailTemplateForm';
+import { TEMPLATE_SCOPE } from './constants';
+
+/**
+ * Settings page for the order email templates stored in mod-template-engine,
+ * modelled on PatronNotices in ui-circulation. The templates are told apart
+ * from other apps' templates by their `scope`.
+ */
+export class EmailTemplates extends React.Component {
+ static propTypes = {
+ label: PropTypes.node.isRequired,
+ resources: PropTypes.shape({
+ entries: PropTypes.shape({
+ records: PropTypes.arrayOf(PropTypes.object),
+ }),
+ }).isRequired,
+ mutator: PropTypes.shape({
+ entries: PropTypes.shape({
+ POST: PropTypes.func,
+ PUT: PropTypes.func,
+ DELETE: PropTypes.func,
+ }),
+ }).isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ location: ReactRouterPropTypes.location,
+ };
+
+ static manifest = Object.freeze({
+ entries: {
+ type: 'okapi',
+ path: 'templates',
+ records: 'templates',
+ params: {
+ query: `cql.allRecords=1 AND scope=="${TEMPLATE_SCOPE}"`,
+ },
+ perRequest: LIMIT_MAX,
+ },
+ });
+
+ render() {
+ const {
+ intl: { formatMessage },
+ label,
+ location,
+ resources,
+ mutator,
+ } = this.props;
+
+ const entryList = sortBy(resources.entries?.records || [], ['name']);
+ const sectionLabel = formatMessage({ id: 'ui-orders.settings.emailTemplates.label' });
+ // EntryManager routes the selected template as the last path segment.
+ const selectedId = location?.pathname.split('/').pop();
+ const selectedName = entryList.find(({ id }) => id === selectedId)?.name;
+
+ return (
+
+
+
+ );
+ }
+}
+
+export default stripesConnect(injectIntl(EmailTemplates));
diff --git a/src/settings/EmailTemplates/EmailTemplates.test.js b/src/settings/EmailTemplates/EmailTemplates.test.js
new file mode 100644
index 000000000..53bd6fbb9
--- /dev/null
+++ b/src/settings/EmailTemplates/EmailTemplates.test.js
@@ -0,0 +1,120 @@
+import { render } from '@folio/jest-config-stripes/testing-library/react';
+import { TitleManager } from '@folio/stripes/core';
+import { EntryManager } from '@folio/stripes/smart-components';
+import { LIMIT_MAX } from '@folio/stripes-acq-components';
+
+// eslint-disable-next-line import/no-named-as-default
+import EmailTemplates, { EmailTemplates as EmailTemplatesClass } from './EmailTemplates';
+import EmailTemplateDetail from './EmailTemplateDetail';
+import EmailTemplateForm from './EmailTemplateForm';
+
+jest.mock('@folio/stripes/core', () => ({
+ ...jest.requireActual('@folio/stripes/core'),
+ TitleManager: jest.fn(({ children }) => {children}
),
+ stripesConnect: jest.fn((component) => component),
+}));
+jest.mock('@folio/stripes/smart-components', () => ({
+ ...jest.requireActual('@folio/stripes/smart-components'),
+ EntryManager: jest.fn(() => EntryManager
),
+}));
+jest.mock('./EmailTemplateDetail', () => () => null);
+jest.mock('./EmailTemplateForm', () => () => null);
+
+const mutator = {
+ entries: {
+ POST: jest.fn(),
+ PUT: jest.fn(),
+ DELETE: jest.fn(),
+ },
+};
+
+const renderEmailTemplates = (records = [], pathname = '/settings/orders/email-templates') => render(
+ ,
+);
+
+describe('EmailTemplates', () => {
+ beforeEach(() => {
+ EntryManager.mockClear();
+ TitleManager.mockClear();
+ });
+
+ it('should load all templates with the orders scope', () => {
+ expect(EmailTemplatesClass.manifest.entries).toEqual(expect.objectContaining({
+ params: { query: 'cql.allRecords=1 AND scope=="orders-email"' },
+ perRequest: LIMIT_MAX,
+ }));
+ });
+
+ it('should put the selected template into the document title', () => {
+ const records = [{ id: 'serials', name: 'Serials' }, { id: 'books', name: 'Books' }];
+
+ renderEmailTemplates(records, '/settings/orders/email-templates/books');
+
+ expect(TitleManager.mock.lastCall[0]).toEqual(expect.objectContaining({
+ page: 'ui-orders.settings.emailTemplates.label',
+ record: 'Books',
+ }));
+ });
+
+ it('should title the document with the section while the list is shown', () => {
+ renderEmailTemplates([{ id: 'books', name: 'Books' }]);
+
+ expect(TitleManager.mock.lastCall[0]).toEqual(expect.objectContaining({
+ page: undefined,
+ record: 'ui-orders.settings.emailTemplates.label',
+ }));
+ });
+
+ it('should pass the templates sorted by name to EntryManager', () => {
+ renderEmailTemplates([{ name: 'Serials' }, { name: 'Books' }]);
+
+ expect(EntryManager.mock.lastCall[0]).toEqual(expect.objectContaining({
+ entryList: [{ name: 'Books' }, { name: 'Serials' }],
+ detailComponent: EmailTemplateDetail,
+ entryFormComponent: EmailTemplateForm,
+ parentMutator: mutator,
+ paneTitle: 'Order email templates',
+ entryLabel: 'ui-orders.settings.emailTemplates.entryLabel',
+ nameKey: 'name',
+ enableDetailsActionMenu: true,
+ }));
+ });
+
+ it('should preset new templates as active Handlebars HTML templates of the orders scope', () => {
+ renderEmailTemplates();
+
+ expect(EntryManager.mock.lastCall[0].defaultEntry).toEqual({
+ active: true,
+ outputFormats: ['text/html'],
+ templateResolver: 'handlebars',
+ scope: 'orders-email',
+ });
+ });
+
+ it('should guard create, edit and delete with the email template permissions', () => {
+ renderEmailTemplates();
+
+ expect(EntryManager.mock.lastCall[0].permissions).toEqual({
+ put: 'ui-orders.settings.email-templates.edit',
+ post: 'ui-orders.settings.email-templates.create',
+ delete: 'ui-orders.settings.email-templates.delete',
+ });
+ });
+
+ it('should cope with resources that have not loaded yet', () => {
+ render(
+ ,
+ );
+
+ expect(EntryManager.mock.lastCall[0].entryList).toEqual([]);
+ });
+});
diff --git a/src/settings/EmailTemplates/TokensList/TokensList.js b/src/settings/EmailTemplates/TokensList/TokensList.js
new file mode 100644
index 000000000..a0faec5db
--- /dev/null
+++ b/src/settings/EmailTemplates/TokensList/TokensList.js
@@ -0,0 +1,159 @@
+import PropTypes from 'prop-types';
+import { FormattedMessage, injectIntl } from 'react-intl';
+
+import {
+ Col,
+ Row,
+} from '@folio/stripes/components';
+import { TokensSection } from '@folio/stripes-template-editor';
+
+import {
+ CONTRIBUTORS_LOOP_TAG,
+ FUND_DISTRIBUTION_LOOP_TAG,
+ ORDERS_LOOP_TAG,
+ ORDER_LINES_LOOP_TAG,
+ PRODUCT_IDS_LOOP_TAG,
+ TOKEN_SECTIONS,
+} from '../constants';
+
+const DOCUMENTATION_URL = 'https://folio-org.atlassian.net/wiki/x/CYD_kg';
+
+const TokensList = ({
+ tokens,
+ onLoopSelect,
+ onSectionInit,
+ onTokenSelect,
+ intl: { formatMessage },
+}) => {
+ const makeLoopConfig = (labelId, tag) => ({
+ enabled: true,
+ label: formatMessage({ id: labelId }),
+ tag,
+ isDisabledLoop: null,
+ });
+
+ const ordersLoopConfig = makeLoopConfig('ui-orders.settings.emailTemplates.tokens.multipleOrders', ORDERS_LOOP_TAG);
+ const orderLinesLoopConfig = makeLoopConfig('ui-orders.settings.emailTemplates.tokens.multipleOrderLines', ORDER_LINES_LOOP_TAG);
+ const contributorsLoopConfig = makeLoopConfig('ui-orders.settings.emailTemplates.tokens.multipleContributors', CONTRIBUTORS_LOOP_TAG);
+ const productIdsLoopConfig = makeLoopConfig('ui-orders.settings.emailTemplates.tokens.multipleProductIds', PRODUCT_IDS_LOOP_TAG);
+ const fundsLoopConfig = makeLoopConfig('ui-orders.settings.emailTemplates.tokens.multipleFunds', FUND_DISTRIBUTION_LOOP_TAG);
+
+ return (
+ <>
+
+
+
+
+
+
+ -
+
+
+
+ ),
+ }}
+ />
+
+ -
+
+
+ -
+ {'{{#field}}…{{/field}}'} }}
+ />
+
+ -
+ {'{{orderLine.customFields..value}}'},
+ multi:
{'{{#orderLine.customFields..values}}{{label}}{{/…}}'},
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+TokensList.propTypes = {
+ tokens: PropTypes.object.isRequired,
+ onLoopSelect: PropTypes.func.isRequired,
+ onSectionInit: PropTypes.func.isRequired,
+ onTokenSelect: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+};
+
+export default injectIntl(TokensList);
diff --git a/src/settings/EmailTemplates/TokensList/TokensList.test.js b/src/settings/EmailTemplates/TokensList/TokensList.test.js
new file mode 100644
index 000000000..e02c58c18
--- /dev/null
+++ b/src/settings/EmailTemplates/TokensList/TokensList.test.js
@@ -0,0 +1,82 @@
+import { FormattedMessage } from 'react-intl';
+
+import {
+ render,
+ screen,
+} from '@folio/jest-config-stripes/testing-library/react';
+import { TokensSection } from '@folio/stripes-template-editor';
+
+import {
+ CONTRIBUTORS_LOOP_TAG,
+ FUND_DISTRIBUTION_LOOP_TAG,
+ ORDER_EMAIL_TOKENS,
+ ORDER_LINES_LOOP_TAG,
+ ORDERS_LOOP_TAG,
+ PRODUCT_IDS_LOOP_TAG,
+ TOKEN_SECTIONS,
+} from '../constants';
+import TokensList from './TokensList';
+
+jest.mock('@folio/stripes-template-editor', () => ({
+ TokensSection: jest.fn(() => null),
+}));
+
+const defaultProps = {
+ tokens: ORDER_EMAIL_TOKENS,
+ onLoopSelect: jest.fn(),
+ onSectionInit: jest.fn(),
+ onTokenSelect: jest.fn(),
+};
+
+const getSectionProps = (section) => TokensSection.mock.calls
+ .map(([props]) => props)
+ .find((props) => props.section === section);
+
+describe('TokensList', () => {
+ beforeEach(() => {
+ TokensSection.mockClear();
+ render();
+ });
+
+ it('should render one section per token group', () => {
+ expect(screen.getByTestId('emailTemplateTokenListWrapper')).toBeInTheDocument();
+ expect(TokensSection).toHaveBeenCalledTimes(Object.keys(TOKEN_SECTIONS).length);
+
+ Object.values(TOKEN_SECTIONS).forEach((section) => {
+ expect(getSectionProps(section)).toEqual(expect.objectContaining({
+ tokens: ORDER_EMAIL_TOKENS[section],
+ onSectionInit: defaultProps.onSectionInit,
+ onTokenSelect: defaultProps.onTokenSelect,
+ }));
+ });
+ });
+
+ it('should offer loops for orders, order lines and their nested lists only', () => {
+ const loopTags = {
+ [TOKEN_SECTIONS.ORDER]: ORDERS_LOOP_TAG,
+ [TOKEN_SECTIONS.ORDER_LINES]: ORDER_LINES_LOOP_TAG,
+ [TOKEN_SECTIONS.CONTRIBUTORS]: CONTRIBUTORS_LOOP_TAG,
+ [TOKEN_SECTIONS.PRODUCT_IDS]: PRODUCT_IDS_LOOP_TAG,
+ [TOKEN_SECTIONS.FUNDS]: FUND_DISTRIBUTION_LOOP_TAG,
+ };
+
+ Object.entries(loopTags).forEach(([section, tag]) => {
+ expect(getSectionProps(section).loopConfig).toEqual(expect.objectContaining({ enabled: true, tag }));
+ expect(getSectionProps(section).onLoopSelect).toBe(defaultProps.onLoopSelect);
+ });
+
+ expect(getSectionProps(TOKEN_SECTIONS.GENERAL).loopConfig).toBeUndefined();
+ expect(getSectionProps(TOKEN_SECTIONS.ORGANIZATION).loopConfig).toBeUndefined();
+ });
+
+ it('should show the tips with a link to the documentation', () => {
+ expect(screen.getByText('ui-orders.settings.emailTemplates.tokens.help.title')).toBeInTheDocument();
+
+ // The react-intl mock renders message ids only, so the link is checked on the props.
+ const handlebarsTip = FormattedMessage.mock.calls
+ .map(([props]) => props)
+ .find((props) => props.id === 'ui-orders.settings.emailTemplates.tokens.help.handlebars');
+
+ expect(handlebarsTip.values.documentationLink.props.href).toBe('https://folio-org.atlassian.net/wiki/x/CYD_kg');
+ });
+});
diff --git a/src/settings/EmailTemplates/TokensList/index.js b/src/settings/EmailTemplates/TokensList/index.js
new file mode 100644
index 000000000..6467264e3
--- /dev/null
+++ b/src/settings/EmailTemplates/TokensList/index.js
@@ -0,0 +1 @@
+export { default } from './TokensList';
diff --git a/src/settings/EmailTemplates/constants.js b/src/settings/EmailTemplates/constants.js
new file mode 100644
index 000000000..a0aab929e
--- /dev/null
+++ b/src/settings/EmailTemplates/constants.js
@@ -0,0 +1,89 @@
+import { get } from 'lodash';
+
+import { SAMPLE_PREVIEW_CONTEXT } from './samplePreviewContext';
+
+/**
+ * Tokens map onto the context payload mod-data-export-worker builds for the
+ * email export (MODEXPW-635) and mod-template-engine renders with Handlebars.
+ * Tokens inside a loop section resolve relative to that section, so the
+ * inner-loop sections (contributors, product IDs, funds) use short token
+ * names (e.g. `contributor`) and only work inside the order-line loop.
+ */
+
+export const TEMPLATE_SCOPE = 'orders-email';
+
+export const TOKEN_SECTIONS = {
+ GENERAL: 'general',
+ ORGANIZATION: 'organization',
+ ORDER: 'order',
+ ORDER_LINES: 'orderLines',
+ CONTRIBUTORS: 'contributors',
+ PRODUCT_IDS: 'productIds',
+ FUNDS: 'funds',
+};
+
+// Handlebars section names wrapped around inserted tokens.
+export const ORDERS_LOOP_TAG = 'orders';
+export const ORDER_LINES_LOOP_TAG = 'orderLines';
+export const CONTRIBUTORS_LOOP_TAG = 'orderLine.contributors';
+export const PRODUCT_IDS_LOOP_TAG = 'orderLine.details.productIds';
+export const FUND_DISTRIBUTION_LOOP_TAG = 'orderLine.fundDistribution';
+
+// The preview values feed the regex preview of the editor, which is the
+// fallback of the backend-rendered preview. They come from the first item
+// of each loop in the sample context, so both previews show the same data.
+// The regex preview only inserts strings and numbers, hence the String().
+const [firstOrder] = SAMPLE_PREVIEW_CONTEXT.orders;
+const [firstOrderLine] = firstOrder.orderLines;
+
+const buildTokens = (base, tokens) => tokens.map((token) => ({
+ token,
+ previewValue: String(get(base, token)),
+}));
+
+export const ORDER_EMAIL_TOKENS = {
+ [TOKEN_SECTIONS.GENERAL]: buildTokens(SAMPLE_PREVIEW_CONTEXT, [
+ 'createdAt',
+ ]),
+ [TOKEN_SECTIONS.ORGANIZATION]: buildTokens(SAMPLE_PREVIEW_CONTEXT, [
+ 'organization.name',
+ 'organization.primaryAddress.addressLine1',
+ 'organization.primaryAddress.city',
+ 'organization.primaryAddress.zipCode',
+ 'organization.primaryAddress.country',
+ ]),
+ [TOKEN_SECTIONS.ORDER]: buildTokens(firstOrder, [
+ 'order.poNumber',
+ 'order.orderType',
+ 'order.metadata.createdByUser.fullName',
+ 'order.shipTo.address',
+ 'order.billTo.address',
+ ]),
+ [TOKEN_SECTIONS.ORDER_LINES]: buildTokens(firstOrderLine, [
+ 'orderLine.poLineNumber',
+ 'orderLine.titleOrPackage',
+ 'orderLine.publisher',
+ 'orderLine.publicationDate',
+ 'orderLine.edition',
+ 'orderLine.rush',
+ 'orderLine.cost.listUnitPrice',
+ 'orderLine.cost.listUnitPriceElectronic',
+ 'orderLine.cost.quantityPhysical',
+ 'orderLine.cost.quantityElectronic',
+ 'orderLine.cost.poLineEstimatedPrice',
+ 'orderLine.cost.currency',
+ 'orderLine.vendorDetail.instructions',
+ ]),
+ [TOKEN_SECTIONS.CONTRIBUTORS]: buildTokens(firstOrderLine.orderLine.contributors[0], [
+ 'contributor',
+ 'contributorNameType.name',
+ ]),
+ [TOKEN_SECTIONS.PRODUCT_IDS]: buildTokens(firstOrderLine.orderLine.details.productIds[0], [
+ 'productId',
+ 'qualifier',
+ 'productIdType.name',
+ ]),
+ [TOKEN_SECTIONS.FUNDS]: buildTokens(firstOrderLine.orderLine.fundDistribution[0], [
+ 'code',
+ ]),
+};
diff --git a/src/settings/EmailTemplates/constants.test.js b/src/settings/EmailTemplates/constants.test.js
new file mode 100644
index 000000000..dee8e39bf
--- /dev/null
+++ b/src/settings/EmailTemplates/constants.test.js
@@ -0,0 +1,36 @@
+import { tokensReducer } from '@folio/stripes-template-editor';
+
+import {
+ ORDER_EMAIL_TOKENS,
+ TOKEN_SECTIONS,
+} from './constants';
+import { SAMPLE_PREVIEW_CONTEXT } from './samplePreviewContext';
+
+describe('ORDER_EMAIL_TOKENS', () => {
+ it('should define every token section', () => {
+ expect(Object.keys(ORDER_EMAIL_TOKENS).sort()).toEqual(Object.values(TOKEN_SECTIONS).sort());
+ });
+
+ it('should resolve every token against the sample preview context', () => {
+ Object.values(ORDER_EMAIL_TOKENS).flat().forEach(({ token, previewValue }) => {
+ expect(typeof previewValue).toBe('string');
+ expect(previewValue).not.toBe('');
+ expect(previewValue).not.toBe('undefined');
+ expect(token).not.toBe('');
+ });
+ });
+
+ it('should take the preview values from the sample preview context', () => {
+ const previewFormat = tokensReducer(ORDER_EMAIL_TOKENS);
+ const [firstOrder] = SAMPLE_PREVIEW_CONTEXT.orders;
+ const [firstOrderLine] = firstOrder.orderLines;
+
+ expect(previewFormat['organization.name']).toBe(SAMPLE_PREVIEW_CONTEXT.organization.name);
+ expect(previewFormat['order.poNumber']).toBe(firstOrder.order.poNumber);
+ expect(previewFormat['orderLine.poLineNumber']).toBe(firstOrderLine.orderLine.poLineNumber);
+ expect(previewFormat['orderLine.rush']).toBe(String(firstOrderLine.orderLine.rush));
+ expect(previewFormat.contributor).toBe(firstOrderLine.orderLine.contributors[0].contributor);
+ expect(previewFormat.productId).toBe(firstOrderLine.orderLine.details.productIds[0].productId);
+ expect(previewFormat.code).toBe(firstOrderLine.orderLine.fundDistribution[0].code);
+ });
+});
diff --git a/src/settings/EmailTemplates/index.js b/src/settings/EmailTemplates/index.js
new file mode 100644
index 000000000..fe415293c
--- /dev/null
+++ b/src/settings/EmailTemplates/index.js
@@ -0,0 +1,3 @@
+export { default as EmailTemplates } from './EmailTemplates';
+export { default as EmailTemplateDetail } from './EmailTemplateDetail';
+export { default as EmailTemplateForm } from './EmailTemplateForm';
diff --git a/src/settings/EmailTemplates/samplePreviewContext.js b/src/settings/EmailTemplates/samplePreviewContext.js
new file mode 100644
index 000000000..90a5589ca
--- /dev/null
+++ b/src/settings/EmailTemplates/samplePreviewContext.js
@@ -0,0 +1,254 @@
+/**
+ * Sample context sent to mod-template-engine for the preview. It mirrors the
+ * payload mod-data-export-worker builds for the email export (MODEXPW-635),
+ * so the preview is only faithful as long as the two stay in sync.
+ *
+ * Nesting: `orders[]` wraps each order in an `order` object plus an
+ * `orderLines[]` array, whose items wrap their fields in `orderLine`. Order
+ * lines carry their own loops (`contributors`, `details.productIds`,
+ * `fundDistribution`). There are deliberately no top-level `order` or
+ * `orderLine` fallbacks, because the real payload has none.
+ *
+ * Addresses are pre-formatted strings with \n between the lines, which an
+ * HTML mail collapses unless the template converts them with a helper.
+ */
+
+const organization = {
+ name: 'Libreria Esempio',
+ primaryAddress: {
+ addressLine1: 'Largo dei Librari 1',
+ city: 'Roma',
+ zipCode: '00186',
+ // ISO 3166-1 alpha-3 code, not a display name.
+ country: 'ITA',
+ },
+};
+
+const orderAFields = {
+ poNumber: '10037',
+ orderType: 'One-Time',
+ metadata: {
+ createdByUser: {
+ id: '6e3f5d2a-1c4b-4e8a-9f7d-2b1a0c8e4d11',
+ firstName: 'Sarah',
+ lastName: 'Miller',
+ fullName: 'Sarah Miller',
+ },
+ },
+ shipTo: {
+ id: 'b1d2c3e4-5f60-4a71-8b92-0c1d2e3f4a50',
+ address: 'Branch Library of Humanities\n10 Philosopher Lane\n01234 Booktown',
+ },
+ billTo: {
+ id: 'a0b1c2d3-4e5f-4061-9a82-1b2c3d4e5f60',
+ address: 'University Library - Acquisitions\n456 Campus Road\n01234 Booktown',
+ },
+};
+
+const orderBFields = {
+ poNumber: '10038',
+ orderType: 'Ongoing',
+ metadata: {
+ createdByUser: {
+ id: '7f4e6c3b-2d5a-4f9b-8e6d-3c2b1a9f5e22',
+ firstName: 'Anna',
+ lastName: 'Schmidt',
+ fullName: 'Anna Schmidt',
+ },
+ },
+ shipTo: {
+ id: 'c2d3e4f5-6071-4b82-9c03-1d2e3f4a5b61',
+ address: 'Science Library\n25 Laboratory Drive\n01234 Booktown',
+ },
+ billTo: {
+ id: 'a0b1c2d3-4e5f-4061-9a82-1b2c3d4e5f60',
+ address: 'University Library - Acquisitions\n456 Campus Road\n01234 Booktown',
+ },
+};
+
+const personalNameType = {
+ id: '2b94c631-fca9-4892-a730-03ee529ffe27',
+ name: 'Personal name',
+};
+
+const corporateNameType = {
+ id: '2e48e713-17f3-4c13-a9f8-23845bb210aa',
+ name: 'Corporate name',
+};
+
+const isbnType = {
+ id: '8261054f-be78-422d-bd51-4ed9f33c3422',
+ name: 'ISBN',
+};
+
+const doiType = {
+ id: 'a4a739e2-3b4f-4a52-9e8d-1c6b0f7a2d34',
+ name: 'DOI',
+};
+
+const oclcType = {
+ id: '439bfbae-75bc-4f74-9fc7-b2a2d47ce3ef',
+ name: 'OCLC',
+};
+
+const issnType = {
+ id: '913300b2-03ed-469a-8179-c1092c991227',
+ name: 'ISSN',
+};
+
+const lineA1Fields = {
+ poLineNumber: '10037-1',
+ // Titles carry the full statement of responsibility and run long.
+ titleOrPackage: 'Introduction to library science : theory and practice / edited by Jane Roberts ; with contributions by the IFLA working group',
+ publisher: 'De Gruyter Saur',
+ publicationDate: '2024',
+ edition: '3rd ed.',
+ rush: false,
+ contributors: [
+ { contributor: 'Roberts, Jane', contributorNameType: personalNameType },
+ { contributor: 'International Federation of Library Associations', contributorNameType: corporateNameType },
+ { contributor: 'Schmidt, Anna', contributorNameType: personalNameType },
+ ],
+ details: {
+ productIds: [
+ // Qualifiers come from the catalogue record and often carry list
+ // prices; product IDs without one have no `qualifier` key at all.
+ { productId: '978-3-11-069137-8', qualifier: 'paperback : EUR 45.00 (DE), EUR 46.30 (AT)', productIdType: isbnType },
+ { productId: '10.1515/9783110692009', productIdType: doiType },
+ ],
+ },
+ cost: {
+ listUnitPrice: '45.00',
+ listUnitPriceElectronic: '0.00',
+ quantityPhysical: 2,
+ quantityElectronic: 0,
+ poLineEstimatedPrice: '90.00',
+ currency: 'EUR',
+ },
+ fundDistribution: [
+ { code: 'LIB-GEN' },
+ { code: 'HUM-2024' },
+ ],
+ vendorDetail: {
+ instructions: 'Please confirm the delivery date.\nDeliver to the acquisitions desk.',
+ },
+};
+
+const lineA2Fields = {
+ poLineNumber: '10037-2',
+ titleOrPackage: 'Advanced cataloging techniques',
+ publisher: 'Facet Publishing',
+ // Free text, brackets mark an inferred date.
+ publicationDate: '[2025]',
+ edition: '1st ed.',
+ rush: true,
+ contributors: [
+ { contributor: 'Becker, Laura', contributorNameType: personalNameType },
+ ],
+ details: {
+ productIds: [
+ { productId: '978-3-540-77974-2', qualifier: 'hardcover', productIdType: isbnType },
+ ],
+ },
+ cost: {
+ listUnitPrice: '59.95',
+ listUnitPriceElectronic: '0.00',
+ quantityPhysical: 1,
+ quantityElectronic: 0,
+ poLineEstimatedPrice: '59.95',
+ currency: 'EUR',
+ },
+ fundDistribution: [
+ { code: 'LIB-GEN' },
+ ],
+ vendorDetail: {
+ instructions: '',
+ },
+};
+
+const lineB1Fields = {
+ poLineNumber: '10038-1',
+ titleOrPackage: 'Digital Preservation Handbook',
+ publisher: 'Routledge',
+ publicationDate: '2023',
+ edition: '2nd ed.',
+ rush: false,
+ contributors: [
+ { contributor: 'Owens, Trevor', contributorNameType: personalNameType },
+ { contributor: 'Lee, Christopher', contributorNameType: personalNameType },
+ ],
+ details: {
+ productIds: [
+ { productId: '978-1-138-23456-7', qualifier: 'print', productIdType: isbnType },
+ { productId: '2049-3630', productIdType: issnType },
+ { productId: '1256789012', qualifier: 'online', productIdType: oclcType },
+ ],
+ },
+ cost: {
+ listUnitPrice: '72.50',
+ listUnitPriceElectronic: '65.00',
+ quantityPhysical: 1,
+ quantityElectronic: 1,
+ poLineEstimatedPrice: '137.50',
+ currency: 'EUR',
+ },
+ fundDistribution: [
+ { code: 'PRES-2024' },
+ ],
+ vendorDetail: {
+ instructions: 'Standing order - renew annually.',
+ },
+};
+
+const lineB2Fields = {
+ poLineNumber: '10038-2',
+ titleOrPackage: 'Open Access Publishing',
+ publisher: 'Cambridge University Press',
+ publicationDate: '2025',
+ edition: '1st ed.',
+ rush: false,
+ contributors: [
+ { contributor: 'Suber, Peter', contributorNameType: personalNameType },
+ ],
+ details: {
+ productIds: [
+ { productId: '978-1-108-12346-3', qualifier: 'ebook', productIdType: isbnType },
+ ],
+ },
+ cost: {
+ listUnitPrice: '0.00',
+ listUnitPriceElectronic: '129.00',
+ quantityPhysical: 0,
+ quantityElectronic: 1,
+ poLineEstimatedPrice: '129.00',
+ currency: 'EUR',
+ },
+ fundDistribution: [
+ { code: 'OA-FUND' },
+ ],
+ vendorDetail: {
+ instructions: '',
+ },
+};
+
+const orderA = {
+ order: orderAFields,
+ orderLines: [
+ { orderLine: lineA1Fields },
+ { orderLine: lineA2Fields },
+ ],
+};
+
+const orderB = {
+ order: orderBFields,
+ orderLines: [
+ { orderLine: lineB1Fields },
+ { orderLine: lineB2Fields },
+ ],
+};
+
+export const SAMPLE_PREVIEW_CONTEXT = {
+ createdAt: '2026-03-30T16:22:13.284Z',
+ organization,
+ orders: [orderA, orderB],
+};
diff --git a/src/settings/EmailTemplates/validate.js b/src/settings/EmailTemplates/validate.js
new file mode 100644
index 000000000..ecd4e7a7d
--- /dev/null
+++ b/src/settings/EmailTemplates/validate.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const isEmpty = (value) => !value || (typeof value === 'string' && value.trim() === '');
+
+// The editor stores HTML, so an "empty" body may still contain tags and
+// non-breaking spaces.
+const isEmptyEditor = (value) => isEmpty(
+ (value ?? '')
+ .replaceAll(/<[^<>]*>/g, '')
+ .replaceAll(' ', ' '),
+);
+
+const validate = (values) => {
+ const errors = {};
+ const enErrors = {};
+
+ if (isEmpty(values.name)) {
+ errors.name = ;
+ }
+
+ if (isEmpty(values.localizedTemplates?.en?.header)) {
+ enErrors.header = ;
+ }
+
+ if (isEmptyEditor(values.localizedTemplates?.en?.body)) {
+ enErrors.body = ;
+ }
+
+ if (Object.keys(enErrors).length > 0) {
+ errors.localizedTemplates = { en: enErrors };
+ }
+
+ return errors;
+};
+
+export default validate;
diff --git a/src/settings/EmailTemplates/validate.test.js b/src/settings/EmailTemplates/validate.test.js
new file mode 100644
index 000000000..e1e274c08
--- /dev/null
+++ b/src/settings/EmailTemplates/validate.test.js
@@ -0,0 +1,69 @@
+import validate from './validate';
+
+const validValues = {
+ name: 'Order',
+ localizedTemplates: {
+ en: {
+ header: 'Order {{order.poNumber}}',
+ body: 'Dear vendor
',
+ },
+ },
+};
+
+describe('validate', () => {
+ it('should accept a complete template', () => {
+ expect(validate(validValues)).toEqual({});
+ });
+
+ it('should require the name', () => {
+ const errors = validate({ ...validValues, name: ' ' });
+
+ expect(errors.name).toBeDefined();
+ expect(errors.localizedTemplates).toBeUndefined();
+ });
+
+ it('should require the subject', () => {
+ const errors = validate({
+ ...validValues,
+ localizedTemplates: { en: { ...validValues.localizedTemplates.en, header: '' } },
+ });
+
+ expect(errors.localizedTemplates.en.header).toBeDefined();
+ expect(errors.localizedTemplates.en.body).toBeUndefined();
+ });
+
+ it('should treat a body made of empty tags as missing', () => {
+ const errors = validate({
+ ...validValues,
+ localizedTemplates: { en: { ...validValues.localizedTemplates.en, body: '
' } },
+ });
+
+ expect(errors.localizedTemplates.en.body).toBeDefined();
+ });
+
+ it('should treat a body made of non-breaking spaces as missing', () => {
+ const errors = validate({
+ ...validValues,
+ localizedTemplates: { en: { ...validValues.localizedTemplates.en, body: '
' } },
+ });
+
+ expect(errors.localizedTemplates.en.body).toBeDefined();
+ });
+
+ it('should cope with a body of null', () => {
+ const errors = validate({
+ ...validValues,
+ localizedTemplates: { en: { ...validValues.localizedTemplates.en, body: null } },
+ });
+
+ expect(errors.localizedTemplates.en.body).toBeDefined();
+ });
+
+ it('should report every missing field of an empty form', () => {
+ const errors = validate({});
+
+ expect(errors.name).toBeDefined();
+ expect(errors.localizedTemplates.en.header).toBeDefined();
+ expect(errors.localizedTemplates.en.body).toBeDefined();
+ });
+});
diff --git a/src/settings/sections.js b/src/settings/sections.js
index d51e0e898..3f65ad972 100644
--- a/src/settings/sections.js
+++ b/src/settings/sections.js
@@ -6,6 +6,7 @@ import ClosingReasons from './ClosingReasons';
import { SECTION_KEYS } from './constants';
import { CreateInventory } from './CreateInventory';
import CustomFieldsSettings from './CustomFieldsSettings';
+import { EmailTemplates } from './EmailTemplates';
import { SETTINGS_SECTION_KEY_FIELD_NAME } from './hooks';
import InstanceMatching from './InstanceMatching';
import InstanceStatus from './InstanceStatus';
@@ -72,6 +73,12 @@ export const SECTIONS = [
route: 'order-template-categories',
perm: 'ui-orders.settings.view',
},
+ {
+ component: EmailTemplates,
+ label: ,
+ route: 'email-templates',
+ perm: 'ui-orders.settings.email-templates.view',
+ },
{
component: POLinesLimit,
label: ,
diff --git a/translations/ui-orders/en.json b/translations/ui-orders/en.json
index 340ecd8c5..529092f6b 100644
--- a/translations/ui-orders/en.json
+++ b/translations/ui-orders/en.json
@@ -767,6 +767,42 @@
"settings.createInventory.recordType.none": "None",
"settings.customFields.purchaseOrders.label": "Custom fields - purchase orders",
"settings.customFields.purchaseOrderLines.label": "Custom fields - purchase order lines",
+ "settings.emailTemplates.label": "Order email templates",
+ "settings.emailTemplates.entryLabel": "Order email template",
+ "settings.emailTemplates.new": "New email template",
+ "settings.emailTemplates.generalInformation": "General information",
+ "settings.emailTemplates.name": "Template name",
+ "settings.emailTemplates.description": "Description",
+ "settings.emailTemplates.active": "Active",
+ "settings.emailTemplates.active.yes": "Yes",
+ "settings.emailTemplates.active.no": "No",
+ "settings.emailTemplates.templateContent": "Template content",
+ "settings.emailTemplates.subject": "Email subject",
+ "settings.emailTemplates.body": "Email body",
+ "settings.emailTemplates.editAsHtml": "Edit as HTML",
+ "settings.emailTemplates.preview": "Preview",
+ "settings.emailTemplates.previewHeader": "Preview of template: {name}",
+ "settings.emailTemplates.tokens.general": "General",
+ "settings.emailTemplates.tokens.organization": "Organization",
+ "settings.emailTemplates.tokens.order": "Order",
+ "settings.emailTemplates.tokens.multipleOrders": "Multiple orders",
+ "settings.emailTemplates.tokens.orderLines": "Order lines",
+ "settings.emailTemplates.tokens.multipleOrderLines": "Multiple order lines",
+ "settings.emailTemplates.tokens.contributors": "Contributors",
+ "settings.emailTemplates.tokens.multipleContributors": "Multiple contributors",
+ "settings.emailTemplates.tokens.productIds": "Product IDs",
+ "settings.emailTemplates.tokens.multipleProductIds": "Multiple product IDs",
+ "settings.emailTemplates.tokens.funds": "Funds",
+ "settings.emailTemplates.tokens.multipleFunds": "Multiple funds",
+ "settings.emailTemplates.tokens.help.title": "Tips",
+ "settings.emailTemplates.tokens.help.handlebars": "This editor uses Handlebars. The {documentationLink} lists all tokens and an example template.",
+ "settings.emailTemplates.tokens.help.documentationLink": "documentation",
+ "settings.emailTemplates.tokens.help.loops": "Use the \"Multiple …\" options to repeat fields. Nested loops (order lines, contributors, product IDs, funds) must sit inside their parent loop.",
+ "settings.emailTemplates.tokens.help.optional": "To skip empty fields, wrap the line in section tags: {pattern}.",
+ "settings.emailTemplates.tokens.help.customFields": "Custom fields are library-specific and depend on the field type: single-value fields use {single}, multi-value fields loop over {multi}.",
+ "settings.emailTemplates.validation.nameRequired": "Template name is required",
+ "settings.emailTemplates.validation.subjectRequired": "Email subject is required",
+ "settings.emailTemplates.validation.bodyRequired": "Email body is required",
"settings.general.label": "General",
"settings.index.paneTitle": "Orders",
"settings.inventoryInteraction.label": "Inventory interaction",