Skip to content

Commit

Permalink
Make email preferences load a client-side app (#5905)
Browse files Browse the repository at this point in the history
* Make email preferences load the client-side app with proper data

* Add logic to save email preferences in EmailNotificationsApp (#5902)
  • Loading branch information
acelaya authored Dec 8, 2023
1 parent cc45ac5 commit 2c0bd3f
Show file tree
Hide file tree
Showing 19 changed files with 274 additions and 163 deletions.
1 change: 0 additions & 1 deletion lms/resources/_js_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ class JSConfig:
class Mode(str, Enum):
OAUTH2_REDIRECT_ERROR = "oauth2-redirect-error"
BASIC_LTI_LAUNCH = "basic-lti-launch"
EMAIL_NOTIFICATIONS = "email-notifications"
FILE_PICKER = "content-item-selection"
ERROR_DIALOG = "error-dialog"

Expand Down
2 changes: 1 addition & 1 deletion lms/static/scripts/frontend_apps/components/AppRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function AppRoot({ initialConfig, services }: AppRootProps) {
<FilePickerApp />
</DataLoader>
</Route>
<Route path="/app/email-notifications">
<Route path="/email/preferences">
<EmailNotificationsApp />
</Route>
<Route path="/app/error-dialog">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default function BasicLTILaunchApp() {
// Content URL to show in the iframe.
viaUrl: viaURL,
canvas,
} = useConfig(['hypothesisClient']);
} = useConfig(['api', 'hypothesisClient']);

const clientRPC = useService(ClientRPC);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export default function ContentSelector({
},
youtube: { enabled: youtubeEnabled },
},
} = useConfig(['filePicker']);
} = useConfig(['api', 'filePicker']);

// Map the existing content selection to a dialog type and value. We don't
// open the corresponding dialog immediately, but do pre-fill the dialog
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useState } from 'preact/hooks';
import { useCallback, useState } from 'preact/hooks';

import { useConfig } from '../config';
import EmailNotificationsPreferences from './EmailNotificationsPreferences';

export default function EmailNotificationsApp() {
const { emailNotifications } = useConfig(['emailNotifications']);
const [selectedDays, setSelectedDays] = useState(emailNotifications);
const [saving, setSaving] = useState(false);
const onSave = useCallback(() => setSaving(true), []);

return (
<div className="h-full grid place-items-center">
Expand All @@ -15,6 +17,8 @@ export default function EmailNotificationsApp() {
updateSelectedDays={newSelectedDays =>
setSelectedDays(prev => ({ ...prev, ...newSelectedDays }))
}
onSave={onSave}
saving={saving}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,106 +1,144 @@
import type { PanelProps } from '@hypothesis/frontend-shared';
import { Button, Checkbox, Panel } from '@hypothesis/frontend-shared';
import { Button, Callout, Checkbox, Panel } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useCallback } from 'preact/hooks';

import type { EmailNotificationsPreferences, WeekDay } from '../config';

const dayNames: [WeekDay, string][] = [
['instructor_email_digests.days.sun', 'Sunday'],
['instructor_email_digests.days.mon', 'Monday'],
['instructor_email_digests.days.tue', 'Tuesday'],
['instructor_email_digests.days.wed', 'Wednesday'],
['instructor_email_digests.days.thu', 'Thursday'],
['instructor_email_digests.days.fri', 'Friday'],
['instructor_email_digests.days.sat', 'Saturday'],
['sun', 'Sunday'],
['mon', 'Monday'],
['tue', 'Tuesday'],
['wed', 'Wednesday'],
['thu', 'Thursday'],
['fri', 'Friday'],
['sat', 'Saturday'],
];

export type EmailNotificationsPreferencesProps = {
/** Currently selected days */
selectedDays: EmailNotificationsPreferences;
/** Callback to fully or partially update currently selected days, without saving */
updateSelectedDays: (
newSelectedDays: Partial<EmailNotificationsPreferences>
) => void;

/** Callback invoked when saving currently selected days */
onSave: (submitEvent: Event) => void;
/** Indicates if a save operation is in progress */
saving?: boolean;
/**
* Represents the result of saving preferences, which can be error or success,
* and includes a message to display.
*/
result?: {
status: 'success' | 'error';
message: string;
};

/**
* Callback used to handle closing the panel.
* If not provided, then the panel won't be considered closable.
*/
onClose?: PanelProps['onClose'];
};

export default function EmailNotificationsPreferences({
onClose,
selectedDays,
updateSelectedDays,
onSave,
saving = false,
result,
onClose,
}: EmailNotificationsPreferencesProps) {
const setAllTo = useCallback(
(enabled: boolean) =>
updateSelectedDays({
'instructor_email_digests.days.sun': enabled,
'instructor_email_digests.days.mon': enabled,
'instructor_email_digests.days.tue': enabled,
'instructor_email_digests.days.wed': enabled,
'instructor_email_digests.days.thu': enabled,
'instructor_email_digests.days.fri': enabled,
'instructor_email_digests.days.sat': enabled,
sun: enabled,
mon: enabled,
tue: enabled,
wed: enabled,
thu: enabled,
fri: enabled,
sat: enabled,
}),
[updateSelectedDays]
);
const selectAll = useCallback(() => setAllTo(true), [setAllTo]);
const selectNone = useCallback(() => setAllTo(false), [setAllTo]);

return (
<Panel
onClose={onClose}
title="Email Notifications"
buttons={<Button variant="primary">Save</Button>}
>
<p className="font-bold">
Receive email notifications when your students annotate.
</p>
<p className="font-bold">Select the days you{"'"}d like your emails:</p>

<div className="flex justify-between px-4">
<div className="flex flex-col gap-1">
{dayNames.map(([day, name]) => (
<span
key={day}
className={classnames(
// The checked icon sets fill from the text color
'text-grey-6'
)}
>
<Checkbox
checked={selectedDays[day]}
onChange={() =>
updateSelectedDays({ [day]: !selectedDays[day] })
}
data-testid={`${day}-checkbox`}
>
<span
className={classnames(
// Override the color set for the checkbox fill
'text-grey-9'
)}
>
{name}
</span>
</Checkbox>
</span>
))}
</div>
<div className="flex items-start gap-2">
<form onSubmit={onSave} method="post">
<Panel
onClose={onClose}
title="Email Notifications"
buttons={
<Button
variant="secondary"
onClick={selectAll}
data-testid="select-all-button"
variant="primary"
type="submit"
disabled={saving}
data-testid="save-button"
>
Select all
</Button>
<Button
variant="secondary"
onClick={selectNone}
data-testid="select-none-button"
>
Select none
Save
</Button>
}
>
<p className="font-bold">
Receive email notifications when your students annotate.
</p>
<p className="font-bold">Select the days you{"'"}d like your emails:</p>

<div className="flex justify-between px-4">
<div className="flex flex-col gap-1">
{dayNames.map(([day, name]) => (
<span
key={day}
className={classnames(
// The checked icon sets fill from the text color
'text-grey-6'
)}
>
<Checkbox
name={day}
checked={selectedDays[day]}
onChange={() =>
updateSelectedDays({ [day]: !selectedDays[day] })
}
data-testid={`${day}-checkbox`}
>
<span
className={classnames(
// Override the color set for the checkbox fill
'text-grey-9'
)}
>
{name}
</span>
</Checkbox>
</span>
))}
</div>
<div className="flex items-start gap-2">
<Button
variant="secondary"
type="button"
onClick={selectAll}
data-testid="select-all-button"
>
Select all
</Button>
<Button
variant="secondary"
type="button"
onClick={selectNone}
data-testid="select-none-button"
>
Select none
</Button>
</div>
</div>
</div>
</Panel>
{result && <Callout status={result.status}>{result.message}</Callout>}
</Panel>
</form>
);
}
4 changes: 2 additions & 2 deletions lms/static/scripts/frontend_apps/components/FilePickerApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export async function loadFilePickerConfig(
throw new Error('Assignment editing config missing');
}

const authToken = config.api.authToken;
const authToken = config.api!.authToken;
const { path, data } = config.editing.getConfig;
const { assignment, filePicker } = await apiCall<Partial<ConfigObject>>({
authToken,
Expand Down Expand Up @@ -166,7 +166,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
},
assignment,
filePicker: { deepLinkingAPI, formAction, formFields, promptForTitle },
} = useConfig(['filePicker']);
} = useConfig(['api', 'filePicker']);

// Currently selected content for assignment.
const [content, setContent] = useState<Content | null>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default function GradingControls({
}: GradingControlsProps) {
const {
api: { authToken, sync: syncAPICallInfo },
} = useConfig();
} = useConfig(['api']);

const clientRPC = useService(ClientRPC);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export default function GroupConfigSelector({
product: {
api: { listGroupSets: listGroupSetsAPI },
},
} = useConfig();
} = useConfig(['api']);

const useGroupSet = groupConfig.useGroupSet;
const groupSet = useGroupSet ? groupConfig.groupSet : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe('AppRoot', () => {
{
config: { mode: 'email-notifications' },
appComponent: 'EmailNotificationsApp',
route: '/email/preferences',
},
{
config: { mode: 'error-dialog' },
Expand All @@ -79,9 +80,9 @@ describe('AppRoot', () => {
config: { mode: 'oauth2-redirect-error' },
appComponent: 'OAuth2RedirectErrorApp',
},
].forEach(({ config, appComponent }) => {
].forEach(({ config, appComponent, route }) => {
it('launches correct app for "mode" config', () => {
navigateTo(`/app/${config.mode}`);
navigateTo(route ?? `/app/${config.mode}`);
const wrapper = renderAppRoot({ config, services: new Map() });
assert.isTrue(wrapper.exists(appComponent));
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { mockImportedComponents } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';

import { Config } from '../../config';
import EmailNotificationsApp from '../EmailNotificationsApp';
import EmailNotificationsApp, { $imports } from '../EmailNotificationsApp';

describe('EmailNotificationsApp', () => {
const emailNotificationsConfig = {
'instructor_email_digests.days.mon': true,
'instructor_email_digests.days.tue': true,
'instructor_email_digests.days.wed': false,
'instructor_email_digests.days.thu': false,
'instructor_email_digests.days.fri': true,
'instructor_email_digests.days.sat': false,
'instructor_email_digests.days.sun': true,
mon: true,
tue: true,
wed: false,
thu: false,
fri: true,
sat: false,
sun: true,
};

beforeEach(() => {
$imports.$mock(mockImportedComponents());
});

afterEach(() => {
$imports.$restore();
});

function createComponent() {
return mount(
<Config.Provider value={{ emailNotifications: emailNotificationsConfig }}>
Expand All @@ -36,8 +45,8 @@ describe('EmailNotificationsApp', () => {
it('allows selected days to be updated', () => {
const wrapper = createComponent();
const newSelectedDays = {
'instructor_email_digests.days.mon': false,
'instructor_email_digests.days.wed': true,
mon: false,
wed: true,
};

wrapper
Expand All @@ -54,4 +63,13 @@ describe('EmailNotificationsApp', () => {
}
);
});

it('when preferences are saved it sets saving to true', () => {
const wrapper = createComponent();

wrapper.find('EmailNotificationsPreferences').props().onSave();
wrapper.update();

assert.isTrue(wrapper.find('EmailNotificationsPreferences').prop('saving'));
});
});
Loading

0 comments on commit 2c0bd3f

Please sign in to comment.