, 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); + }} + /> + )} +
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( +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: 'DearHello
' }, + }); + + 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 |
| x |
Hi
Hi
'); + expect(save()).toBeDisabled(); + }); + + it('points at the source view once it has dropped one', async () => { + await renderHtml(); + act(() => lastEditor.onChange('Hi
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 ( +{data.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