Skip to content

Commit

Permalink
Add toast notifications to server-side operations (#1092)
Browse files Browse the repository at this point in the history
* build: install `react-hot-toast`

* feat: toast notifications on resource save

* feat: customize toast notification for quick registration

* fix: "e-mail" instead of "email"
  • Loading branch information
drahoja9 authored Sep 12, 2024
1 parent f0c3062 commit 4f99604
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 15 deletions.
2 changes: 1 addition & 1 deletion app/account/UserProfileTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const BioSection = ({ model, updating, onChange }: SectionProps) => {
id="contactMail"
type="email"
label="Veřejný kontaktní e-mail:"
saveButtonLabel="Uložit email"
saveButtonLabel="Uložit e-mail"
defaultValue={model?.contactEmail}
disabled={!model || updating}
onSave={(contactEmail) => onChange({ ...model!, contactEmail })}
Expand Down
4 changes: 4 additions & 0 deletions app/events/[slug]/QuickRegistrationButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export const QuickRegistrationButton = ({ event }: { event: Event }) => {
const url = `/events/${event.slug}/registration-status`;
const { model, setModel, updating } = useJSONResource<RegistrationStatus>({
url,
toastOptions: {
onSavePendingMsg: (oldValue) =>
!oldValue?.registered ? "Registruji..." : "Ruším registraci...",
},
});
if (updating || sessionStatus === "loading") {
// Loading session or updating state
Expand Down
3 changes: 3 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Metadata } from "next";
import "./globals.css";

import { SessionProvider } from "components/SessionProvider";
import { Toaster } from "react-hot-toast";

import { MobileNav } from "./MobileNav";
import { DesktopNav } from "./Navigation";
Expand Down Expand Up @@ -36,6 +37,8 @@ export default async function RootLayout({
<SessionProvider>
<NavigationBar />
{children}
{/* It's important to have only a single instance of the `Toaster` in the whole app. */}
<Toaster />
</SessionProvider>
</body>
</html>
Expand Down
110 changes: 96 additions & 14 deletions components/hooks/resource.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,82 @@
import { useEffect, useState } from "react";

import toast from "react-hot-toast";

import { getJSON, patchJSON, postJSON } from "~/src/utils";

type GetMessageCallback<T> = (oldValue: T | undefined, newValue: T) => string;

type ToastOptions<T> = {
onSavePendingMsg?: string | GetMessageCallback<T>;
onSaveMsg?: string | GetMessageCallback<T>;
onSaveErrorMsg?: string | GetMessageCallback<T>;
};

function getMessage<T>(
oldValue: T | undefined,
newValue: T,
callbackOrString: string | GetMessageCallback<T>,
): string {
if (typeof callbackOrString === "function") {
return callbackOrString(oldValue, newValue);
}
return callbackOrString;
}

/**
* Add a toast notification to a promise (loading, on success and on failure).
*
* Toast message can be customized via `options` prop.
*/
async function withToast<T>(
oldValue: T | undefined,
newValue: T,
promise: Promise<void>,
options: ToastOptions<T> = {},
) {
const loadingMsg = getMessage(
oldValue,
newValue,
options.onSavePendingMsg ?? "Ukládám...",
);
const successMsg = getMessage(
oldValue,
newValue,
options.onSaveMsg ?? "Hotovo 🎉",
);
const errorMsg = getMessage(
oldValue,
newValue,
options.onSaveErrorMsg ?? "Nastala chyba. 😕\nZkus to, prosím, později.",
);

// Get rid of all previous toasts, so it's clear what's happening.
toast.dismiss();
let toastId;
// Show loading toast only if the response takes longer than 250ms.
// Otherwise, it will just flicker.
const timeoutId = setTimeout(() => {
toastId = toast.loading(loadingMsg);
}, 250);
try {
await promise;
toast.success(successMsg, {
iconTheme: { primary: "blue", secondary: "white" },
});
} catch {
toast.error(errorMsg);
} finally {
clearTimeout(timeoutId);
if (toastId) {
toast.dismiss(toastId);
}
}
}

export function useResource<Value, Response>(
loadValue: () => Promise<Value>,
saveValue: (_: Value) => Promise<Response>,
toastOptions?: ToastOptions<Value>,
) {
const [model, setModel] = useState<Value | undefined>();
const [updating, setUpdating] = useState(false);
Expand Down Expand Up @@ -33,13 +105,17 @@ export function useResource<Value, Response>(
setUpdating(true);
const oldValue = model;
setModel(newValue);
try {
await saveValue(newValue);
} catch (e) {
// TBD: Set error state?
console.error(e);
setModel(oldValue);
}
const handleRequestPromise = async () => {
try {
await saveValue(newValue);
} catch (e) {
// TBD: Set error state?
console.error(e);
setModel(oldValue);
throw e;
}
};
await withToast(oldValue, newValue, handleRequestPromise(), toastOptions);
setUpdating(false);
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Expand All @@ -49,23 +125,29 @@ export function useResource<Value, Response>(
return { model, updating, setModel: saveModel };
}

export type JSONResourceProps<T> = {
type CommonJSONResourceProps<T> = {
url: string;
decoder?: (_: unknown) => T;
toastOptions?: ToastOptions<T>;
};

export const useJSONResource = <T>({ url, decoder }: JSONResourceProps<T>) =>
useResource(getJSON(url, decoder), postJSON(url));
export type JSONResourceProps<T> = CommonJSONResourceProps<T> & {};

export type PatchedJSONResourceProps<T> = {
url: string;
decoder?: (_: unknown) => T;
export const useJSONResource = <T>({
url,
decoder,
toastOptions,
}: JSONResourceProps<T>) =>
useResource(getJSON(url, decoder), postJSON(url), toastOptions);

export type PatchedJSONResourceProps<T> = CommonJSONResourceProps<T> & {
writeKeys?: (keyof T)[];
};

export const usePatchedJSONResource = <T>({
url,
decoder,
writeKeys,
toastOptions,
}: PatchedJSONResourceProps<T>) =>
useResource(getJSON(url, decoder), patchJSON(url, writeKeys));
useResource(getJSON(url, decoder), patchJSON(url, writeKeys), toastOptions);
38 changes: 38 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"plausible-tracker": "^0.3.9",
"react": "^18",
"react-dom": "^18",
"react-hot-toast": "^2.4.1",
"react-lite-youtube-embed": "^2.4.0",
"react-papaparse": "^4.4.0",
"react-select": "^5.8.0",
Expand Down

0 comments on commit 4f99604

Please sign in to comment.