From 98351a207ab6e7ee5a5682b77143633880cc71e5 Mon Sep 17 00:00:00 2001 From: schefter Date: Thu, 8 Oct 2026 14:57:37 +0200 Subject: [PATCH 1/7] UIOR-1492 Add order email template settings Add Settings > Orders > Order email templates: list, detail view and form for mod-template-engine templates with scope "orders", a token picker for the Handlebars data model of the EDIFACT email export (MODEXPW-635) and a backend-rendered preview via POST /template-request/preview (MODTEMPENG-135). Refs UIOR-1492, UIOR-1493, UIOR-1495. --- CHANGELOG.md | 1 + package.json | 53 +++- .../EmailTemplates/EmailTemplateDetail.js | 152 +++++++++++ .../EmailTemplates/EmailTemplateForm.css | 3 + .../EmailTemplates/EmailTemplateForm.js | 226 ++++++++++++++++ src/settings/EmailTemplates/EmailTemplates.js | 100 +++++++ .../EmailTemplates/TokensList/TokensList.js | 159 +++++++++++ .../EmailTemplates/TokensList/index.js | 1 + src/settings/EmailTemplates/constants.js | 164 +++++++++++ src/settings/EmailTemplates/index.js | 3 + .../EmailTemplates/samplePreviewContext.js | 254 ++++++++++++++++++ src/settings/EmailTemplates/validate.js | 37 +++ src/settings/sections.js | 7 + translations/ui-orders/en.json | 36 +++ 14 files changed, 1192 insertions(+), 4 deletions(-) create mode 100644 src/settings/EmailTemplates/EmailTemplateDetail.js create mode 100644 src/settings/EmailTemplates/EmailTemplateForm.css create mode 100644 src/settings/EmailTemplates/EmailTemplateForm.js create mode 100644 src/settings/EmailTemplates/EmailTemplates.js create mode 100644 src/settings/EmailTemplates/TokensList/TokensList.js create mode 100644 src/settings/EmailTemplates/TokensList/index.js create mode 100644 src/settings/EmailTemplates/constants.js create mode 100644 src/settings/EmailTemplates/index.js create mode 100644 src/settings/EmailTemplates/samplePreviewContext.js create mode 100644 src/settings/EmailTemplates/validate.js 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/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/EmailTemplates.js b/src/settings/EmailTemplates/EmailTemplates.js new file mode 100644 index 000000000..812ec1dfe --- /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`. + */ +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/TokensList/TokensList.js b/src/settings/EmailTemplates/TokensList/TokensList.js new file mode 100644 index 000000000..21a3f7ab7 --- /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 DATA_SCHEMA_URL = 'https://github.com/folio-org/mod-data-export-worker'; + +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/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..66e7030b5 --- /dev/null +++ b/src/settings/EmailTemplates/constants.js @@ -0,0 +1,164 @@ +/** + * 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'; + +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'; + +export const ORDER_EMAIL_TOKENS = { + [TOKEN_SECTIONS.GENERAL]: [ + { + token: 'createdAt', + previewValue: '2026-03-30T16:22:13.284Z', + }, + ], + [TOKEN_SECTIONS.ORGANIZATION]: [ + { + token: 'organization.name', + previewValue: 'Schweitzer Fachinformationen', + }, + { + token: 'organization.primaryAddress.addressLine1', + previewValue: 'Hagenauer Straße 47', + }, + { + token: 'organization.primaryAddress.city', + previewValue: 'Wiesbaden', + }, + { + token: 'organization.primaryAddress.zipCode', + previewValue: '65203', + }, + { + token: 'organization.primaryAddress.country', + previewValue: 'DEU', + }, + ], + [TOKEN_SECTIONS.ORDER]: [ + { + token: 'order.poNumber', + previewValue: '10037', + }, + { + token: 'order.orderType', + previewValue: 'One-Time', + }, + { + token: 'order.metadata.createdByUser.fullName', + previewValue: 'Max Mustermann', + }, + { + // Address lines are separated with \n, see samplePreviewContext.js. + token: 'order.shipTo.address', + previewValue: 'Branch Library of Humanities\n10 Philosopher Lane\n01234 Booktown', + }, + { + token: 'order.billTo.address', + previewValue: 'University Library - Acquisitions\n456 Campus Road\n01234 Booktown', + }, + ], + [TOKEN_SECTIONS.ORDER_LINES]: [ + { + token: 'orderLine.poLineNumber', + previewValue: '10037-1', + }, + { + token: 'orderLine.titleOrPackage', + previewValue: 'Introduction to Library Science', + }, + { + token: 'orderLine.publisher', + previewValue: 'De Gruyter Saur', + }, + { + token: 'orderLine.publicationDate', + previewValue: '2024', + }, + { + token: 'orderLine.edition', + previewValue: '3rd ed.', + }, + { + token: 'orderLine.rush', + previewValue: 'false', + }, + { + token: 'orderLine.cost.listUnitPrice', + previewValue: '45.00', + }, + { + token: 'orderLine.cost.listUnitPriceElectronic', + previewValue: '0.00', + }, + { + token: 'orderLine.cost.quantityPhysical', + previewValue: '2', + }, + { + token: 'orderLine.cost.quantityElectronic', + previewValue: '0', + }, + { + token: 'orderLine.cost.poLineEstimatedPrice', + previewValue: '90.00', + }, + { + token: 'orderLine.cost.currency', + previewValue: 'EUR', + }, + { + token: 'orderLine.vendorDetail.instructions', + previewValue: 'Please confirm the delivery date.', + }, + ], + [TOKEN_SECTIONS.CONTRIBUTORS]: [ + { + token: 'contributor', + previewValue: 'Mustermann, Max', + }, + { + token: 'contributorNameType.name', + previewValue: 'Personal name', + }, + ], + [TOKEN_SECTIONS.PRODUCT_IDS]: [ + { + token: 'productId', + previewValue: '978-3-11-069137-8', + }, + { + token: 'qualifier', + previewValue: 'paperback', + }, + { + token: 'productIdType.name', + previewValue: 'ISBN', + }, + ], + [TOKEN_SECTIONS.FUNDS]: [ + { + token: 'code', + previewValue: 'LIB-GEN', + }, + ], +}; 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..ba5f148ab --- /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: 'Schweitzer Fachinformationen', + primaryAddress: { + addressLine1: 'Hagenauer Straße 47', + city: 'Wiesbaden', + zipCode: '65203', + // ISO 3166-1 alpha-3 code, not a display name. + country: 'DEU', + }, +}; + +const orderAFields = { + poNumber: '10037', + orderType: 'One-Time', + metadata: { + createdByUser: { + id: '6e3f5d2a-1c4b-4e8a-9f7d-2b1a0c8e4d11', + firstName: 'Max', + lastName: 'Mustermann', + fullName: 'Max Mustermann', + }, + }, + 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: 'Mustermann, Max', 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..08175c2cb --- /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 ?? '') + .replace(/<\/?[^>]+(>|$)/g, '') + .replace(/ /g, ' '), +); + +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/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..c70776aa7 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 with the order data schema ({schemaLink}).", + "settings.emailTemplates.tokens.help.schemaLink": "README", + "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", From 7cd9bebbfe380d81d85b95af4ce83078c0da3d2a Mon Sep 17 00:00:00 2001 From: schefter Date: Thu, 8 Oct 2026 16:03:31 +0200 Subject: [PATCH 2/7] UIOR-1492 Add tests for order email templates Cover the settings page, the detail view with its preview, the form, the token picker and the validation. Refs UIOR-1492, UIOR-1493, UIOR-1495. --- .../EmailTemplateDetail.test.js | 119 +++++++++++ .../EmailTemplates/EmailTemplateForm.test.js | 191 ++++++++++++++++++ src/settings/EmailTemplates/EmailTemplates.js | 2 +- .../EmailTemplates/EmailTemplates.test.js | 120 +++++++++++ .../TokensList/TokensList.test.js | 82 ++++++++ src/settings/EmailTemplates/validate.test.js | 69 +++++++ 6 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 src/settings/EmailTemplates/EmailTemplateDetail.test.js create mode 100644 src/settings/EmailTemplates/EmailTemplateForm.test.js create mode 100644 src/settings/EmailTemplates/EmailTemplates.test.js create mode 100644 src/settings/EmailTemplates/TokensList/TokensList.test.js create mode 100644 src/settings/EmailTemplates/validate.test.js 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.test.js b/src/settings/EmailTemplates/EmailTemplateForm.test.js new file mode 100644 index 000000000..c8c71d1d9 --- /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 }) => ( + <> + +