Notifications: inline error message on notifications saving error (#10288)

* basic sync setup

* formatting

* get loudest value for synced rules

* more types

* test synced rules in notifications settings

* type fixes

* noimplicitany fixes

* remove debug

* tidying

* extract updatePushRuleActions fn to utils

* extract update synced rules

* just synchronise in one place?

* monitor account data changes AND trigger changes sync in notifications form

* lint

* setup LoggedInView test with enough mocks

* test rule syncing in LoggedInView

* strict fixes

* more comments

* one more comment

* add error variant for caption component

* tests for new error message

* tweak styles

* noImplicitAny

* revert out of date prettier changes to unrelated files

* limit inline message to radios only, tests

* strict fix
pull/28788/head^2
Kerry 2023-03-14 10:59:04 +13:00 committed by GitHub
parent 72404d7216
commit 9f66082486
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 233 additions and 33 deletions

View File

@ -17,4 +17,8 @@ limitations under the License.
.mx_Caption {
font-size: $font-12px;
color: $secondary-content;
&.mx_Caption_error {
color: $alert;
}
}

View File

@ -61,6 +61,14 @@ limitations under the License.
font-size: $font-12px;
font-weight: $font-semi-bold;
}
.mx_UserNotifSettings_gridRowError {
// occupy full row
grid-column: 1/-1;
justify-self: start;
padding-right: 30%;
// collapse half of the grid-gap
margin-top: -$spacing-4;
}
.mx_UserNotifSettings {
color: $primary-content; /* override from default settings page styles */

View File

@ -47,6 +47,7 @@ import {
updateExistingPushRulesWithActions,
updatePushRuleActions,
} from "../../../utils/pushRules/updatePushRuleActions";
import { Caption } from "../typography/Caption";
// TODO: this "view" component still has far too much application logic in it,
// which should be factored out to other files.
@ -55,7 +56,10 @@ enum Phase {
Loading = "loading",
Ready = "ready",
Persisting = "persisting", // technically a meta-state for Ready, but whatever
// unrecoverable error - eg can't load push rules
Error = "error",
// error saving individual rule
SavingError = "savingError",
}
enum RuleClass {
@ -121,6 +125,8 @@ interface IState {
audioNotifications: boolean;
clearingNotifications: boolean;
ruleIdsWithError: Record<RuleId | string, boolean>;
}
const findInDefaultRules = (
ruleId: RuleId | string,
@ -194,6 +200,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"),
audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"),
clearingNotifications: false,
ruleIdsWithError: {},
};
this.settingWatchers = [
@ -243,13 +250,9 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
).reduce((p, c) => Object.assign(c, p), {});
this.setState<
keyof Omit<
keyof Pick<
IState,
| "deviceNotificationsEnabled"
| "desktopNotifications"
| "desktopShowBody"
| "audioNotifications"
| "clearingNotifications"
"phase" | "vectorKeywordRuleInfo" | "vectorPushRules" | "pushers" | "threepids" | "masterPushRule"
>
>({
...newState,
@ -393,8 +396,8 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
private onMasterRuleChanged = async (checked: boolean): Promise<void> => {
this.setState({ phase: Phase.Persisting });
const masterRule = this.state.masterPushRule!;
try {
const masterRule = this.state.masterPushRule!;
await MatrixClientPeg.get().setPushRuleEnabled("global", masterRule.kind, masterRule.rule_id, !checked);
await this.refreshFromServer();
} catch (e) {
@ -404,6 +407,13 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
}
};
private setSavingError = (ruleId: RuleId | string): void => {
this.setState(({ ruleIdsWithError }) => ({
phase: Phase.SavingError,
ruleIdsWithError: { ...ruleIdsWithError, [ruleId]: true },
}));
};
private updateDeviceNotifications = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, checked);
};
@ -455,11 +465,18 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
};
private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState): Promise<void> => {
this.setState({ phase: Phase.Persisting });
this.setState(({ ruleIdsWithError }) => ({
phase: Phase.Persisting,
ruleIdsWithError: { ...ruleIdsWithError, [rule.ruleId]: false },
}));
try {
const cli = MatrixClientPeg.get();
if (rule.ruleId === KEYWORD_RULE_ID) {
// should not encounter this
if (!this.state.vectorKeywordRuleInfo) {
throw new Error("Notification data is incomplete.");
}
// Update all the keywords
for (const rule of this.state.vectorKeywordRuleInfo.rules) {
let enabled: boolean | undefined;
@ -505,9 +522,8 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
this.setSavingError(rule.ruleId);
logger.error("Error updating push rule:", e);
this.showSaveError();
}
};
@ -618,14 +634,16 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
private renderTopSection(): JSX.Element {
const masterSwitch = (
<LabelledToggleSwitch
data-testid="notif-master-switch"
value={!this.isInhibited}
label={_t("Enable notifications for this account")}
caption={_t("Turn off to disable notifications on all your devices and sessions")}
onChange={this.onMasterRuleChanged}
disabled={this.state.phase === Phase.Persisting}
/>
<>
<LabelledToggleSwitch
data-testid="notif-master-switch"
value={!this.isInhibited}
label={_t("Enable notifications for this account")}
caption={_t("Turn off to disable notifications on all your devices and sessions")}
onChange={this.onMasterRuleChanged}
disabled={this.state.phase === Phase.Persisting}
/>
</>
);
// If all the rules are inhibited, don't show anything.
@ -639,7 +657,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
<LabelledToggleSwitch
data-testid="notif-email-switch"
key={e.address}
value={this.state.pushers.some((p) => p.kind === "email" && p.pushkey === e.address)}
value={!!this.state.pushers?.some((p) => p.kind === "email" && p.pushkey === e.address)}
label={_t("Enable email notifications for %(email)s", { email: e.address })}
onChange={this.onEmailNotificationsChanged.bind(this, e.address)}
disabled={this.state.phase === Phase.Persisting}
@ -768,6 +786,15 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
{makeRadio(r, VectorState.Off)}
{makeRadio(r, VectorState.On)}
{makeRadio(r, VectorState.Loud)}
{this.state.ruleIdsWithError[r.ruleId] && (
<div className="mx_UserNotifSettings_gridRowError">
<Caption isError>
{_t(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
)}
</Caption>
</div>
)}
</fieldset>
));

View File

@ -14,15 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from "classnames";
import React, { HTMLAttributes } from "react";
interface Props extends Omit<HTMLAttributes<HTMLSpanElement>, "className"> {
children: React.ReactNode;
isError?: boolean;
}
export const Caption: React.FC<Props> = ({ children, ...rest }) => {
export const Caption: React.FC<Props> = ({ children, isError, ...rest }) => {
return (
<span className="mx_Caption" {...rest}>
<span
className={classNames("mx_Caption", {
mx_Caption_error: isError,
})}
{...rest}
>
{children}
</span>
);

View File

@ -1443,6 +1443,7 @@
"On": "On",
"Off": "Off",
"Noisy": "Noisy",
"An error occurred when updating your notification preferences. Please try to toggle your option again.": "An error occurred when updating your notification preferences. Please try to toggle your option again.",
"Global": "Global",
"Mentions & keywords": "Mentions & keywords",
"Notification targets": "Notification targets",

View File

@ -28,7 +28,7 @@ import {
IPushRuleCondition,
} from "matrix-js-sdk/src/matrix";
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
import { act, fireEvent, getByTestId, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, getByTestId, render, screen, waitFor, within } from "@testing-library/react";
import Notifications from "../../../../src/components/views/settings/Notifications";
import SettingsStore from "../../../../src/settings/SettingsStore";
@ -90,6 +90,15 @@ const encryptedGroupRule: IPushRule = {
default: true,
enabled: true,
} as IPushRule;
const bananaRule = {
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }],
pattern: "banana",
rule_id: "banana",
default: false,
enabled: true,
} as IPushRule;
const pushRules: IPushRules = {
global: {
underride: [
@ -130,13 +139,7 @@ const pushRules: IPushRules = {
},
],
content: [
{
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }],
pattern: "banana",
rule_id: "banana",
default: false,
enabled: true,
},
bananaRule,
{
actions: [
PushRuleActionName.Notify,
@ -272,7 +275,7 @@ describe("<Notifications />", () => {
mockClient.getPushers.mockClear().mockResolvedValue({ pushers: [] });
mockClient.getThreePids.mockClear().mockResolvedValue({ threepids: [] });
mockClient.setPusher.mockClear().mockResolvedValue({});
mockClient.setPushRuleActions.mockClear().mockResolvedValue({});
mockClient.setPushRuleActions.mockReset().mockResolvedValue({});
mockClient.pushRules = pushRules;
});
@ -395,6 +398,18 @@ describe("<Notifications />", () => {
});
});
it("toggles master switch correctly", async () => {
await getComponentAndWait();
// master switch is on
expect(screen.getByLabelText("Enable notifications for this account")).toBeChecked();
fireEvent.click(screen.getByLabelText("Enable notifications for this account"));
await flushPromises();
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith("global", "override", ".m.rule.master", true);
});
it("toggles and sets settings correctly", async () => {
await getComponentAndWait();
let audioNotifsToggle!: HTMLDivElement;
@ -473,6 +488,73 @@ describe("<Notifications />", () => {
);
});
it("adds an error message when updating notification level fails", async () => {
await getComponentAndWait();
const section = "vector_global";
const error = new Error("oups");
mockClient.setPushRuleEnabled.mockRejectedValue(error);
// oneToOneRule is set to 'on'
// and is kind: 'underride'
const offToggle = screen.getByTestId(section + oneToOneRule.rule_id).querySelector('input[type="radio"]')!;
fireEvent.click(offToggle);
await flushPromises();
// error message attached to oneToOne rule
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
// old value still shown as selected
expect(within(oneToOneRuleElement).getByLabelText("On")).toBeChecked();
expect(
within(oneToOneRuleElement).getByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).toBeInTheDocument();
});
it("clears error message for notification rule on retry", async () => {
await getComponentAndWait();
const section = "vector_global";
const error = new Error("oups");
mockClient.setPushRuleEnabled.mockRejectedValueOnce(error).mockResolvedValue({});
// oneToOneRule is set to 'on'
// and is kind: 'underride'
const offToggle = screen.getByTestId(section + oneToOneRule.rule_id).querySelector('input[type="radio"]')!;
fireEvent.click(offToggle);
await flushPromises();
// error message attached to oneToOne rule
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
expect(
within(oneToOneRuleElement).getByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).toBeInTheDocument();
// retry
fireEvent.click(offToggle);
// error removed as soon as we start request
expect(
within(oneToOneRuleElement).queryByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).not.toBeInTheDocument();
await flushPromises();
// no error after after successful change
expect(
within(oneToOneRuleElement).queryByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).not.toBeInTheDocument();
});
describe("synced rules", () => {
const pollStartOneToOne = {
conditions: [
@ -554,7 +636,11 @@ describe("<Notifications />", () => {
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(1);
// no error
expect(screen.queryByTestId("error-message")).not.toBeInTheDocument();
expect(
within(oneToOneRuleElement).queryByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).not.toBeInTheDocument();
});
it("updates synced rules when they exist for user", async () => {
@ -585,7 +671,11 @@ describe("<Notifications />", () => {
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(2);
// no error
expect(screen.queryByTestId("error-message")).not.toBeInTheDocument();
expect(
within(oneToOneRuleElement).queryByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).not.toBeInTheDocument();
});
it("does not update synced rules when main rule update fails", async () => {
@ -610,7 +700,11 @@ describe("<Notifications />", () => {
// only called for parent rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("error-message")).toBeInTheDocument();
expect(
within(oneToOneRuleElement).getByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).toBeInTheDocument();
});
it("sets the UI toggle to rule value when no synced rule exist for the user", async () => {
@ -664,6 +758,47 @@ describe("<Notifications />", () => {
});
});
describe("keywords", () => {
// keywords rule is not a real rule, but controls actions on keywords content rules
const keywordsRuleId = "_keywords";
it("updates individual keywords content rules when keywords rule is toggled", async () => {
await getComponentAndWait();
const section = "vector_mentions";
fireEvent.click(within(screen.getByTestId(section + keywordsRuleId)).getByLabelText("Off"));
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith("global", "content", bananaRule.rule_id, false);
fireEvent.click(within(screen.getByTestId(section + keywordsRuleId)).getByLabelText("Noisy"));
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"content",
bananaRule.rule_id,
StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
);
});
it("renders an error when updating keywords fails", async () => {
await getComponentAndWait();
const section = "vector_mentions";
mockClient.setPushRuleEnabled.mockRejectedValueOnce("oups");
fireEvent.click(within(screen.getByTestId(section + keywordsRuleId)).getByLabelText("Off"));
await flushPromises();
const rule = screen.getByTestId(section + keywordsRuleId);
expect(
within(rule).getByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).toBeInTheDocument();
});
});
describe("clear all notifications", () => {
it("clears all notifications", async () => {
const room = new Room("room123", mockClient, "@alice:example.org");

View File

@ -31,6 +31,11 @@ describe("<Caption />", () => {
expect({ container }).toMatchSnapshot();
});
it("renders an error message", () => {
const { container } = render(getComponent({ isError: true }));
expect({ container }).toMatchSnapshot();
});
it("renders react children", () => {
const children = (
<>

View File

@ -1,5 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Caption /> renders an error message 1`] = `
{
"container": <div>
<span
class="mx_Caption mx_Caption_error"
data-testid="test test id"
>
test
</span>
</div>,
}
`;
exports[`<Caption /> renders plain text children 1`] = `
{
"container": <div>