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 { 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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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} />}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue