Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type EmailTemplateEditorProps = {
error?: string;
/** Namespaces the control ids and test ids, so two editors can coexist on one page. */
idPrefix: string;
/** The languages to offer in the language picker; defaults to all supported languages. */
languages?: readonly Language[];
/** Omit to show the template read-only. */
onChange?: (template: MailTemplate) => void;
template: MailTemplate;
Expand All @@ -22,6 +24,7 @@ export type EmailTemplateEditorProps = {
export const EmailTemplateEditor = ({
error,
idPrefix,
languages,
onChange,
template,
variables = []
Expand Down Expand Up @@ -56,6 +59,7 @@ export const EmailTemplateEditor = ({
<LanguageSelect
data-testid={`${idPrefix}-language`}
id={`${idPrefix}-language`}
options={languages}
value={language}
onChange={setLanguage}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,31 @@ import React from 'react';

import { Button, Dialog, Heading, Input, Label } from '@douglasneuroinformatics/libui/components';
import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { Language } from '@opendatacapture/schemas/core';
import type { MailTemplate } from '@opendatacapture/schemas/mail';

import { EmailTemplateEditor } from '@/components/EmailTemplateEditor';
import { ASSIGNMENT_TEMPLATE_VARS } from '@/utils/email-template';
import { LANGUAGE_LABELS, LANGUAGES } from '@/utils/language';
import { LANGUAGE_LABELS } from '@/utils/language';

const emptyTemplate = (): MailTemplate => ({ body: {}, subject: {} });

export type CreateTemplateFormProps = {
activeLanguages: readonly Language[];
isPending: boolean;
/** Resolves true once the template is saved; the form clears only then. */
onCreate: (name: string, template: MailTemplate) => Promise<boolean>;
validateContent: (template: MailTemplate) => string | undefined;
validateName: (name: string) => string | undefined;
};

export const CreateTemplateForm = ({ isPending, onCreate, validateContent, validateName }: CreateTemplateFormProps) => {
export const CreateTemplateForm = ({
activeLanguages,
isPending,
onCreate,
validateContent,
validateName
}: CreateTemplateFormProps) => {
const { t } = useTranslation();
const addNotification = useNotificationsStore((store) => store.addNotification);
const formRef = React.useRef<HTMLFormElement>(null);
Expand All @@ -27,7 +35,7 @@ export const CreateTemplateForm = ({ isPending, onCreate, validateContent, valid
const [isConfirmingMissingTranslations, setIsConfirmingMissingTranslations] = React.useState(false);

const contentError = validateContent(draft);
const missingLanguages = LANGUAGES.filter((code) => !draft.body[code]?.trim());
const missingLanguages = activeLanguages.filter((code) => !draft.body[code]?.trim());

const create = async () => {
if (!(await onCreate(name.trim(), draft))) {
Expand Down Expand Up @@ -76,6 +84,7 @@ export const CreateTemplateForm = ({ isPending, onCreate, validateContent, valid
<EmailTemplateEditor
error={contentError}
idPrefix="template-create"
languages={activeLanguages}
template={draft}
variables={ASSIGNMENT_TEMPLATE_VARS}
onChange={setDraft}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import React from 'react';

import { Button, Dialog, Input, Label } from '@douglasneuroinformatics/libui/components';
import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { Language } from '@opendatacapture/schemas/core';
import type { GroupEmailTemplate } from '@opendatacapture/schemas/group';
import type { MailTemplate } from '@opendatacapture/schemas/mail';

import { EmailTemplateEditor } from '@/components/EmailTemplateEditor';
import { ASSIGNMENT_TEMPLATE_VARS } from '@/utils/email-template';

type EditTemplateFormProps = {
activeLanguages: readonly Language[];
isPending: boolean;
onCancel: () => void;
onSave: (template: GroupEmailTemplate) => void;
Expand All @@ -18,6 +20,7 @@ type EditTemplateFormProps = {
};

const EditTemplateForm = ({
activeLanguages,
isPending,
onCancel,
onSave,
Expand Down Expand Up @@ -62,6 +65,7 @@ const EditTemplateForm = ({
<EmailTemplateEditor
error={contentError}
idPrefix="template-edit"
languages={activeLanguages}
template={draft}
variables={ASSIGNMENT_TEMPLATE_VARS}
onChange={setDraft}
Expand All @@ -84,6 +88,7 @@ const EditTemplateForm = ({
};

export type EditTemplateDialogProps = {
activeLanguages: readonly Language[];
isPending: boolean;
onOpenChange: (open: boolean) => void;
onSave: (template: GroupEmailTemplate) => void;
Expand All @@ -104,6 +109,7 @@ export const EditTemplateDialog = ({ template, ...props }: EditTemplateDialogPro
{/* Mounted only while open, so the draft resets between templates. */}
{template && (
<EditTemplateForm
activeLanguages={props.activeLanguages}
isPending={props.isPending}
template={template}
validateContent={props.validateContent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isAxiosError } from 'axios';
import { EyeIcon, PencilIcon, Trash2Icon } from 'lucide-react';

import { groupQueryOptions, useGroupQuery } from '@/hooks/useGroupQuery';
import { useSetupStateQuery } from '@/hooks/useSetupStateQuery';
import { useUpdateGroupMutation } from '@/hooks/useUpdateGroupMutation';
import { useAppStore } from '@/store';
import { ASSIGNMENT_TEMPLATE_VARS, checkTemplateIssue } from '@/utils/email-template';
Expand All @@ -31,6 +32,8 @@ const EmptyState = ({ children }: EmptyStateProps) => (
export const GroupEmailTemplates = () => {
const { t } = useTranslation();
const addNotification = useNotificationsStore((store) => store.addNotification);
const { data: setupState } = useSetupStateQuery();
const activeLanguages = setupState.activeLanguages;
const currentGroup = useAppStore((store) => store.currentGroup);
const groupId = currentGroup?.id;
const queryClient = useQueryClient();
Expand Down Expand Up @@ -280,15 +283,21 @@ export const GroupEmailTemplates = () => {
</div>

<CreateTemplateForm
activeLanguages={activeLanguages}
isPending={updateGroupMutation.isPending}
validateContent={validateContent}
validateName={(name) => validateName(name)}
onCreate={handleCreate}
/>

<ViewDefaultTemplateDialog open={isViewingDefault} onOpenChange={setIsViewingDefault} />
<ViewDefaultTemplateDialog
activeLanguages={activeLanguages}
open={isViewingDefault}
onOpenChange={setIsViewingDefault}
/>

<EditTemplateDialog
activeLanguages={activeLanguages}
isPending={updateGroupMutation.isPending}
template={editing}
validateContent={validateContent}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { Button, Dialog } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { Language } from '@opendatacapture/schemas/core';
import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail';

import { EmailTemplateEditor } from '@/components/EmailTemplateEditor';

export type ViewDefaultTemplateDialogProps = {
activeLanguages: readonly Language[];
onOpenChange: (open: boolean) => void;
open: boolean;
};

/** Read-only view of the message sent when no custom template is active. */
export const ViewDefaultTemplateDialog = ({ onOpenChange, open }: ViewDefaultTemplateDialogProps) => {
export const ViewDefaultTemplateDialog = ({ activeLanguages, onOpenChange, open }: ViewDefaultTemplateDialogProps) => {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
Expand All @@ -31,7 +33,11 @@ export const ViewDefaultTemplateDialog = ({ onOpenChange, open }: ViewDefaultTem
})}
</Dialog.Description>
</Dialog.Header>
<EmailTemplateEditor idPrefix="template-builtin" template={DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE} />
<EmailTemplateEditor
idPrefix="template-builtin"
languages={activeLanguages}
template={DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE}
/>
<Dialog.Footer>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('core.close')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const renderForm = ({
}: RenderOptions = {}) => {
render(
<CreateTemplateForm
activeLanguages={['en', 'fr']}
isPending={isPending}
validateContent={validateContent}
validateName={validateName}
Expand Down Expand Up @@ -92,7 +93,7 @@ describe('CreateTemplateForm', () => {
const { onCreate } = renderForm();
fillEnglishOnly('Reminder');
fireEvent.click(submitButton());
expect(screen.getByText('Warning: This template is missing translations for: Spanish, French.')).toBeTruthy();
expect(screen.getByText('Warning: This template is missing translations for: French.')).toBeTruthy();
expect(onCreate).not.toHaveBeenCalled();
});

Expand All @@ -116,11 +117,9 @@ describe('CreateTemplateForm', () => {
expect(onCreate).not.toHaveBeenCalled();
});

it('should create without a warning when every language is translated', () => {
it('should create without a warning when every active language is translated', () => {
const { onCreate } = renderForm();
fillEnglishOnly('Reminder');
switchLanguage('Spanish');
fillContent('Por favor', 'Abra {{url}} antes de {{expiresAt}}');
switchLanguage('French');
fillContent('Veuillez', 'Ouvrez {{url}} avant {{expiresAt}}');
fireEvent.click(submitButton());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks';
import { i18n } from '@douglasneuroinformatics/libui/i18n';
import type { Language } from '@opendatacapture/schemas/core';
import type { GroupEmailTemplate } from '@opendatacapture/schemas/group';
import type { MailTemplate } from '@opendatacapture/schemas/mail';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
Expand All @@ -16,14 +17,19 @@ const template: GroupEmailTemplate = {
subject: { en: 'Please complete' }
};

/** Spanish deliberately left out, so a test can tell a scoped picker from an unscoped one. */
const ACTIVE_LANGUAGES: readonly Language[] = ['en', 'fr'];

type RenderOptions = {
activeLanguages?: readonly Language[];
isPending?: boolean;
validateContent?: (template: MailTemplate) => string | undefined;
validateName?: (name: string) => string | undefined;
value?: GroupEmailTemplate | null;
};

const renderDialog = ({
activeLanguages = ACTIVE_LANGUAGES,
isPending = false,
validateContent = () => undefined,
validateName = () => undefined,
Expand All @@ -33,6 +39,7 @@ const renderDialog = ({
const onSave = vi.fn();
render(
<EditTemplateDialog
activeLanguages={activeLanguages}
isPending={isPending}
template={value}
validateContent={validateContent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ vi.mock('@/store', () => ({
useAppStore: vi.fn((selector: (state: typeof store) => unknown) => selector(store))
}));

vi.mock('@/hooks/useSetupStateQuery', () => ({
useSetupStateQuery: () => ({ data: { activeLanguages: ['en', 'fr'] } })
}));

const reminder: GroupEmailTemplate = {
body: { en: 'Open {{url}} before {{expiresAt}}' },
id: 'tpl-a',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { i18n } from '@douglasneuroinformatics/libui/i18n';
import type { Language } from '@opendatacapture/schemas/core';
import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -7,6 +8,9 @@ import { ViewDefaultTemplateDialog } from '../ViewDefaultTemplateDialog';

import '@/services/i18n';

/** Spanish deliberately left out, so a test can tell a scoped picker from an unscoped one. */
const ACTIVE_LANGUAGES: readonly Language[] = ['en', 'fr'];

describe('ViewDefaultTemplateDialog', () => {
beforeEach(() => {
i18n.changeLanguage('en');
Expand All @@ -15,12 +19,12 @@ describe('ViewDefaultTemplateDialog', () => {
afterEach(cleanup);

it('should render nothing while closed', () => {
render(<ViewDefaultTemplateDialog open={false} onOpenChange={vi.fn()} />);
render(<ViewDefaultTemplateDialog activeLanguages={ACTIVE_LANGUAGES} open={false} onOpenChange={vi.fn()} />);
expect(screen.queryByText('Built-in default template')).toBeNull();
});

it('should show the built-in subject and body when open', () => {
render(<ViewDefaultTemplateDialog open onOpenChange={vi.fn()} />);
render(<ViewDefaultTemplateDialog open activeLanguages={ACTIVE_LANGUAGES} onOpenChange={vi.fn()} />);
expect(screen.getByTestId<HTMLInputElement>('template-builtin-subject').value).toBe(
DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject.en
);
Expand All @@ -30,15 +34,22 @@ describe('ViewDefaultTemplateDialog', () => {
});

it('should make the built-in subject and body read-only, since the default template cannot be edited', () => {
render(<ViewDefaultTemplateDialog open onOpenChange={vi.fn()} />);
render(<ViewDefaultTemplateDialog open activeLanguages={ACTIVE_LANGUAGES} onOpenChange={vi.fn()} />);
expect(screen.getByTestId<HTMLInputElement>('template-builtin-subject').readOnly).toBe(true);
expect(screen.getByTestId<HTMLTextAreaElement>('template-builtin-body').readOnly).toBe(true);
});

it('should request closing when the close button is clicked', () => {
const onOpenChange = vi.fn();
render(<ViewDefaultTemplateDialog open onOpenChange={onOpenChange} />);
render(<ViewDefaultTemplateDialog open activeLanguages={ACTIVE_LANGUAGES} onOpenChange={onOpenChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});

// Offering a language the instance has disabled invites authoring copy nothing will ever send.
it('should offer only the languages the instance has enabled', () => {
render(<ViewDefaultTemplateDialog open activeLanguages={ACTIVE_LANGUAGES} onOpenChange={vi.fn()} />);
fireEvent.keyDown(screen.getByTestId('template-builtin-language'), { key: 'Enter' });
expect(screen.getAllByRole('option').map((option) => option.textContent)).toStrictEqual(['English', 'French']);
});
});
5 changes: 4 additions & 1 deletion apps/web/src/components/MailSettings/MailSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';

import { Checkbox, Heading } from '@douglasneuroinformatics/libui/components';
import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { Language } from '@opendatacapture/schemas/core';
import { $UpdateMailConfigData, isSameMailServer } from '@opendatacapture/schemas/mail';
import type { MailConfigDto, MailTemplate, UpdateMailConfigData } from '@opendatacapture/schemas/mail';
import { z } from 'zod/v4';
Expand Down Expand Up @@ -32,11 +33,12 @@ const initialValues = (config: MailConfigDto | null): MailConfigFormValues => ({
});

export type MailSettingsProps = {
activeLanguages: readonly Language[];
config: MailConfigDto | null;
newUserEmailTemplate: MailTemplate;
};

export const MailSettings = ({ config, newUserEmailTemplate }: MailSettingsProps) => {
export const MailSettings = ({ activeLanguages, config, newUserEmailTemplate }: MailSettingsProps) => {
const { t } = useTranslation();
const addNotification = useNotificationsStore((store) => store.addNotification);
const updateMutation = useUpdateMailSettingsMutation();
Expand Down Expand Up @@ -202,6 +204,7 @@ export const MailSettings = ({ config, newUserEmailTemplate }: MailSettingsProps
<TestMailSection buildConfig={buildConfig} onInvalid={setErrors} />

<NewUserTemplateSection
activeLanguages={activeLanguages}
isSaving={updateMutation.isPending}
template={newUserEmailTemplate}
onSave={(next) => void save({ newUserEmailTemplate: next })}
Expand Down
Loading
Loading