From 9afe159bf2c5f583ddc71be7990238f11e64ed6a Mon Sep 17 00:00:00 2001 From: Derock Date: Mon, 26 Feb 2024 02:40:44 +0100 Subject: [PATCH] feat: animation updates --- .../[serviceId]/domains/DomainsList.tsx | 126 ++++++++++-------- .../[serviceId]/domains/[domainId]/page.tsx | 7 +- .../domains/_components/DomainEntry.tsx | 18 ++- src/components/ui/button.tsx | 18 ++- src/hooks/forms.tsx | 49 ++++--- 5 files changed, 141 insertions(+), 77 deletions(-) diff --git a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/DomainsList.tsx b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/DomainsList.tsx index cea699a..450c262 100644 --- a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/DomainsList.tsx +++ b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/DomainsList.tsx @@ -5,11 +5,12 @@ import { AnimatePresence, motion } from "framer-motion"; import { PlusIcon } from "lucide-react"; import { useEffect, type ReactNode } from "react"; import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; import { uuidv7 } from "uuidv7"; import { z } from "zod"; import { Button } from "~/components/ui/button"; import { Form } from "~/components/ui/form"; -import { FormSubmit } from "~/hooks/forms"; +import { FormSubmit, FormUnsavedChangesIndicator } from "~/hooks/forms"; import { api } from "~/trpc/react"; import { type RouterOutputs } from "~/trpc/shared"; import { useService } from "../_hooks/service"; @@ -48,7 +49,11 @@ export default function DomainsList({ const form = useForm>({ defaultValues: { - domains: service.data?.domains, + domains: service.data?.domains.map((newDomain) => ({ + ...newDomain, + domainId: newDomain.id, + id: undefined, + })), }, resolver: zodResolver(formValidator), @@ -60,18 +65,23 @@ export default function DomainsList({ }); useEffect(() => { - console.log("setting domains", service.data?.domains ?? []); - form.setValue( - "domains", - service.data?.domains.map((d) => ({ ...d, domainId: d.id })) ?? [], - ); - }, [form, service.data?.domains]); + // reset the dirty state + form.resetField("domains", { + defaultValue: + service.data?.domains.map((newDomain) => ({ + ...newDomain, + domainId: newDomain.id, + })) ?? [], + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [service.data]); return (
{ - await Promise.all([ + const results = await Promise.allSettled([ ...data.domains.map((domain) => { if (service.data === undefined) return; @@ -105,60 +115,68 @@ export default function DomainsList({ }) ?? []), ]); + // check if any of the promises failed + const numFailed = results.filter( + (r) => r.status === "rejected", + ).length; + if (numFailed > 0) { + toast.error(`${numFailed} domains failed to update`); + return; + } + // refetch service await service.refetch(); + toast.success("Domains updated"); })} className="flex flex-col gap-4" > - {/* Animations break react-hook-form, no tracking issue yet. */} - {/* */} + +

+ Domains +

-

- Domains -

+ {domainsForm.fields.map((field, index) => ( + + ))} - {domainsForm.fields.map((field, index) => ( - - ))} - - - - - + - {/* */} - - {/*
*/} + + + + +
{children}
diff --git a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/[domainId]/page.tsx b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/[domainId]/page.tsx index 2576ca9..c9aaa87 100644 --- a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/[domainId]/page.tsx +++ b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/[domainId]/page.tsx @@ -23,7 +23,10 @@ export default function DomainPage() { ); return ( -
+
e.preventDefault()} + > } /> -
+ ); } diff --git a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/_components/DomainEntry.tsx b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/_components/DomainEntry.tsx index 30b5609..865191e 100644 --- a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/_components/DomainEntry.tsx +++ b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/_components/DomainEntry.tsx @@ -38,7 +38,22 @@ const DomainEntry = forwardRef< type: "spring", bounce: 0.15, }} - key={field.domainId} + key={field.domainId ?? field.id} + onAnimationComplete={(def) => { + // https://github.com/orgs/react-hook-form/discussions/11379 + // this took me FOREVER to figure out + // basically we need to re-remove the field if it's the last one (and it is exit animation) + + if ( + index === domains.fields.length - 1 && + // check if it's the exit animation + typeof def !== "string" && + "opacity" in def && + def.opacity === 0 + ) { + domains.remove(index); + } + }} >
@@ -94,7 +109,6 @@ const DomainEntry = forwardRef< type="button" icon={TrashIcon} onClick={() => { - console.log("remove domain: ", field.domainId); domains.remove(index); }} /> diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 6753572..4dbda0f 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -61,17 +61,29 @@ const Button = React.forwardRef( const Comp = asChild ? Slot : "button"; const Child = asChild ? "span" : React.Fragment; const iconPadding = children ? " mr-2" : ""; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const disabled = isLoading || props.disabled; + + // override onClick if disabled + if (disabled) { + props.onClick = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + } + + // handle disabled ourselves, since disabled also hides it from screen readers and we don't want that + delete props.disabled; return ( {isLoading && } diff --git a/src/hooks/forms.tsx b/src/hooks/forms.tsx index 76dd3c8..d5ef00a 100644 --- a/src/hooks/forms.tsx +++ b/src/hooks/forms.tsx @@ -1,7 +1,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { AnimatePresence, motion } from "framer-motion"; import { type ReactNode } from "react"; import { useForm as useFormHook, + useFormState, type Control, type ControllerProps, type FieldPath, @@ -85,7 +87,7 @@ export function SimpleFormField< ( + render={({ field, fieldState, formState }) => ( {props.friendlyName} @@ -95,11 +97,8 @@ export function SimpleFormField< {render({ //@ts-expect-error i cant type this any better field, - formState: props.control._formState, - fieldState: props.control.getFieldState( - props.name, - props.control._formState, - ), + fieldState, + formState, })} {props.description && ( @@ -127,29 +126,47 @@ export function FormSubmit({ + {/* unsaved changes indicator */} {!hideUnsavedChangesIndicator && ( - + )}
); } export function FormUnsavedChangesIndicator({ - form, + form: control, className, }: { // eslint-disable-next-line @typescript-eslint/no-explicit-any - form: UseFormReturn>; + form?: UseFormReturn>["control"]; className?: string; }) { + const form = useFormState({ control }); + return ( -

- You have unsaved changes! -

+ + {form.isDirty && ( + + You have unsaved changes! + + )} + ); }