From 6adae61e18cfa0c76bdcb0b348f31771c07dfda8 Mon Sep 17 00:00:00 2001 From: Vin Bui Date: Thu, 12 Dec 2024 08:59:25 -0500 Subject: [PATCH] WEB-27: Add error state --- actions/upload.ts | 19 -- package.json | 1 + src/app/layout.tsx | 8 +- .../announcement/announcementForm.tsx | 2 + src/components/authGuard/authGuard.tsx | 15 +- src/components/landing/landing.tsx | 12 +- src/components/system/errorToast.tsx | 10 + src/components/ui/toast.tsx | 112 +++++++++++ src/components/ui/toaster.tsx | 26 +++ src/hooks/use-toast.ts | 189 ++++++++++++++++++ tailwind.config.ts | 2 +- tsconfig.json | 2 +- yarn.lock | 18 ++ 13 files changed, 381 insertions(+), 35 deletions(-) delete mode 100644 actions/upload.ts create mode 100644 src/components/system/errorToast.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/hooks/use-toast.ts diff --git a/actions/upload.ts b/actions/upload.ts deleted file mode 100644 index 2746473..0000000 --- a/actions/upload.ts +++ /dev/null @@ -1,19 +0,0 @@ -'use server' - -import { put } from '@vercel/blob' -import { revalidatePath } from 'next/cache' - -export async function uploadFile(formData: FormData) { - try { - const file = formData.get('file') as File - const blob = await put(file.name, file, { - access: 'public', - }) - - revalidatePath('/') - return { success: true, url: blob.url } - } catch (error) { - return { success: false, error: 'Failed to upload file' } - } -} - diff --git a/package.json b/package.json index 02c0bd2..9ccbc17 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", "@tanstack/react-query": "^5.51.5", "axios": "^1.7.2", "class-variance-authority": "^0.7.1", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e8700b9..ac1a395 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ -import "./globals.css"; +import { Toaster } from "@/components/ui/toaster"; import localFont from "next/font/local"; +import "./globals.css"; // Fonts const sfProDisplay = localFont({ @@ -30,7 +31,10 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + + ); } diff --git a/src/components/announcement/announcementForm.tsx b/src/components/announcement/announcementForm.tsx index 8d0c51a..72cb27d 100644 --- a/src/components/announcement/announcementForm.tsx +++ b/src/components/announcement/announcementForm.tsx @@ -5,6 +5,7 @@ import { useUserStore } from "@/stores/useUserStore"; import { addDays } from "date-fns"; import { useMemo, useState } from "react"; import ButtonPrimary1 from "../system/button/buttonPrimary1"; +import errorToast from "../system/errorToast"; import InputDatePicker from "../system/input/inputDatePicker"; import InputMultiSelect from "../system/input/inputMultiselect"; import InputText from "../system/input/inputText"; @@ -64,6 +65,7 @@ export default function AnnouncementForm({ onClose, editingAnnouncement }: Props console.log("Scheduling", announcement); } catch (err) { console.error(err); + errorToast(); } }; diff --git a/src/components/authGuard/authGuard.tsx b/src/components/authGuard/authGuard.tsx index 0496906..64a34a7 100644 --- a/src/components/authGuard/authGuard.tsx +++ b/src/components/authGuard/authGuard.tsx @@ -1,11 +1,12 @@ "use client"; -import { useUserStore } from "@/stores/useUserStore"; -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import { User } from "@/models/user"; import ApiClient from "@/services/apiClient"; +import { useUserStore } from "@/stores/useUserStore"; import { Constants } from "@/utils/constants"; -import { User } from "@/models/user"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import errorToast from "../system/errorToast"; export default function AuthGuard({ children }: { children: React.ReactNode }) { const apiClient = ApiClient.createInstance(); @@ -32,10 +33,10 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) { ApiClient.setAuthToken(apiClient, user.idToken); const userData = await ApiClient.post(apiClient, "/users/login"); setUser({ ...userData, idToken: user.idToken }); - } catch (error) { + } catch (err) { // Token has expired or unauthorized - // TODO: Show error toast - console.error(error); + console.error(err); + errorToast(); setUser(undefined); router.push(Constants.pagePath.default); } diff --git a/src/components/landing/landing.tsx b/src/components/landing/landing.tsx index 3df2232..3beed85 100644 --- a/src/components/landing/landing.tsx +++ b/src/components/landing/landing.tsx @@ -1,19 +1,20 @@ "use client"; +import Footer from "@/components/common/footer"; +import NavBar from "@/components/common/navBar"; +import PageHeader from "@/components/common/pageHeader"; import LandingActiveSection from "@/components/landing/landingActiveSection"; import LandingCreateAnnouncement from "@/components/landing/landingCreateAnnouncement"; -import Footer from "@/components/common/footer"; import LandingPastSection from "@/components/landing/landingPastSection"; import LandingUpcomingSection from "@/components/landing/landingUpcomingSection"; import { Announcement } from "@/models/announcement"; -import PageHeader from "@/components/common/pageHeader"; -import NavBar from "@/components/common/navBar"; -import { useUserStore } from "@/stores/useUserStore"; import ApiClient from "@/services/apiClient"; -import { useQuery } from "@tanstack/react-query"; +import { useUserStore } from "@/stores/useUserStore"; import { Constants } from "@/utils/constants"; +import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import AnnouncementForm from "../announcement/announcementForm"; +import errorToast from "../system/errorToast"; export default function Landing() { const apiClient = ApiClient.createInstance(); @@ -34,6 +35,7 @@ export default function Landing() { return await ApiClient.get(apiClient, "/announcements", { params: { debug } }); } catch (err) { console.error(err); + errorToast(); return []; } }; diff --git a/src/components/system/errorToast.tsx b/src/components/system/errorToast.tsx new file mode 100644 index 0000000..bc28d81 --- /dev/null +++ b/src/components/system/errorToast.tsx @@ -0,0 +1,10 @@ +import { toast } from "@/hooks/use-toast"; + +export default function errorToast() { + toast({ + variant: "destructive", + duration: 5 * 1000, + title: "Oops! Something went wrong.", + description: "We're having trouble completing your request. Please try again later.", + }); +} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..8e2e168 --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; +import * as React from "react"; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-5 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, variant, ...props }, ref) => { + return ; +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef; + +type ToastActionElement = React.ReactElement; + +export { + Toast, + ToastAction, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, + type ToastActionElement, + type ToastProps, +}; diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..90a9f29 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"; +import { useToast } from "@/hooks/use-toast"; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && {description}} +
+ {action} + +
+ ); + })} + +
+ ); +} diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000..525dd71 --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,189 @@ +"use client"; + +// Inspired by react-hot-toast library +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); +} + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { toast, useToast }; diff --git a/tailwind.config.ts b/tailwind.config.ts index 84c8f1a..842febc 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -115,7 +115,7 @@ const config: Config = { foreground: "hsl(var(--accent-foreground))", }, destructive: { - DEFAULT: "hsl(var(--destructive))", + DEFAULT: "#CA4238", foreground: "hsl(var(--destructive-foreground))", }, border: "hsl(var(--border))", diff --git a/tsconfig.json b/tsconfig.json index f48e7ee..183b7d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,4 +37,4 @@ "exclude": [ "node_modules" ] -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 83172de..b5c8a6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1395,6 +1395,24 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.0" +"@radix-ui/react-toast@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.2.2.tgz#fdd8ed0b80f47d6631dfd90278fee6debc06bf33" + integrity sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-collection" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.1" + "@radix-ui/react-portal" "1.1.2" + "@radix-ui/react-presence" "1.1.1" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-visually-hidden" "1.1.0" + "@radix-ui/react-use-callback-ref@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"