From 4e7b4a0238ac6a0533e704e24a3d08b404dfdb57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Efe=20Ak=C3=A7a?= Date: Wed, 15 May 2024 14:42:17 +0300 Subject: [PATCH] feat(frontend): handle errors and show toast --- frontend/package.json | 11 +- frontend/src/App.tsx | 2 + frontend/src/components/Comment.tsx | 1 - frontend/src/components/ui/toast.tsx | 127 ++++++++++++ frontend/src/components/ui/toaster.tsx | 33 +++ frontend/src/components/ui/use-toast.ts | 189 ++++++++++++++++++ .../src/services/api/semanticBrowseFetcher.ts | 25 +-- frontend/src/services/auth.tsx | 2 + frontend/src/services/query-client.ts | 36 +++- frontend/yarn.lock | 52 +++++ 10 files changed, 455 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/ui/toast.tsx create mode 100644 frontend/src/components/ui/toaster.tsx create mode 100644 frontend/src/components/ui/use-toast.ts diff --git a/frontend/package.json b/frontend/package.json index c6715f2a..1d743db7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.35.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -77,14 +78,8 @@ "pre-commit": "lint-staged && tsc" }, "lint-staged": { - "*/**/*.{js,jsx,ts,tsx}": [ - "prettier --write", - "eslint --fix", - "eslint" - ], - "*/**/*.{json,css,md}": [ - "prettier --write" - ] + "*/**/*.{js,jsx,ts,tsx}": ["prettier --write", "yarn lint"], + "*/**/*.{json,css,md}": ["prettier --write"] }, "packageManager": "yarn@4.1.1" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 702a8223..485d03bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,12 +3,14 @@ import { routeConfig } from "./routes"; import { FullscreenLoading } from "./components/FullscreenLoading"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./services/query-client"; +import { Toaster } from "./components/ui/toaster"; const router = createBrowserRouter(routeConfig); function App() { return ( + } /> ); diff --git a/frontend/src/components/Comment.tsx b/frontend/src/components/Comment.tsx index 08f59b03..210077b9 100644 --- a/frontend/src/components/Comment.tsx +++ b/frontend/src/components/Comment.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { UserSummary } from "@/services/api/semanticBrowseSchemas"; diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx new file mode 100644 index 00000000..2bc23c1f --- /dev/null +++ b/frontend/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +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-4 overflow-hidden rounded-md border p-6 pr-8 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 { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx new file mode 100644 index 00000000..5ff57090 --- /dev/null +++ b/frontend/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast"; +import { useToast } from "@/components/ui/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/frontend/src/components/ui/use-toast.ts b/frontend/src/components/ui/use-toast.ts new file mode 100644 index 00000000..5a03b78a --- /dev/null +++ b/frontend/src/components/ui/use-toast.ts @@ -0,0 +1,189 @@ +// 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 { useToast, toast }; diff --git a/frontend/src/services/api/semanticBrowseFetcher.ts b/frontend/src/services/api/semanticBrowseFetcher.ts index 1394f58c..056ef074 100644 --- a/frontend/src/services/api/semanticBrowseFetcher.ts +++ b/frontend/src/services/api/semanticBrowseFetcher.ts @@ -165,33 +165,34 @@ const resolveUrl = ( export const errorSchema = z.object({ status: z.number().or(z.literal("unknown")), - payload: z - .object({ - status: z.number(), - errors: z.array( + payload: z.object({ + status: z.number(), + errors: z + .array( z.object({ field: z.string().optional(), message: z.string(), }), - ), - }) - .optional(), - message: z.string().optional(), + ) + .optional(), + message: z.string().optional(), + }), }); export const renderError = ( - error: ErrorWrapper<{ status: unknown; payload: ErrorResponseObject }>, + unknownError: unknown, excludeFieldErrors: boolean = false, ): string => { - if (!errorSchema.safeParse(error).success) { + if (!errorSchema.safeParse(unknownError).success) { return "Unknown error"; } + const error = errorSchema.parse(unknownError); if (!("errors" in error.payload)) { return error.payload?.["message"] ?? "Unknown error"; } const errors = excludeFieldErrors - ? error.payload.errors.filter((e) => !e.field) - : error.payload.errors; + ? error.payload.errors!.filter((e) => !e.field) + : error.payload.errors!; const fieldErrors = errors .filter((e) => !!e.field) diff --git a/frontend/src/services/auth.tsx b/frontend/src/services/auth.tsx index f3a017c4..b3241c25 100644 --- a/frontend/src/services/auth.tsx +++ b/frontend/src/services/auth.tsx @@ -6,6 +6,7 @@ interface AuthState { /* null if logged out */ token: string | null; setToken: (token: string | null) => void; + logout: () => void; } const useAuthStore = create()( @@ -13,6 +14,7 @@ const useAuthStore = create()( (set) => ({ token: null, setToken: (token) => set({ token }), + logout: () => set({ token: null }), }), { name: "auth-storage", diff --git a/frontend/src/services/query-client.ts b/frontend/src/services/query-client.ts index 6c7b9ded..954dc5c3 100644 --- a/frontend/src/services/query-client.ts +++ b/frontend/src/services/query-client.ts @@ -1,3 +1,35 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryCache, QueryClient } from "@tanstack/react-query"; +import useAuthStore from "./auth"; +import { toast } from "@/components/ui/use-toast"; +import { renderError } from "./api/semanticBrowseFetcher"; -export const queryClient = new QueryClient(); +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + }, + }, + queryCache: new QueryCache({ + onError: (error) => { + if ("status" in error && error.status === 401) { + useAuthStore.getState().logout(); + toast({ + title: "Session expired", + description: "Please log in again", + variant: "destructive", + }); + + // navigate to /login + window.location.href = + "/login?from=" + + encodeURIComponent(window.location.pathname + window.location.search); + } else { + toast({ + title: "An error occurred", + description: renderError(error), + variant: "destructive", + }); + } + }, + }), +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 2c0d9f1b..ccee936d 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1379,6 +1379,37 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-toast@npm:^1.1.5": + version: 1.1.5 + resolution: "@radix-ui/react-toast@npm:1.1.5" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-collection": "npm:1.0.3" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-dismissable-layer": "npm:1.0.5" + "@radix-ui/react-portal": "npm:1.0.4" + "@radix-ui/react-presence": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" + "@radix-ui/react-use-layout-effect": "npm:1.0.1" + "@radix-ui/react-visually-hidden": "npm:1.0.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/1263f1fd4b53f563c0eace84582ff0c82b515100230e35a17b95c44ad420a4e9c156be28787398d3a455d699292dde4dd23311e1d8a0c23ec36931ff6e5a6935 + languageName: node + linkType: hard + "@radix-ui/react-use-callback-ref@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-callback-ref@npm:1.0.1" @@ -1473,6 +1504,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-visually-hidden@npm:1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-visually-hidden@npm:1.0.3" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-primitive": "npm:1.0.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/0cbc12c2156b3fa0e40090cafd8525ce84c16a6b5a038a8e8fc7cbb16ed6da9ab369593962c57a18c41a16ec8713e0195c68ea34072ef1ca254ed4d4c0770bb4 + languageName: node + linkType: hard + "@radix-ui/rect@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/rect@npm:1.0.1" @@ -4304,6 +4355,7 @@ __metadata: "@radix-ui/react-popover": "npm:^1.0.7" "@radix-ui/react-slot": "npm:^1.0.2" "@radix-ui/react-tabs": "npm:^1.0.4" + "@radix-ui/react-toast": "npm:^1.1.5" "@tanstack/react-query": "npm:^5.35.1" "@testing-library/jest-dom": "npm:^6.4.2" "@testing-library/react": "npm:^15.0.5"