feat: animation updates
This commit is contained in:
parent
f067a01e47
commit
9afe159bf2
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue