feat: animation updates

This commit is contained in:
Derock 2024-02-26 02:40:44 +01:00
parent f067a01e47
commit 9afe159bf2
No known key found for this signature in database
5 changed files with 141 additions and 77 deletions

View file

@ -5,11 +5,12 @@ import { AnimatePresence, motion } from "framer-motion";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { useEffect, type ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { uuidv7 } from "uuidv7"; import { uuidv7 } from "uuidv7";
import { z } from "zod"; import { z } from "zod";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Form } from "~/components/ui/form"; import { Form } from "~/components/ui/form";
import { FormSubmit } from "~/hooks/forms"; import { FormSubmit, FormUnsavedChangesIndicator } from "~/hooks/forms";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { type RouterOutputs } from "~/trpc/shared"; import { type RouterOutputs } from "~/trpc/shared";
import { useService } from "../_hooks/service"; import { useService } from "../_hooks/service";
@ -48,7 +49,11 @@ export default function DomainsList({
const form = useForm<z.infer<typeof formValidator>>({ const form = useForm<z.infer<typeof formValidator>>({
defaultValues: { defaultValues: {
domains: service.data?.domains, domains: service.data?.domains.map((newDomain) => ({
...newDomain,
domainId: newDomain.id,
id: undefined,
})),
}, },
resolver: zodResolver(formValidator), resolver: zodResolver(formValidator),
@ -60,18 +65,23 @@ export default function DomainsList({
}); });
useEffect(() => { useEffect(() => {
console.log("setting domains", service.data?.domains ?? []); // reset the dirty state
form.setValue( form.resetField("domains", {
"domains", defaultValue:
service.data?.domains.map((d) => ({ ...d, domainId: d.id })) ?? [], service.data?.domains.map((newDomain) => ({
); ...newDomain,
}, [form, service.data?.domains]); domainId: newDomain.id,
})) ?? [],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [service.data]);
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(async (data) => { onSubmit={form.handleSubmit(async (data) => {
await Promise.all([ const results = await Promise.allSettled([
...data.domains.map((domain) => { ...data.domains.map((domain) => {
if (service.data === undefined) return; if (service.data === undefined) return;
@ -105,14 +115,22 @@ 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 // refetch service
await service.refetch(); await service.refetch();
toast.success("Domains updated");
})} })}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
{/* Animations break react-hook-form, no tracking issue yet. */} <AnimatePresence mode="sync" initial={false}>
{/* <AnimatePresence mode="sync"> */}
<h1 key="title" className="col-span-2"> <h1 key="title" className="col-span-2">
Domains Domains
</h1> </h1>
@ -121,7 +139,7 @@ export default function DomainsList({
<DomainEntry <DomainEntry
field={field} field={field}
index={index} index={index}
key={field.id} key={field.domainId ?? field.id}
domains={domainsForm} domains={domainsForm}
/> />
))} ))}
@ -143,7 +161,7 @@ export default function DomainsList({
icon={PlusIcon} icon={PlusIcon}
onClick={() => { onClick={() => {
const domain = { const domain = {
domainId: undefined, domainId: uuidv7(),
domain: uuidv7().split("-").at(-1) + ".example.com", domain: uuidv7().split("-").at(-1) + ".example.com",
forceSSL: false, forceSSL: false,
https: false, https: false,
@ -156,9 +174,9 @@ export default function DomainsList({
Add Domain Add Domain
</Button> </Button>
{/* <FormUnsavedChangesIndicator form={form} /> */} <FormUnsavedChangesIndicator />
</motion.div> </motion.div>
{/* </AnimatePresence> */} </AnimatePresence>
<AnimatePresence>{children}</AnimatePresence> <AnimatePresence>{children}</AnimatePresence>
</form> </form>

View file

@ -23,7 +23,10 @@ export default function DomainPage() {
); );
return ( return (
<div className="flex flex-col gap-4 pt-4"> <form
className="flex flex-col gap-4 pt-4"
onSubmit={(e) => e.preventDefault()}
>
<SimpleFormField <SimpleFormField
control={form.control} control={form.control}
friendlyName="Force HTTPS" friendlyName="Force HTTPS"
@ -37,6 +40,6 @@ export default function DomainPage() {
name={`domains.${index}.forceSSL`} name={`domains.${index}.forceSSL`}
render={({ field }) => <Switch {...field} className="my-4 block" />} render={({ field }) => <Switch {...field} className="my-4 block" />}
/> />
</div> </form>
); );
} }

View file

@ -38,7 +38,22 @@ const DomainEntry = forwardRef<
type: "spring", type: "spring",
bounce: 0.15, 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);
}
}}
> >
<Card className="p-4"> <Card className="p-4">
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
@ -94,7 +109,6 @@ const DomainEntry = forwardRef<
type="button" type="button"
icon={TrashIcon} icon={TrashIcon}
onClick={() => { onClick={() => {
console.log("remove domain: ", field.domainId);
domains.remove(index); domains.remove(index);
}} }}
/> />

View file

@ -61,17 +61,29 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
const Child = asChild ? "span" : React.Fragment; const Child = asChild ? "span" : React.Fragment;
const iconPadding = children ? " mr-2" : ""; 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 ( return (
<Comp <Comp
className={cn( className={cn(
buttonVariants({ variant, size, className }), buttonVariants({ variant, size, className }),
isLoading && "cursor-not-allowed brightness-75 filter", disabled && "!cursor-not-allowed brightness-75 filter",
)} )}
aria-disabled={disabled}
ref={ref} ref={ref}
{...props} {...props}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
disabled={isLoading || props.disabled}
> >
<Child> <Child>
{isLoading && <CgSpinner className={"animate-spin" + iconPadding} />} {isLoading && <CgSpinner className={"animate-spin" + iconPadding} />}

View file

@ -1,7 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { import {
useForm as useFormHook, useForm as useFormHook,
useFormState,
type Control, type Control,
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
@ -85,7 +87,7 @@ export function SimpleFormField<
<UIFormField <UIFormField
control={props.control} control={props.control}
name={props.name} name={props.name}
render={({ field }) => ( render={({ field, fieldState, formState }) => (
<FormItem className={props.className}> <FormItem className={props.className}>
<FormLabel> <FormLabel>
{props.friendlyName} {props.friendlyName}
@ -95,11 +97,8 @@ export function SimpleFormField<
{render({ {render({
//@ts-expect-error i cant type this any better //@ts-expect-error i cant type this any better
field, field,
formState: props.control._formState, fieldState,
fieldState: props.control.getFieldState( formState,
props.name,
props.control._formState,
),
})} })}
</FormControl> </FormControl>
{props.description && ( {props.description && (
@ -127,29 +126,47 @@ export function FormSubmit({
<Button type="submit" isLoading={form.formState.isSubmitting}> <Button type="submit" isLoading={form.formState.isSubmitting}>
Save Save
</Button> </Button>
<Button
type="reset"
variant="secondary"
disabled={form.formState.isSubmitting || !form.formState.isDirty}
onClick={() => {
form.reset();
}}
>
Reset
</Button>
{/* unsaved changes indicator */} {/* unsaved changes indicator */}
{!hideUnsavedChangesIndicator && ( {!hideUnsavedChangesIndicator && (
<FormUnsavedChangesIndicator form={form} /> <FormUnsavedChangesIndicator form={form.control} />
)} )}
</div> </div>
); );
} }
export function FormUnsavedChangesIndicator({ export function FormUnsavedChangesIndicator({
form, form: control,
className, className,
}: { }: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
form: UseFormReturn<z.infer<any>>; form?: UseFormReturn<z.infer<any>>["control"];
className?: string; className?: string;
}) { }) {
const form = useFormState({ control });
return ( return (
<p <AnimatePresence>
className={`text-sm text-red-500 duration-200 animate-in fade-in ${ {form.isDirty && (
form.formState.isDirty ? "opacity-100" : "invisible opacity-0" <motion.p
} ${className}`} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className={`text-sm text-red-500 ${className}`}
> >
You have unsaved changes! You have unsaved changes!
</p> </motion.p>
)}
</AnimatePresence>
); );
} }