From 4cac474707ed494b417a62bebb0f67c936547133 Mon Sep 17 00:00:00 2001 From: Jason Skomorowski Date: Tue, 4 Aug 2026 17:12:27 -0400 Subject: [PATCH 1/3] Template settings ILLDEV-457 --- ui-rs/src/settings/index.js | 6 + .../scheduledActions/ScheduledActionForm.js | 30 +- .../ScheduledActionForm.test.js | 105 ++++- .../actions/EmailPullslipsParams.js | 109 +++-- .../src/settings/templates/CreateTemplate.js | 52 +++ ui-rs/src/settings/templates/EditTemplate.js | 51 ++ ui-rs/src/settings/templates/TemplateForm.css | 11 + ui-rs/src/settings/templates/TemplateForm.js | 441 ++++++++++++++++++ .../settings/templates/TemplateForm.test.js | 358 ++++++++++++++ ui-rs/src/settings/templates/Templates.js | 96 ++++ .../src/settings/templates/Templates.test.js | 122 +++++ ui-rs/src/settings/templates/ViewTemplate.css | 8 + ui-rs/src/settings/templates/ViewTemplate.js | 121 +++++ ui-rs/src/settings/templates/index.js | 1 + ui-rs/src/settings/templates/mapping.js | 74 +++ ui-rs/src/settings/templates/mapping.test.js | 153 ++++++ ui-rs/src/test/editorMock.js | 35 ++ ui-rs/src/test/okapiKyMock.js | 2 + ui-rs/translations/ui-rs/en.json | 80 ++-- 19 files changed, 1752 insertions(+), 103 deletions(-) create mode 100644 ui-rs/src/settings/templates/CreateTemplate.js create mode 100644 ui-rs/src/settings/templates/EditTemplate.js create mode 100644 ui-rs/src/settings/templates/TemplateForm.css create mode 100644 ui-rs/src/settings/templates/TemplateForm.js create mode 100644 ui-rs/src/settings/templates/TemplateForm.test.js create mode 100644 ui-rs/src/settings/templates/Templates.js create mode 100644 ui-rs/src/settings/templates/Templates.test.js create mode 100644 ui-rs/src/settings/templates/ViewTemplate.css create mode 100644 ui-rs/src/settings/templates/ViewTemplate.js create mode 100644 ui-rs/src/settings/templates/index.js create mode 100644 ui-rs/src/settings/templates/mapping.js create mode 100644 ui-rs/src/settings/templates/mapping.test.js create mode 100644 ui-rs/src/test/editorMock.js diff --git a/ui-rs/src/settings/index.js b/ui-rs/src/settings/index.js index dd36dde..4a06f4b 100644 --- a/ui-rs/src/settings/index.js +++ b/ui-rs/src/settings/index.js @@ -3,6 +3,7 @@ import { FormattedMessage } from 'react-intl'; import { Settings } from '@folio/stripes/smart-components'; import ScheduledActions from './scheduledActions'; +import Templates from './templates'; const sections = [ { @@ -13,6 +14,11 @@ const sections = [ label: , component: ScheduledActions, }, + { + route: 'templates', + label: , + component: Templates, + }, ], }, ]; diff --git a/ui-rs/src/settings/scheduledActions/ScheduledActionForm.js b/ui-rs/src/settings/scheduledActions/ScheduledActionForm.js index e0ce60b..feabf63 100644 --- a/ui-rs/src/settings/scheduledActions/ScheduledActionForm.js +++ b/ui-rs/src/settings/scheduledActions/ScheduledActionForm.js @@ -22,21 +22,23 @@ import { recordToFormValues } from './model'; import actionRegistry from './actions/actionRegistry'; import css from './ScheduledActionForm.css'; -// Templates seed the form but are not themselves submitted. -const TemplatePicker = ({ templates }) => { +// Built-in batch actions that seed the form but are not themselves submitted. Called +// presets rather than templates: the email params block now picks a message template, +// which is a different thing entirely. +const PresetPicker = ({ presets }) => { const intl = useIntl(); const form = useForm(); const [selected, setSelected] = useState(''); - if (!templates.length) return null; + if (!presets.length) return null; - const templateLabel = (t) => intl.formatMessage({ - id: `ui-rs.settings.scheduledActions.templates.${t.titleKey}`, - defaultMessage: t.title, + const presetLabel = (p) => intl.formatMessage({ + id: `ui-rs.settings.scheduledActions.preset.${p.titleKey}`, + defaultMessage: p.title, }); const options = [ - { value: '', label: intl.formatMessage({ id: 'ui-rs.settings.scheduledActions.template.placeholder' }) }, - ...templates.map((t, i) => ({ value: String(i), label: templateLabel(t) })), + { value: '', label: intl.formatMessage({ id: 'ui-rs.settings.scheduledActions.preset.placeholder' }) }, + ...presets.map((p, i) => ({ value: String(i), label: presetLabel(p) })), ]; const onChange = (e) => { @@ -44,7 +46,7 @@ const TemplatePicker = ({ templates }) => { setSelected(idx); if (idx === '') return; // Replacing actionParams prevents parameters leaking between action types. - const values = recordToFormValues(templates[Number(idx)]); + const values = recordToFormValues(presets[Number(idx)]); form.batch(() => { Object.entries(values).forEach(([field, value]) => form.change(field, value)); }); @@ -54,11 +56,11 @@ const TemplatePicker = ({ templates }) => { } + value={value} + onChange={onChange} + onBlur={input.onBlur} + onFocus={input.onFocus} + error={meta.touched ? meta.error : undefined} + /> + ); +}; + const EmailPullslipsParams = () => { const intl = useIntl(); const form = useForm(); @@ -30,7 +95,6 @@ const EmailPullslipsParams = () => { const validateRecipientCount = (value) => ((value?.length ?? 0) === 0 ? msg('recipients') : undefined); const validateEmail = (value) => (value && EMAIL.test(value.trim()) ? undefined : msg('recipientsInvalid')); - const validateRequired = (id) => (value) => (value && value.trim() ? undefined : msg(id)); return ( @@ -58,30 +122,7 @@ const EmailPullslipsParams = () => { /> - } - /> - } - /> - } - /> + ( value={(Array.isArray(actionParams?.to) ? actionParams.to : []).join(', ')} /> } - value={actionParams?.subject} - /> - } - value={actionParams?.body} - /> - } - value={} + label={} + value={actionParams?.templateLabel} /> } diff --git a/ui-rs/src/settings/templates/CreateTemplate.js b/ui-rs/src/settings/templates/CreateTemplate.js new file mode 100644 index 0000000..73466bf --- /dev/null +++ b/ui-rs/src/settings/templates/CreateTemplate.js @@ -0,0 +1,52 @@ +import React, { useContext } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useMutation, useQueryClient } from 'react-query'; +import { CalloutContext } from '@folio/stripes/core'; +import { useOkapiKy, useCloseDirect } from '@projectreshare/stripes-reshare'; + +import TemplateForm from './TemplateForm'; +import { buildCreateTemplateBody } from './mapping'; + +const INITIAL_VALUES = { + title: '', + // Unset, not defaulted: purpose cannot be changed after creation and decides + // whether the template is ever found, so it is the user's to state. + purpose: '', + contentType: 'html', + subject: '', + body: '', + labels: [''], + audience: '', +}; + +const CreateTemplate = () => { + const okapiKy = useOkapiKy(); + const queryClient = useQueryClient(); + const callout = useContext(CalloutContext); + const close = useCloseDirect(); + + const creator = useMutation({ + mutationFn: (values) => okapiKy.post('broker/templates', { json: buildCreateTemplateBody(values) }), + onSuccess: async () => { + await queryClient.invalidateQueries('broker/templates'); + close(); + }, + onError: () => callout?.sendCallout({ + type: 'error', + message: , + }), + }); + + return ( + } + submitLabelId="ui-rs.create" + onClose={close} + initialValues={INITIAL_VALUES} + submitting={creator.isLoading} + onSubmit={(values) => creator.mutate(values)} + /> + ); +}; + +export default CreateTemplate; diff --git a/ui-rs/src/settings/templates/EditTemplate.js b/ui-rs/src/settings/templates/EditTemplate.js new file mode 100644 index 0000000..86f6408 --- /dev/null +++ b/ui-rs/src/settings/templates/EditTemplate.js @@ -0,0 +1,51 @@ +import React, { useContext, useMemo } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useMutation, useQueryClient } from 'react-query'; +import { CalloutContext } from '@folio/stripes/core'; +import { useOkapiKy, useOkapiQuery, useCloseDirect } from '@projectreshare/stripes-reshare'; + +import TemplateForm from './TemplateForm'; +import { buildUpdateTemplateBody, recordToFormValues } from './mapping'; + +const EditTemplate = ({ match }) => { + const { id } = match.params; + const okapiKy = useOkapiKy(); + const queryClient = useQueryClient(); + const callout = useContext(CalloutContext); + const close = useCloseDirect(); + + const { data, isSuccess } = useOkapiQuery(`broker/templates/${id}`); + + // Fresh objects here re-initialize the form and discard whatever is being typed, + // since react-final-form compares initialValues shallowly and labels is an array. + const initialValues = useMemo(() => recordToFormValues(data), [data]); + + const updater = useMutation({ + mutationFn: (values) => okapiKy.put(`broker/templates/${id}`, { json: buildUpdateTemplateBody(values) }), + onSuccess: async () => { + await queryClient.invalidateQueries('broker/templates'); + await queryClient.invalidateQueries(`broker/templates/${id}`); + close(); + }, + onError: () => callout?.sendCallout({ + type: 'error', + message: , + }), + }); + + if (!isSuccess) return null; + + return ( + } + submitLabelId="ui-rs.save" + editing + onClose={close} + initialValues={initialValues} + submitting={updater.isLoading} + onSubmit={(values) => updater.mutate(values)} + /> + ); +}; + +export default EditTemplate; diff --git a/ui-rs/src/settings/templates/TemplateForm.css b/ui-rs/src/settings/templates/TemplateForm.css new file mode 100644 index 0000000..a631b5a --- /dev/null +++ b/ui-rs/src/settings/templates/TemplateForm.css @@ -0,0 +1,11 @@ +/* Stripes fields have no help-text slot. Align this custom caption with their + left edge (--input-vertical-padding) and bottom rhythm + (--control-margin-bottom); marginBottom0 on the field lets the caption own the + gap below it. */ +.help { + padding: 0 var(--input-vertical-padding); + color: var(--color-text-p2); + font-size: 0.8125rem; + margin-top: 0.25rem; + margin-bottom: var(--control-margin-bottom); +} diff --git a/ui-rs/src/settings/templates/TemplateForm.js b/ui-rs/src/settings/templates/TemplateForm.js new file mode 100644 index 0000000..92779e0 --- /dev/null +++ b/ui-rs/src/settings/templates/TemplateForm.js @@ -0,0 +1,441 @@ +import React, { useRef, useState } from 'react'; +import { Form, Field, useForm } from 'react-final-form'; +import { FieldArray } from 'react-final-form-arrays'; +import arrayMutators from 'final-form-arrays'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + Button, + Checkbox, + Col, + ConfirmationModal, + Editor, + Label, + Pane, + PaneFooter, + RepeatableField, + Row, + Select, + TextArea, + TextField, +} from '@folio/stripes/components'; +import { useOkapiQuery } from '@projectreshare/stripes-reshare'; + +import { recordToFormValues, escapeHtml, suggestedLabels } from './mapping'; +import css from './TemplateForm.css'; + +// "Template" here is a stored message template. The scheduled-actions form uses the +// same word for its batch-action presets; unrelated, hence the separate key namespace. + +// Lowercase letters, hyphen-separated, as every label the broker ships is written. +// Hyphens have to join two words, so a bare or trailing one is rejected. +const LABEL_FORMAT = /^[a-z]+(-[a-z]+)*$/; + +const PURPOSES = ['email', 'pullslip']; +const CONTENT_TYPES = ['text', 'html']; +const AUDIENCES = ['', 'patron', 'staff']; + +// Built-in templates that seed the form but are not themselves submitted; the broker +// serves them and creates none. Unlike the scheduled-actions preset picker there is no +// titleKey to localize against: these carry a plain title, so it is shown as-is. +const PresetPicker = ({ presets, bodyIsMarkup }) => { + const intl = useIntl(); + const form = useForm(); + const [selected, setSelected] = useState(''); + if (!presets.length) return null; + + const options = [ + { value: '', label: intl.formatMessage({ id: 'ui-rs.settings.templates.preset.placeholder' }) }, + ...presets.map((preset, i) => ({ value: String(i), label: preset.title })), + ]; + + const onChange = (e) => { + const idx = e.target.value; + setSelected(idx); + if (idx === '') return; + const values = recordToFormValues(presets[Number(idx)]); + // Seeding replaces body and contentType together, so the tracked encoding has + // to follow or a later toggle would escape seeded markup. + bodyIsMarkup.current = values.contentType === 'html'; + form.batch(() => { + Object.entries(values).forEach(([field, value]) => form.change(field, value)); + }); + }; + + return ( + + + } + /> + ); +}; + +// The body, as either the WYSIWYG or its markup. Quill has no source view of its +// own, so the toggle is separate from contentType, which decides what is stored. +// Going back to Quill re-parses the markup and drops anything it has no format +// for (tables, style attributes, most pasted email HTML), hence the confirmation +// on that leg only. +// +// Quill re-parses on mount too, reported as an onChange with source `api`. Those +// are dropped rather than written to the form, so an unedited body saves back +// byte-identical -- and dropping one is how we know to warn. +const SourceToggle = ({ checked, onChange }) => ( + } + /> +); + +const BodyField = ({ isHtml, storedBody }) => { + const form = useForm(); + const [sourceMode, setSourceMode] = useState(false); + const [confirmWysiwyg, setConfirmWysiwyg] = useState(false); + const [quillWouldRewrite, setQuillWouldRewrite] = useState(false); + const label = ; + + if (!isHtml || sourceMode) { + return ( + <> + {isHtml && ( + setConfirmWysiwyg(true)} /> + )} + + } + message={} + confirmLabel={} + onConfirm={() => { setConfirmWysiwyg(false); setSourceMode(false); }} + onCancel={() => setConfirmWysiwyg(false)} + /> + + ); + } + + return ( + <> + setSourceMode(true)} /> + + {({ input, meta }) => ( + { + if (source === 'user') { + input.onChange(value); + return; + } + // Not a user edit: Quill re-parsing what we gave it. Dropping the value + // is the point -- an unedited body has to save back exactly as stored. + // Only the stored body earns a warning. Quill normalizes everything + // it is given -- bare text gains a

, for one -- so flagging every + // difference would fire on our own text-to-HTML conversion too. + const current = form.getState().values.body; + if (current && current === storedBody && value !== current) setQuillWouldRewrite(true); + }} + /> + )} + + {quillWouldRewrite && ( +

+ + {' '} + +
+ )} + + ); +}; + +const TemplateForm = ({ initialValues, onSubmit, onClose, title, submitLabelId, submitting, editing }) => { + const intl = useIntl(); + // Presets seed the form; stored templates contribute any locally used labels. + // Both are optional, so either request can fail without disabling free-text labels. + const { data: presets } = useOkapiQuery('broker/state_model/templates', { useErrorBoundary: false }); + const { data: templates } = useOkapiQuery('broker/templates', { + searchParams: { limit: 100 }, + useErrorBoundary: false, + }); + const labelSources = [...(presets ?? []), ...(templates?.items ?? [])]; + + const msg = (id) => intl.formatMessage({ id: `ui-rs.settings.templates.validate.${id}` }); + const opts = (field, values) => values.map(value => ({ + value, + label: intl.formatMessage({ id: `ui-rs.settings.templates.${field}.${value || 'both'}` }), + })); + + // Offered only while creating: an existing template's purpose can never be unset. + const purposeOptions = editing + ? opts('purpose', PURPOSES) + : [{ value: '', label: intl.formatMessage({ id: 'ui-rs.settings.templates.purpose.placeholder' }) }, ...opts('purpose', PURPOSES)]; + + const validate = (values) => { + const errors = {}; + if (!values.purpose) errors.purpose = msg('purpose'); + if (!values.title?.trim()) errors.title = msg('title'); + if (!values.body?.trim()) errors.body = msg('body'); + // An empty subject fails the broker's send outright. + if (values.purpose === 'email' && !values.subject?.trim()) errors.subject = msg('subject'); + return errors; + }; + + // Swapping patron for staff is an ordinary update; only clearing one is impossible, + // since PUT can set an audience but never restore the "matches both" null. So the + // choice is withheld rather than offered and then refused. + const audiences = editing && initialValues?.audience ? AUDIENCES.filter(Boolean) : AUDIENCES; + + // Whether the body currently holds markup, which is not the same question as + // which content type is selected: switching to text leaves the markup alone. + const bodyIsMarkup = useRef(initialValues?.contentType === 'html'); + + const validateLabelCount = (value) => ( + (value ?? []).some(label => (label ?? '').trim()) ? undefined : msg('labels') + ); + + // Labels are matched literally against what a state model or scheduled action asks + // for, so the form keeps them to the shape those references use. Blank rows are + // left to validateLabelCount, which owns "at least one". + const validateLabelFormat = (value) => { + const label = (value ?? '').trim(); + if (!label) return undefined; + return LABEL_FORMAT.test(label) ? undefined : msg('labelFormat'); + }; + + return ( +
+ {({ handleSubmit, values, pristine, invalid, form }) => { + const isHtml = values.contentType === 'html'; + const isPullslip = values.purpose === 'pullslip'; + const footer = ( + + + + } + renderEnd={ + + } + /> + ); + return ( + + + {!editing && } + + + } + /> + + + + {({ input }) => ( + } + value={input.value} + onBlur={input.onBlur} + onFocus={input.onFocus} + onChange={(e) => { + input.onChange(e); + // Escape only text. The reverse leg leaves markup alone, so a + // body that has been through HTML stays markup and escaping it + // again would show the user their own tags. + const toHtml = e.target.value === 'html'; + if (toHtml && !bodyIsMarkup.current) { + const body = form.getState().values.body ?? ''; + if (body) form.change('body', escapeHtml(body)); + } + bodyIsMarkup.current = bodyIsMarkup.current || toHtml; + }} + /> +
+ +
+ + )} +
+ +
+ + + + + + + + + + + + + )} + addLabel={} + onAdd={fields => fields.push('')} + hasMargin={false} + validate={validateLabelCount} + renderField={field => ( + + )} + /> + + + + + + +
+ ); + }} + + ); +}; + +export default TemplateForm; diff --git a/ui-rs/src/settings/templates/TemplateForm.test.js b/ui-rs/src/settings/templates/TemplateForm.test.js new file mode 100644 index 0000000..fecc090 --- /dev/null +++ b/ui-rs/src/settings/templates/TemplateForm.test.js @@ -0,0 +1,358 @@ +import React from 'react'; +import { act, fireEvent, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import { renderWithRs } from '../../test/renderWithRs'; +import { makeOkapiKyMock } from '../../test/okapiKyMock'; +import { lastEditor } from '../../test/editorMock'; +import TemplateForm from './TemplateForm'; + +const mockOkapi = makeOkapiKyMock(); + +jest.mock('@folio/stripes-components/lib/Icon', () => require('../../test/iconMock').default); +jest.mock('@folio/stripes-components/lib/TextArea', () => require('../../test/textAreaMock').default); +jest.mock('@folio/stripes-components/lib/Editor', () => require('../../test/editorMock').default); +jest.mock('@folio/stripes/core', () => require('../../test/stripesCore').makeStripesCoreMock(() => mockOkapi)); + +const PRESETS = [ + { + title: 'Received item notification', + purpose: 'email', + contentType: 'text', + subject: 'Your requested item is ready', + body: 'Your requested item has been received.', + labels: ['received-notification'], + audience: 'patron', + }, + { + title: 'Scheduled pullslips email template', + purpose: 'email', + contentType: 'text', + subject: 'Pull slips', + body: 'Matched {{fullCount}} requests.', + labels: ['pullslip-email'], + audience: 'staff', + }, +]; + +const EXISTING_TEMPLATES = { + about: { count: 1 }, + items: [{ + title: 'Local notification', + purpose: 'email', + labels: ['locally-invented'], + }], +}; + +const baseInitial = { + title: '', + purpose: 'email', + contentType: 'text', + subject: '', + body: '', + labels: [''], + audience: '', +}; + +const htmlValues = { ...baseInitial, title: 'T', subject: 'S', labels: ['l'], contentType: 'html', body: '

Hi

' }; + +const byId = (id) => document.getElementById(id); +const save = () => byId('clickable-save-template'); + +const fillRequired = () => { + fireEvent.change(byId('template-title'), { target: { value: 'Received' } }); + fireEvent.change(byId('template-subject'), { target: { value: 'Ready' } }); + fireEvent.change(byId('template-body'), { target: { value: 'Your item is ready' } }); + fireEvent.change(byId('template-label-labels[0]'), { target: { value: 'received-notification' } }); +}; + +const renderForm = (onSubmit, { initialValues = baseInitial, ...props } = {}) => renderWithRs( + {}} + title="Test" + submitLabelId="ui-rs.create" + {...props} + />, +); + +describe('TemplateForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOkapi.setResponses({ + 'broker/state_model/templates': PRESETS, + 'broker/templates': EXISTING_TEMPLATES, + }); + }); + + it('disables save until title, subject, body and a label are present', async () => { + renderForm(jest.fn()); + + expect(save()).toBeDisabled(); + fillRequired(); + await waitFor(() => expect(save()).not.toBeDisabled()); + }); + + it('submits the entered values', async () => { + const onSubmit = jest.fn(); + renderForm(onSubmit); + + fillRequired(); + await waitFor(() => expect(save()).not.toBeDisabled()); + fireEvent.click(save()); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ + title: 'Received', + purpose: 'email', + subject: 'Ready', + labels: ['received-notification'], + }); + }); + + it('requires no subject for a pull slip template, which never uses one', async () => { + renderForm(jest.fn()); + + fireEvent.change(byId('template-title'), { target: { value: 'Slip' } }); + fireEvent.change(byId('template-body'), { target: { value: 'Body' } }); + fireEvent.change(byId('template-label-labels[0]'), { target: { value: 'slip' } }); + fireEvent.change(byId('template-purpose'), { target: { value: 'pullslip' } }); + + await waitFor(() => expect(save()).not.toBeDisabled()); + expect(byId('template-subject')).toBeDisabled(); + }); + + // Only that the HTML branch mounts: with the editor stubbed, nothing here can say + // anything about rich-text behaviour, which is verified by hand for now. + it('renders a body field for an HTML template', async () => { + renderForm(jest.fn(), { + editing: true, + initialValues: { ...baseInitial, title: 'T', subject: 'S', body: '

Hi

', labels: ['l'], contentType: 'html' }, + }); + + await waitFor(() => expect(byId('template-body')).toBeInTheDocument()); + expect(byId('template-body').value).toBe('

Hi

'); + }); + + describe('switching content type', () => { + const setType = (value) => fireEvent.change(byId('template-contentType'), { target: { value } }); + + it('escapes plain text so it survives the move into the editor', () => { + renderForm(jest.fn()); + + fireEvent.change(byId('template-body'), { target: { value: 'Dear ,' } }); + setType('html'); + + expect(byId('template-body').value).toBe('Dear <name>,'); + }); + + it('leaves markup alone on the way back through text', () => { + renderForm(jest.fn(), { + editing: true, + initialValues: { ...baseInitial, title: 'T', subject: 'S', labels: ['l'], contentType: 'html', body: '

Hello

' }, + }); + + setType('text'); + expect(byId('template-body').value).toBe('

Hello

'); + // The body still holds markup, so returning to HTML must not escape it. + setType('html'); + expect(byId('template-body').value).toBe('

Hello

'); + }); + + it('does not escape the escapes when toggled repeatedly', () => { + renderForm(jest.fn()); + + fireEvent.change(byId('template-body'), { target: { value: 'R&D' } }); + setType('html'); + setType('text'); + setType('html'); + + expect(byId('template-body').value).toBe('R&D'); + }); + }); + + describe('editing HTML source', () => { + it('is not offered for a plain text template, which is already its own source', () => { + renderForm(jest.fn()); + + expect(byId('template-body-source')).toBeNull(); + }); + + it('shows the markup unescaped', () => { + renderForm(jest.fn(), { editing: true, initialValues: htmlValues }); + + fireEvent.click(byId('template-body-source')); + + expect(byId('template-body').value).toBe('

Hi

'); + expect(screen.queryByText('ui-rs.settings.templates.wysiwyg.heading')).toBeNull(); + }); + + it('confirms before returning to the editor, which cannot hold every markup', async () => { + renderForm(jest.fn(), { editing: true, initialValues: htmlValues }); + + fireEvent.click(byId('template-body-source')); + fireEvent.change(byId('template-body'), { target: { value: '
x
' } }); + fireEvent.click(byId('template-body-source')); + + expect(await screen.findByText('ui-rs.settings.templates.wysiwyg.heading')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'ui-rs.settings.templates.wysiwyg.confirm' })); + + // Confirming switches widget only: the body is Quill's problem now, not ours. + await waitFor(() => expect(byId('template-body').value).toBe('
x
')); + }); + + it('keeps the source open when the confirmation is declined', async () => { + renderForm(jest.fn(), { editing: true, initialValues: htmlValues }); + + fireEvent.click(byId('template-body-source')); + fireEvent.click(byId('template-body-source')); + fireEvent.click(await screen.findByRole('button', { name: 'stripes-components.cancel' })); + + await waitFor(() => expect(byId('template-body-source')).toBeChecked()); + }); + }); + + // When Quill re-parses a stored body it reports a change with source `api` rather + // than `user`. The stub lets these tests send that report by hand; they cover how + // the form reacts to it, not whether Quill would really rewrite this markup. + describe('changes the editor makes by itself', () => { + const renderHtml = async () => { + renderForm(jest.fn(), { editing: true, initialValues: htmlValues }); + await waitFor(() => expect(byId('template-body')).toBeInTheDocument()); + }; + + it('keeps the stored body, so an untouched template saves back unchanged', async () => { + await renderHtml(); + act(() => lastEditor.onChange('

Hi


', null, 'api')); + expect(byId('template-body').value).toBe('

Hi

'); + expect(save()).toBeDisabled(); + }); + + it('points at the source view once it has dropped one', async () => { + await renderHtml(); + act(() => lastEditor.onChange('

Hi


', null, 'api')); + expect(byId('template-body-rewrite')).toBeInTheDocument(); + }); + + it('stays quiet when the editor returns the body as it was', async () => { + await renderHtml(); + act(() => lastEditor.onChange('

Hi

', null, 'api')); + expect(byId('template-body-rewrite')).toBeNull(); + }); + + it('lets a typed edit through', async () => { + await renderHtml(); + act(() => lastEditor.onChange('

Edited

', null, 'user')); + expect(byId('template-body').value).toBe('

Edited

'); + await waitFor(() => expect(save()).not.toBeDisabled()); + }); + }); + + it('fixes the purpose of an existing template', () => { + renderForm(jest.fn(), { editing: true, initialValues: { ...baseInitial, title: 'T', body: 'B', subject: 'S', labels: ['l'] } }); + + expect(byId('template-purpose')).toBeDisabled(); + }); + + describe('audience', () => { + const optionValues = () => [...byId('template-audience').options].map(opt => opt.value); + + it('cannot be cleared once set, which the broker cannot restore to "both"', () => { + renderForm(jest.fn(), { + editing: true, + initialValues: { ...baseInitial, title: 'T', body: 'B', subject: 'S', labels: ['l'], audience: 'patron' }, + }); + + expect(optionValues()).toEqual(['patron', 'staff']); + }); + + it('can still be narrowed from "both", which needs no restoring', () => { + renderForm(jest.fn(), { + editing: true, + initialValues: { ...baseInitial, title: 'T', body: 'B', subject: 'S', labels: ['l'] }, + }); + + expect(optionValues()).toEqual(['', 'patron', 'staff']); + }); + }); + + describe('labels', () => { + it('offers built-in and existing labels before a preset is selected', async () => { + renderForm(jest.fn(), { initialValues: { ...baseInitial, purpose: '' } }); + + await waitFor(() => expect(byId('template-known-label')).toBeInTheDocument()); + expect(byId('template-preset').value).toBe(''); + expect(screen.getByRole('option', { name: 'received-notification' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'pullslip-email' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'locally-invented' })).toBeInTheDocument(); + }); + + it('fills the blank row when a known label is picked', async () => { + renderForm(jest.fn()); + + await waitFor(() => expect(byId('template-known-label')).toBeInTheDocument()); + fireEvent.change(byId('template-known-label'), { target: { value: 'pullslip-email' } }); + + expect(byId('template-label-labels[0]').value).toBe('pullslip-email'); + }); + + it('does not add a label twice', async () => { + renderForm(jest.fn()); + + await waitFor(() => expect(byId('template-known-label')).toBeInTheDocument()); + fireEvent.change(byId('template-known-label'), { target: { value: 'pullslip-email' } }); + fireEvent.change(byId('template-known-label'), { target: { value: 'pullslip-email' } }); + + expect(byId('template-label-labels[1]')).toBeNull(); + }); + + it('accepts a label the broker has never heard of', async () => { + const onSubmit = jest.fn(); + renderForm(onSubmit); + + fillRequired(); + fireEvent.change(byId('template-label-labels[0]'), { target: { value: 'locally-invented' } }); + await waitFor(() => expect(save()).not.toBeDisabled()); + fireEvent.click(save()); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0].labels).toEqual(['locally-invented']); + }); + }); + + describe('built-in presets', () => { + it('seeds every field from the selected preset', async () => { + renderForm(jest.fn()); + + await waitFor(() => expect(byId('template-preset')).toBeInTheDocument()); + fireEvent.change(byId('template-preset'), { target: { value: '0' } }); + + expect(byId('template-title').value).toBe('Received item notification'); + expect(byId('template-subject').value).toBe('Your requested item is ready'); + expect(byId('template-body').value).toBe('Your requested item has been received.'); + expect(byId('template-label-labels[0]').value).toBe('received-notification'); + expect(byId('template-audience').value).toBe('patron'); + }); + + it('is not offered when editing, where seeding would overwrite the record', async () => { + renderForm(jest.fn(), { editing: true, initialValues: { ...baseInitial, title: 'T', body: 'B', subject: 'S', labels: ['l'] } }); + + await waitFor(() => expect(byId('template-known-label')).toBeInTheDocument()); + expect(byId('template-preset')).toBeNull(); + }); + + it('leaves the form usable when the presets cannot be fetched', async () => { + // Silence the expected react-query error for the missing mock response. + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockOkapi.setResponses({}); + renderForm(jest.fn()); + + fillRequired(); + await waitFor(() => expect(save()).not.toBeDisabled()); + // Without presets or existing templates there are no suggestions; labels stay free text. + expect(byId('template-preset')).toBeNull(); + expect(byId('template-known-label')).toBeNull(); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/ui-rs/src/settings/templates/Templates.js b/ui-rs/src/settings/templates/Templates.js new file mode 100644 index 0000000..3e5cc8a --- /dev/null +++ b/ui-rs/src/settings/templates/Templates.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { Switch, Route } from 'react-router-dom'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { Button, MultiColumnList, Pane, PaneMenu } from '@folio/stripes/components'; +import { DirectLink, useOkapiQuery } from '@projectreshare/stripes-reshare'; + +import CreateTemplate from './CreateTemplate'; +import ViewTemplate from './ViewTemplate'; +import EditTemplate from './EditTemplate'; + +const TemplatesList = ({ match, history }) => { + const intl = useIntl(); + const { data, isSuccess } = useOkapiQuery('broker/templates', { + searchParams: { limit: 100 }, + }); + const items = data?.items ?? []; + + const formatter = { + purpose: r => intl.formatMessage({ id: `ui-rs.settings.templates.purpose.${r.purpose}`, defaultMessage: r.purpose }), + contentType: r => intl.formatMessage({ id: `ui-rs.settings.templates.contentType.${r.contentType}`, defaultMessage: r.contentType }), + labels: r => (r.labels ?? []).join(', '), + // An absent audience means the template serves both. + audience: r => intl.formatMessage({ id: `ui-rs.settings.templates.audience.${r.audience || 'both'}` }), + updatedAt: r => { + const stamp = r.updatedAt ?? r.createdAt; + return stamp ? intl.formatDate(stamp) : ''; + }, + }; + + return ( + } + lastMenu={ + + + + + + } + > + , + purpose: , + labels: , + audience: , + contentType: , + updatedAt: , + }} + formatter={formatter} + onRowClick={(_e, row) => history.push({ pathname: `${match.url}/${row.id}`, state: { direct: true } })} + isEmptyMessage={ + isSuccess + ? + : '' + } + /> + + ); +}; + +const Templates = ({ match }) => ( + + + + + + +); + +export default Templates; diff --git a/ui-rs/src/settings/templates/Templates.test.js b/ui-rs/src/settings/templates/Templates.test.js new file mode 100644 index 0000000..9d8a49a --- /dev/null +++ b/ui-rs/src/settings/templates/Templates.test.js @@ -0,0 +1,122 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import { fireEvent, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import { renderWithRs } from '../../test/renderWithRs'; +import { makeOkapiKyMock } from '../../test/okapiKyMock'; +import Templates from './Templates'; + +const mockOkapi = makeOkapiKyMock(); + +jest.mock('@folio/stripes-components/lib/Icon', () => require('../../test/iconMock').default); +jest.mock('@folio/stripes-components/lib/TextArea', () => require('../../test/textAreaMock').default); +jest.mock('@folio/stripes-components/lib/Editor', () => require('../../test/editorMock').default); +jest.mock('@folio/stripes/core', () => require('../../test/stripesCore').makeStripesCoreMock(() => mockOkapi)); + +const PATH = '/settings/rs/templates'; + +const TEMPLATE = { + id: 't1', + title: 'Received item notification', + purpose: 'email', + contentType: 'text', + subject: 'Your requested item is ready', + body: 'Your requested item has been received.', + labels: ['received-notification'], + audience: 'patron', + createdAt: '2026-05-01T00:00:00Z', +}; + +const DEFAULTS = []; + +const byId = (id) => document.getElementById(id); + +const renderList = () => renderWithRs( + , + { initialEntries: [PATH] }, +); + +describe('Templates', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOkapi.setResponses({ + 'broker/templates': { about: { count: 1 }, items: [TEMPLATE] }, + 'broker/templates/t1': TEMPLATE, + 'broker/state_model/templates': DEFAULTS, + }); + }); + + it('renders a row per template', async () => { + renderList(); + + await waitFor(() => expect(screen.getByText('Received item notification')).toBeInTheDocument()); + expect(mockOkapi.calledUrls()).toContain('broker/templates?limit=100'); + expect(screen.getByText('received-notification')).toBeInTheDocument(); + expect(screen.getByText('ui-rs.settings.templates.audience.patron')).toBeInTheDocument(); + }); + + it('creates a template from the entered values', async () => { + renderList(); + + fireEvent.click(await screen.findByRole('button', { name: 'ui-rs.settings.templates.new' })); + + await waitFor(() => expect(byId('template-title')).toBeInTheDocument()); + // Nothing is submittable until a purpose is stated; it cannot be changed later. + fireEvent.change(byId('template-purpose'), { target: { value: 'email' } }); + fireEvent.change(byId('template-title'), { target: { value: 'Cancelled' } }); + fireEvent.change(byId('template-subject'), { target: { value: 'Cancelled' } }); + fireEvent.change(byId('template-body'), { target: { value: 'Your request was cancelled' } }); + fireEvent.change(byId('template-label-labels[0]'), { target: { value: 'cancelled-notification' } }); + fireEvent.change(byId('template-audience'), { target: { value: 'patron' } }); + + await waitFor(() => expect(byId('clickable-save-template')).not.toBeDisabled()); + fireEvent.click(byId('clickable-save-template')); + + await waitFor(() => expect(mockOkapi.post).toHaveBeenCalledWith('broker/templates', { + json: { + title: 'Cancelled', + purpose: 'email', + contentType: 'html', + subject: 'Cancelled', + body: 'Your request was cancelled', + labels: ['cancelled-notification'], + audience: 'patron', + }, + })); + }); + + it('updates a template without sending the purpose the broker cannot change', async () => { + renderList(); + + fireEvent.click(await screen.findByText('Received item notification')); + fireEvent.click(await screen.findByRole('button', { name: 'stripes-components.paneMenuActionsToggleLabel' })); + // The edit item is a link styled as a dropdown button, so it reports role button. + fireEvent.click(screen.getByRole('button', { name: 'ui-rs.edit' })); + + await waitFor(() => expect(byId('template-title')).toBeInTheDocument()); + fireEvent.change(byId('template-title'), { target: { value: 'Received (revised)' } }); + + await waitFor(() => expect(byId('clickable-save-template')).not.toBeDisabled()); + fireEvent.click(byId('clickable-save-template')); + + await waitFor(() => expect(mockOkapi.put).toHaveBeenCalled()); + const [path, { json }] = mockOkapi.put.mock.calls[0]; + expect(path).toBe('broker/templates/t1'); + expect(json.title).toBe('Received (revised)'); + expect(json).not.toHaveProperty('purpose'); + }); + + it('deletes a template once the deletion is confirmed', async () => { + renderList(); + + fireEvent.click(await screen.findByText('Received item notification')); + fireEvent.click(await screen.findByRole('button', { name: 'stripes-components.paneMenuActionsToggleLabel' })); + fireEvent.click(screen.getByRole('button', { name: 'ui-rs.delete' })); + + // The confirmation button shares its label with the action-menu item. + const confirm = await screen.findByRole('button', { name: 'ui-rs.delete' }); + fireEvent.click(confirm); + + await waitFor(() => expect(mockOkapi.delete).toHaveBeenCalledWith('broker/templates/t1')); + }); +}); diff --git a/ui-rs/src/settings/templates/ViewTemplate.css b/ui-rs/src/settings/templates/ViewTemplate.css new file mode 100644 index 0000000..1c86aeb --- /dev/null +++ b/ui-rs/src/settings/templates/ViewTemplate.css @@ -0,0 +1,8 @@ +/* Plain-text bodies are authored with meaningful line breaks; keep them, but wrap + long lines rather than scrolling the pane sideways. */ +.bodyText { + margin: 0; + font-family: inherit; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/ui-rs/src/settings/templates/ViewTemplate.js b/ui-rs/src/settings/templates/ViewTemplate.js new file mode 100644 index 0000000..8b24511 --- /dev/null +++ b/ui-rs/src/settings/templates/ViewTemplate.js @@ -0,0 +1,121 @@ +import React, { useContext, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useMutation, useQueryClient } from 'react-query'; +import { + Button, + Col, + ConfirmationModal, + Editor, + KeyValue, + Pane, + Row, +} from '@folio/stripes/components'; +import { CalloutContext } from '@folio/stripes/core'; +import { DirectLink, useOkapiKy, useOkapiQuery, useCloseDirect } from '@projectreshare/stripes-reshare'; + +import css from './ViewTemplate.css'; + +const ViewTemplate = ({ match }) => { + const { id } = match.params; + const intl = useIntl(); + const okapiKy = useOkapiKy(); + const queryClient = useQueryClient(); + const callout = useContext(CalloutContext); + const close = useCloseDirect(); + const [confirmDelete, setConfirmDelete] = useState(false); + + const { data, isSuccess } = useOkapiQuery(`broker/templates/${id}`); + + const remover = useMutation({ + mutationFn: () => okapiKy.delete(`broker/templates/${id}`), + onSuccess: async () => { + await queryClient.invalidateQueries('broker/templates'); + close(); + }, + onError: () => callout?.sendCallout({ + type: 'error', + message: , + }), + }); + + if (!isSuccess) return null; + + const isHtml = data.contentType === 'html'; + + return ( + ( + <> + + + + + + )} + > + + + } + value={intl.formatMessage({ id: `ui-rs.settings.templates.purpose.${data.purpose}`, defaultMessage: data.purpose })} + /> + } + value={(data.labels ?? []).join(', ')} + /> + } + value={} + /> + + + {/* Pull slips have no subject; showing a blank row would imply one is missing. */} + {data.purpose !== 'pullslip' && ( + } + value={data.subject} + /> + )} + } + value={intl.formatMessage({ id: `ui-rs.settings.templates.contentType.${data.contentType}`, defaultMessage: data.contentType })} + /> + + + + + } /> + {/* Rendered through the editor rather than injected as raw HTML: staff-authored + markup is untrusted and nothing in the tree sanitizes it. */} + {isHtml + ? {}} /> + :
{data.body}
} + +
+ } + message={} + confirmLabel={} + onConfirm={() => { setConfirmDelete(false); remover.mutate(); }} + onCancel={() => setConfirmDelete(false)} + /> +
+ ); +}; + +export default ViewTemplate; diff --git a/ui-rs/src/settings/templates/index.js b/ui-rs/src/settings/templates/index.js new file mode 100644 index 0000000..e4ee3d2 --- /dev/null +++ b/ui-rs/src/settings/templates/index.js @@ -0,0 +1 @@ +export { default } from './Templates'; diff --git a/ui-rs/src/settings/templates/mapping.js b/ui-rs/src/settings/templates/mapping.js new file mode 100644 index 0000000..7d6cd37 --- /dev/null +++ b/ui-rs/src/settings/templates/mapping.js @@ -0,0 +1,74 @@ +// Form values <-> API bodies. The broker's Create and Update shapes differ: +// UpdateTemplate has no `purpose`, so a template's purpose is fixed at creation. + +const cleanLabels = (labels) => (labels ?? []) + .map(label => (label ?? '').trim()) + .filter(Boolean); + +export function recordToFormValues(record = {}) { + return { + title: record.title ?? '', + purpose: record.purpose ?? '', + contentType: record.contentType ?? 'text', + subject: record.subject ?? '', + body: record.body ?? '', + // Starting with a blank row is better UX than the repeatable field's empty state. + labels: record.labels?.length ? [...record.labels] : [''], + audience: record.audience ?? '', + }; +} + +export function buildCreateTemplateBody(values = {}) { + const created = { + title: values.title, + purpose: values.purpose, + body: values.body, + contentType: values.contentType, + labels: cleanLabels(values.labels), + }; + const subject = (values.subject ?? '').trim(); + // Subject is documented as unused for pull slips; don't store a value that misleads. + if (subject && values.purpose !== 'pullslip') created.subject = subject; + if (values.audience) created.audience = values.audience; + return created; +} + +export function buildUpdateTemplateBody(values = {}) { + const updated = { + title: values.title, + body: values.body, + contentType: values.contentType, + labels: cleanLabels(values.labels), + subject: (values.subject ?? '').trim(), + }; + // Omitted rather than sent empty: "" matches neither an audience nor the IS NULL + // "both" case, making the template unreachable. The form rejects clearing it. + if (values.audience) updated.audience = values.audience; + return updated; +} + +// Quill reads its value as markup, so text carried into HTML mode has to be escaped +// or fragments like "Dear ," disappear as unknown tags. +export function escapeHtml(text = '') { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export function templateLabelOptions(templates, { purpose, audience } = {}) { + const byLabel = new Map(); + (templates ?? []) + .filter(t => t.purpose === purpose && (!t.audience || t.audience === audience)) + .forEach(t => (t.labels ?? []).forEach(label => { + if (label && !byLabel.has(label)) byLabel.set(label, { value: label, label: `${t.title} (${label})` }); + })); + return [...byLabel.values()].sort((a, b) => a.label.localeCompare(b.label)); +} + +export function suggestedLabels(templates) { + const seen = new Set(); + (templates ?? []) + .forEach(template => (template.labels ?? []).forEach(label => seen.add(label))); + return [...seen]; +} diff --git a/ui-rs/src/settings/templates/mapping.test.js b/ui-rs/src/settings/templates/mapping.test.js new file mode 100644 index 0000000..df69f2e --- /dev/null +++ b/ui-rs/src/settings/templates/mapping.test.js @@ -0,0 +1,153 @@ +import { + recordToFormValues, + buildCreateTemplateBody, + buildUpdateTemplateBody, + escapeHtml, + suggestedLabels, + templateLabelOptions, +} from './mapping'; + +describe('recordToFormValues', () => { + it('maps a stored template onto form values', () => { + expect(recordToFormValues({ + id: 't1', + title: 'Received', + purpose: 'email', + contentType: 'html', + subject: 'Ready', + body: '

Ready

', + labels: ['received-notification'], + audience: 'patron', + })).toEqual({ + title: 'Received', + purpose: 'email', + contentType: 'html', + subject: 'Ready', + body: '

Ready

', + labels: ['received-notification'], + audience: 'patron', + }); + }); + + it('represents an absent audience as an empty selection', () => { + expect(recordToFormValues({ title: 'T', body: 'B' }).audience).toBe(''); + }); + + it('starts labels with one blank row rather than an empty list', () => { + expect(recordToFormValues({ title: 'T', body: 'B' }).labels).toEqual(['']); + }); +}); + +describe('buildCreateTemplateBody', () => { + const values = { + title: 'Received', + purpose: 'email', + contentType: 'text', + subject: ' Ready ', + body: 'Your item is ready', + labels: ['received-notification', ' ', ''], + audience: 'patron', + }; + + it('trims labels and drops blank rows', () => { + expect(buildCreateTemplateBody(values).labels).toEqual(['received-notification']); + }); + + it('sends purpose, which cannot be changed later', () => { + expect(buildCreateTemplateBody(values).purpose).toBe('email'); + }); + + it('omits a blank subject and a blank audience rather than sending empties', () => { + const body = buildCreateTemplateBody({ ...values, subject: ' ', audience: '' }); + expect(body).not.toHaveProperty('subject'); + expect(body).not.toHaveProperty('audience'); + }); + + it('omits the subject for pull slips, which never use one', () => { + expect(buildCreateTemplateBody({ ...values, purpose: 'pullslip' })).not.toHaveProperty('subject'); + }); +}); + +describe('buildUpdateTemplateBody', () => { + const values = { + title: 'Received', + purpose: 'email', + contentType: 'text', + subject: 'Ready', + body: 'Your item is ready', + labels: ['received-notification'], + audience: 'patron', + }; + + it('never sends purpose: UpdateTemplate has no such field', () => { + expect(buildUpdateTemplateBody(values)).not.toHaveProperty('purpose'); + }); + + it('sends an empty subject so clearing one takes effect', () => { + // PUT leaves omitted fields untouched, so omission would silently keep the old value. + expect(buildUpdateTemplateBody({ ...values, subject: '' }).subject).toBe(''); + }); + + it('omits a cleared audience, which would otherwise be stored as an unmatchable empty string', () => { + expect(buildUpdateTemplateBody({ ...values, audience: '' })).not.toHaveProperty('audience'); + }); +}); + +describe('escapeHtml', () => { + it('escapes markup so plain text survives the move into the editor', () => { + expect(escapeHtml('Dear , R&D')).toBe('Dear <name>, R&D'); + }); + + it('escapes ampersands before angle brackets', () => { + expect(escapeHtml('<')).toBe('&lt;'); + }); +}); + +describe('templateLabelOptions', () => { + const templates = [ + { title: 'Pull slips', purpose: 'email', audience: 'staff', labels: ['pullslip-email', 'nightly'] }, + { title: 'Both audiences', purpose: 'email', labels: ['shared'] }, + { title: 'Patron notice', purpose: 'email', audience: 'patron', labels: ['received-notification'] }, + { title: 'Slip layout', purpose: 'pullslip', audience: 'staff', labels: ['slip'] }, + ]; + const options = templateLabelOptions(templates, { purpose: 'email', audience: 'staff' }); + + // One assertion covers the lot: which templates qualify (this purpose, this audience + // or none), one option per label, named by title and label, ordered by what is shown. + it('offers a labelled option for every label of every matching template', () => { + expect(options).toEqual([ + { value: 'shared', label: 'Both audiences (shared)' }, + { value: 'nightly', label: 'Pull slips (nightly)' }, + { value: 'pullslip-email', label: 'Pull slips (pullslip-email)' }, + ]); + }); + + it('offers a label shared by two templates once, as the broker resolves only one', () => { + const dupes = [ + { title: 'First', purpose: 'email', audience: 'staff', labels: ['shared'] }, + { title: 'Second', purpose: 'email', audience: 'staff', labels: ['shared'] }, + ]; + expect(templateLabelOptions(dupes, { purpose: 'email', audience: 'staff' })) + .toEqual([{ value: 'shared', label: 'First (shared)' }]); + }); + + it('is empty when the templates could not be fetched', () => { + expect(templateLabelOptions(undefined, { purpose: 'email', audience: 'staff' })).toEqual([]); + }); +}); + +describe('suggestedLabels', () => { + const defaults = [ + { purpose: 'email', labels: ['received-notification'] }, + { purpose: 'email', labels: ['pullslip-email', 'received-notification'] }, + { purpose: 'pullslip', labels: ['slip'] }, + ]; + + it('collects labels across purposes without duplicates', () => { + expect(suggestedLabels(defaults)).toEqual(['received-notification', 'pullslip-email', 'slip']); + }); + + it('is empty when the defaults could not be fetched', () => { + expect(suggestedLabels(undefined)).toEqual([]); + }); +}); diff --git a/ui-rs/src/test/editorMock.js b/ui-rs/src/test/editorMock.js new file mode 100644 index 0000000..8bc37ac --- /dev/null +++ b/ui-rs/src/test/editorMock.js @@ -0,0 +1,35 @@ +import React from 'react'; + +// Stripes' Editor wraps react-quill, which does not survive jsdom. Swap in a +// textarea so a form holding an HTML template can render at all. Mock the deep +// path, like textAreaMock — internal stripes imports bypass the barrel: +// jest.mock('@folio/stripes-components/lib/Editor', () => require('../test/editorMock').default); +// That path is the formField-wrapped export, so the stub gets final-form's +// `input`/`meta` and wires the handlers itself. +// +// This exists so tests can run, not so they can assert on the editor: anything it +// appears to prove about rich text is a property of this stub. +// react-quill also fires changes nobody typed (its own re-parse, source `api`), and +// no DOM event can produce one. Rather than have the stub imitate Quill, it hands the +// current handler back so a test can call it with the arguments react-quill documents. +export const lastEditor = { onChange: null }; + +const Editor = ({ input = {}, id, label, value, readOnly, onChange }) => { + lastEditor.onChange = onChange ?? ((html) => input.onChange(html)); + return ( +