Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Validation for Filtering #86

Merged
merged 15 commits into from
Oct 27, 2024
9 changes: 8 additions & 1 deletion apps/web/src/app/(pages)/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { Toaster } from "@cooper/ui/toaster";

import HeaderLayout from "~/app/_components/header-layout";

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return <HeaderLayout>{children}</HeaderLayout>;
return (
<HeaderLayout>
{children}
<Toaster />
</HeaderLayout>
);
}
54 changes: 45 additions & 9 deletions apps/web/src/app/(pages)/(dashboard)/roles/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import { z } from "zod";

import type {
import {

Check warning on line 6 in apps/web/src/app/(pages)/(dashboard)/roles/page.tsx

View workflow job for this annotation

GitHub Actions / lint

Imports "ReviewType", "WorkEnvironmentType" and "WorkTermType" are only used as type
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can fix this warning by doing something like this:

import type {
  ReviewType,
  WorkEnvironmentType,
  WorkTermType,
} from "@cooper/db/schema";
import { WorkEnvironment, WorkTerm } from "@cooper/db/schema";

ReviewType,
WorkEnvironment,
WorkEnvironmentType,
WorkTerm,
WorkTermType,
} from "@cooper/db/schema";
import { cn } from "@cooper/ui";
import { useToast } from "@cooper/ui/hooks/use-toast";

import { ReviewCard } from "~/app/_components/reviews/review-card";
import { ReviewCardPreview } from "~/app/_components/reviews/review-card-preview";
Expand All @@ -18,15 +22,48 @@
searchParams,
}: {
searchParams?: {
workTerm?: WorkTermType;
workEnvironment?: WorkEnvironmentType;
cycle?: WorkTermType;
term?: WorkEnvironmentType;
};
}) {
const { toast } = useToast();

const RolesSearchParam = z.object({
cycle: z
.nativeEnum(WorkTerm, {
message: "Invalid cycle type",
})
.optional(),
term: z
.nativeEnum(WorkEnvironment, {
message: "Invalid term type",
})
.optional(),
});

const validationResult = RolesSearchParam.safeParse(searchParams);
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) {
return;
}
if (!validationResult.success) {
toast({
title: "Invalid search params",
description: validationResult.error.issues
.map((issue) => issue.message)
.join(", "),
variant: "destructive",
});
}
}, [toast, mounted]);

Check warning on line 63 in apps/web/src/app/(pages)/(dashboard)/roles/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'validationResult.error.issues' and 'validationResult.success'. Either include them or remove the dependency array

const reviews = api.review.list.useQuery({
options: {
cycle: searchParams?.workTerm,
term: searchParams?.workEnvironment,
},
options: validationResult.success ? validationResult.data : {},
});

const [selectedReview, setSelectedReview] = useState<ReviewType | undefined>(
Expand All @@ -36,7 +73,6 @@
return (
<>
<SearchFilter />
{/* TODO: Loading animations */}
{reviews.data && (
<div className="mb-8 grid h-[70dvh] w-4/5 grid-cols-5 gap-4 lg:w-3/4">
<div className="col-span-2 gap-3 overflow-scroll pr-4">
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit --emitDeclarationOnly false",
"ui-add": "pnpm dlx shadcn-ui add && prettier src --write --list-different"
"ui-add": "pnpm dlx shadcn@latest add && prettier src --write --list-different"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
Expand All @@ -28,6 +28,7 @@
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"class-variance-authority": "^0.7.0",
"cmdk": "1.0.0",
"next-themes": "^0.3.0",
Expand Down
192 changes: 192 additions & 0 deletions packages/ui/src/hooks/use-toast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"use client";

// Inspired by react-hot-toast library
import * as React from "react";

import type { ToastActionElement, ToastProps } from "../toast";

const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;

type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
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<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};

interface State {
toasts: ToasterToast[];
}

const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();

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: ((state: State) => void)[] = [];

let memoryState: State = { toasts: [] };

function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}

type Toast = Omit<ToasterToast, "id">;

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<State>(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 };
Loading