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 { 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<z.infer<typeof formValidator>>({
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 (
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
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. */}
{/* <AnimatePresence mode="sync"> */}
<AnimatePresence mode="sync" initial={false}>
<h1 key="title" className="col-span-2">
Domains
</h1>
<h1 key="title" className="col-span-2">
Domains
</h1>
{domainsForm.fields.map((field, index) => (
<DomainEntry
field={field}
index={index}
key={field.domainId ?? field.id}
domains={domainsForm}
/>
))}
{domainsForm.fields.map((field, index) => (
<DomainEntry
field={field}
index={index}
key={field.id}
domains={domainsForm}
/>
))}
<motion.div
className="flex flex-row flex-wrap items-center gap-4"
layout
key={service.data?.id}
>
<FormSubmit
form={form}
className="col-span-2"
hideUnsavedChangesIndicator
/>
<Button
type="button"
variant="secondary"
icon={PlusIcon}
onClick={() => {
const domain = {
domainId: undefined,
domain: uuidv7().split("-").at(-1) + ".example.com",
forceSSL: false,
https: false,
internalPort: 8080,
};
domainsForm.append(domain);
}}
<motion.div
className="flex flex-row flex-wrap items-center gap-4"
layout
key={service.data?.id}
>
Add Domain
</Button>
<FormSubmit
form={form}
className="col-span-2"
hideUnsavedChangesIndicator
/>
{/* <FormUnsavedChangesIndicator form={form} /> */}
</motion.div>
{/* </AnimatePresence> */}
<Button
type="button"
variant="secondary"
icon={PlusIcon}
onClick={() => {
const domain = {
domainId: uuidv7(),
domain: uuidv7().split("-").at(-1) + ".example.com",
forceSSL: false,
https: false,
internalPort: 8080,
};
domainsForm.append(domain);
}}
>
Add Domain
</Button>
<FormUnsavedChangesIndicator />
</motion.div>
</AnimatePresence>
<AnimatePresence>{children}</AnimatePresence>
</form>

View file

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

View file

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

View file

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

View file

@ -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<
<UIFormField
control={props.control}
name={props.name}
render={({ field }) => (
render={({ field, fieldState, formState }) => (
<FormItem className={props.className}>
<FormLabel>
{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,
})}
</FormControl>
{props.description && (
@ -127,29 +126,47 @@ export function FormSubmit({
<Button type="submit" isLoading={form.formState.isSubmitting}>
Save
</Button>
<Button
type="reset"
variant="secondary"
disabled={form.formState.isSubmitting || !form.formState.isDirty}
onClick={() => {
form.reset();
}}
>
Reset
</Button>
{/* unsaved changes indicator */}
{!hideUnsavedChangesIndicator && (
<FormUnsavedChangesIndicator form={form} />
<FormUnsavedChangesIndicator form={form.control} />
)}
</div>
);
}
export function FormUnsavedChangesIndicator({
form,
form: control,
className,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: UseFormReturn<z.infer<any>>;
form?: UseFormReturn<z.infer<any>>["control"];
className?: string;
}) {
const form = useFormState({ control });
return (
<p
className={`text-sm text-red-500 duration-200 animate-in fade-in ${
form.formState.isDirty ? "opacity-100" : "invisible opacity-0"
} ${className}`}
>
You have unsaved changes!
</p>
<AnimatePresence>
{form.isDirty && (
<motion.p
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!
</motion.p>
)}
</AnimatePresence>
);
}