Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ui-rs/src/settings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -13,6 +14,11 @@ const sections = [
label: <FormattedMessage id="ui-rs.settings.scheduledActions.heading" />,
component: ScheduledActions,
},
{
route: 'templates',
label: <FormattedMessage id="ui-rs.settings.templates.heading" />,
component: Templates,
},
],
},
];
Expand Down
30 changes: 16 additions & 14 deletions ui-rs/src/settings/scheduledActions/ScheduledActionForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,31 @@ 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) => {
const idx = e.target.value;
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));
});
Expand All @@ -54,11 +56,11 @@ const TemplatePicker = ({ templates }) => {
<Row>
<Col xs={12} md={8}>
<Select
id="scheduled-action-template"
id="scheduled-action-preset"
dataOptions={options}
value={selected}
onChange={onChange}
label={<FormattedMessage id="ui-rs.settings.scheduledActions.field.template" />}
label={<FormattedMessage id="ui-rs.settings.scheduledActions.field.preset" />}
/>
</Col>
</Row>
Expand All @@ -67,8 +69,8 @@ const TemplatePicker = ({ templates }) => {

const ScheduledActionForm = ({ initialValues, onSubmit, onClose, title, submitLabelId, submitting, editing, unsupportedSchedule }) => {
const intl = useIntl();
// Template lookup is optional; failure leaves the manual form usable.
const { data: templates } = useOkapiQuery(
// Preset lookup is optional; failure leaves the manual form usable.
const { data: presets } = useOkapiQuery(
'broker/state_model/batch_actions',
{ enabled: !editing, useErrorBoundary: false }
);
Expand Down Expand Up @@ -155,7 +157,7 @@ const ScheduledActionForm = ({ initialValues, onSubmit, onClose, title, submitLa
/>
</MessageBanner>
)}
{!editing && <TemplatePicker templates={templates ?? []} />}
{!editing && <PresetPicker presets={presets ?? []} />}
<Row>
<Col xs={12} md={4}>
<Field name="actionName">
Expand Down
105 changes: 81 additions & 24 deletions ui-rs/src/settings/scheduledActions/ScheduledActionForm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ const mockOkapi = makeOkapiKyMock();
jest.mock('@folio/stripes-components/lib/Icon', () => require('../../test/iconMock').default);
jest.mock('@folio/stripes/core', () => require('../../test/stripesCore').makeStripesCoreMock(() => mockOkapi));

const TEMPLATES = [
const PRESETS = [
{
titleKey: 'email-pullslips',
title: 'Email pull slips ready to ship',
actionName: 'email-pullslips',
batchQuery: 'side = lending and state = WILL_SUPPLY',
schedule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=6;BYMINUTE=0',
actionParams: { to: ['staff@example.com'], subject: 'Pull slips', body: 'See attached', includePdf: true },
actionParams: { to: ['staff@example.com'], templateLabel: 'pullslip-email', includePdf: true },
},
{
titleKey: 'request-aging-express',
Expand All @@ -29,6 +29,13 @@ const TEMPLATES = [
},
];

const TEMPLATES = {
items: [
{ id: 't1', title: 'Scheduled pullslips', purpose: 'email', audience: 'staff', labels: ['pullslip-email'] },
{ id: 't2', title: 'Patron notice', purpose: 'email', audience: 'patron', labels: ['received-notification'] },
],
};

const baseInitial = {
actionName: 'email-pullslips',
frequency: 'weekly',
Expand All @@ -42,9 +49,11 @@ const baseInitial = {

const ATTACH_PDF = 'ui-rs.settings.scheduledActions.params.includePdf';
const RECIPIENT_0 = 'scheduled-action-email-to-actionParams.to[0]';
const TEMPLATE_NONE = 'Create an email template for staff first';
const messages = {
'ui-rs.settings.scheduledActions.unsupportedSchedule': '{schedule}',
'ui-rs.settings.scheduledActions.templates.email-pullslips': 'Zettel mailen',
'ui-rs.settings.scheduledActions.preset.email-pullslips': 'Zettel mailen',
'ui-rs.settings.scheduledActions.params.templateLabelNone': TEMPLATE_NONE,
};

const byId = (id) => document.getElementById(id);
Expand All @@ -56,10 +65,14 @@ const fillRequired = () => {
fireEvent.click(screen.getByRole('button', { name: 'Monday' }));
};

const fillEmail = () => {
const TEMPLATE_SELECT = 'scheduled-action-email-templateLabel';

// The template options arrive from broker/templates, so the select is not there
// on first render.
const fillEmail = async () => {
fireEvent.change(byId(RECIPIENT_0), { target: { value: 'a@lib.org' } });
fireEvent.change(byId('scheduled-action-email-subject'), { target: { value: 'Pull slips' } });
fireEvent.change(byId('scheduled-action-email-body'), { target: { value: 'See attached' } });
await waitFor(() => expect(byId(TEMPLATE_SELECT)).toBeInTheDocument());
fireEvent.change(byId(TEMPLATE_SELECT), { target: { value: 'pullslip-email' } });
};

const renderForm = (onSubmit, { initialValues = baseInitial, ...props } = {}) => renderWithRs(
Expand All @@ -77,14 +90,17 @@ const renderForm = (onSubmit, { initialValues = baseInitial, ...props } = {}) =>
describe('ScheduledActionForm', () => {
beforeEach(() => {
jest.clearAllMocks();
mockOkapi.setResponses({ 'broker/state_model/batch_actions': TEMPLATES });
mockOkapi.setResponses({
'broker/state_model/batch_actions': PRESETS,
'broker/templates': TEMPLATES,
});
});

it('disables save until the query, an hour and a day are valid', async () => {
renderForm(jest.fn());

expect(save()).toBeDisabled();
fillEmail();
await fillEmail();
fillRequired();
await waitFor(() => expect(save()).not.toBeDisabled());

Expand All @@ -102,7 +118,7 @@ describe('ScheduledActionForm', () => {

fillRequired();
fireEvent.change(byId('scheduled-action-minute'), { target: { value: '30' } });
fillEmail();
await fillEmail();
fireEvent.click(screen.getByLabelText(ATTACH_PDF));

fireEvent.click(save());
Expand All @@ -114,18 +130,17 @@ describe('ScheduledActionForm', () => {
expect(values.hours).toBe('9');
expect(values.minute).toBe('30');
expect(values.actionParams.to).toEqual(['a@lib.org']);
expect(values.actionParams.subject).toBe('Pull slips');
expect(values.actionParams.body).toBe('See attached');
expect(values.actionParams.templateLabel).toBe('pullslip-email');
expect(values.actionParams.includePdf).toBe(true);
});

it('keeps save disabled until a valid recipient, subject and body are provided', async () => {
it('keeps save disabled until a valid recipient and a template are provided', async () => {
renderForm(jest.fn());

fillRequired();
await waitFor(() => expect(save()).toBeDisabled());

fillEmail();
await fillEmail();
await waitFor(() => expect(save()).not.toBeDisabled());

fireEvent.change(byId(RECIPIENT_0), { target: { value: 'not-an-email' } });
Expand Down Expand Up @@ -155,26 +170,68 @@ describe('ScheduledActionForm', () => {
expect(values.actionParams).not.toHaveProperty('includePdf');
});

it('fills the whole form (query, action, params and schedule) from a template', async () => {
it('fills the whole form (query, action, params and schedule) from a preset', async () => {
renderForm(jest.fn());

await waitFor(() => expect(byId('scheduled-action-template')).toBeInTheDocument());
await waitFor(() => expect(byId('scheduled-action-preset')).toBeInTheDocument());

fireEvent.change(byId('scheduled-action-template'), { target: { value: '1' } });
fireEvent.change(byId('scheduled-action-preset'), { target: { value: '1' } });
await waitFor(() => expect(byId('scheduled-action-age-interval')).toBeInTheDocument());
expect(byId('scheduled-action-batchQuery').value).toBe(TEMPLATES[1].batchQuery);
expect(byId('scheduled-action-batchQuery').value).toBe(PRESETS[1].batchQuery);
expect(byId('scheduled-action-actionName').value).toBe('request-aging');
expect(byId('scheduled-action-age-interval').value).toBe('2h');
expect(byId('scheduled-action-frequency').value).toBe('minutely');
expect(byId('scheduled-action-interval').value).toBe('15');
});

it('labels templates by titleKey, falling back to the title the broker sent', async () => {
it('labels presets by titleKey, falling back to the title the broker sent', async () => {
renderForm(jest.fn());

await waitFor(() => expect(byId('scheduled-action-template')).toBeInTheDocument());
await waitFor(() => expect(byId('scheduled-action-preset')).toBeInTheDocument());
expect(screen.getByRole('option', { name: 'Zettel mailen' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: TEMPLATES[1].title })).toBeInTheDocument();
expect(screen.getByRole('option', { name: PRESETS[1].title })).toBeInTheDocument();
});

// Which templates qualify is mapping.test.js's job; this only passes if the scope
// handed to it (email, staff) reached the query.
it('takes a preset\'s suggested template label when a template carries it', async () => {
renderForm(jest.fn());

await waitFor(() => expect(byId('scheduled-action-preset')).toBeInTheDocument());
fireEvent.change(byId('scheduled-action-preset'), { target: { value: '0' } });

await waitFor(() => expect(byId(TEMPLATE_SELECT).value).toBe('pullslip-email'));
});

it('drops a suggested label no template carries, leaving the field to be filled', async () => {
// The broker suggests labels but never creates the templates behind them.
mockOkapi.setResponses({
'broker/state_model/batch_actions': PRESETS,
'broker/templates': { items: [{ id: 't3', title: 'Nightly', purpose: 'email', audience: 'staff', labels: ['nightly'] }] },
});
renderForm(jest.fn());

await waitFor(() => expect(byId('scheduled-action-preset')).toBeInTheDocument());
fireEvent.change(byId('scheduled-action-preset'), { target: { value: '0' } });

await waitFor(() => expect(byId('scheduled-action-batchQuery').value).toBe(PRESETS[0].batchQuery));
expect(byId(TEMPLATE_SELECT).value).toBe('');
expect(save()).toBeDisabled();
});

it('explains how to create a template when none is available, blocking save', async () => {
mockOkapi.setResponses({
'broker/state_model/batch_actions': PRESETS,
'broker/templates': { items: [] },
});
renderForm(jest.fn());

await waitFor(() => expect(screen.getByText(TEMPLATE_NONE)).toBeInTheDocument());
expect(byId(TEMPLATE_SELECT)).toBeNull();

fillRequired();
fireEvent.change(byId(RECIPIENT_0), { target: { value: 'a@lib.org' } });
await waitFor(() => expect(save()).toBeDisabled());
});

it('in hourly mode, save gates on a query and a valid minute (no days/hours)', async () => {
Expand Down Expand Up @@ -220,22 +277,22 @@ describe('ScheduledActionForm', () => {
expect(values.interval).toBe('15');
});

it('renders without a template picker when the defaults endpoint is unavailable', async () => {
it('renders without a preset picker when the defaults endpoint is unavailable', async () => {
// Silence the expected react-query error for the missing mock response.
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockOkapi.setResponses({});
renderForm(jest.fn());

await waitFor(() => expect(byId('scheduled-action-actionName')).toBeInTheDocument());
expect(byId('scheduled-action-template')).toBeNull();
expect(byId('scheduled-action-preset')).toBeNull();
expect(byId('scheduled-action-batchQuery').value).toBe('');
errorSpy.mockRestore();
});

it('omits the template picker when editing', async () => {
it('omits the preset picker when editing', async () => {
renderForm(jest.fn(), { editing: true });

await waitFor(() => expect(byId('scheduled-action-actionName')).toBeInTheDocument());
expect(byId('scheduled-action-template')).toBeNull();
expect(byId('scheduled-action-preset')).toBeNull();
});
});
Loading
Loading