sendou.ink/app/components/ActionButton.tsx
2026-08-05 18:16:24 +03:00

114 lines
3.3 KiB
TypeScript

import type * as React from "react";
import { type FetcherWithComponents, useFetcher } from "react-router";
import type { z } from "zod";
import {
type ActionsOf,
type FieldsOf,
serializeFieldValue,
} from "~/utils/action-schemas";
import { SendouButton, type SendouButtonProps } from "./elements/Button";
import { FormWithConfirm } from "./FormWithConfirm";
import { SubmitButton } from "./SubmitButton";
interface ActionButtonBaseProps<
TSchema extends z.ZodTypeAny,
TAction extends ActionsOf<TSchema>,
> extends Omit<SendouButtonProps, "type" | "name" | "value" | "form"> {
/** Action schema of the route the button submits to. Only used for typing `action` and `fields`. */
schema: TSchema;
/** `_action` to submit, narrowed to the literals of the schema. */
action: TAction;
/** Route to submit to. Defaults to the current route. */
formAction?: string;
formClassName?: string;
/** Fetcher to submit with, e.g. to share submitting state between buttons. Defaults to own fetcher. */
fetcher?: FetcherWithComponents<unknown>;
/** When set, submits only after the user confirms via a dialog. */
confirm?: {
dialogHeading: string;
description?: React.ReactNode;
submitButtonText?: string;
submitButtonVariant?: SendouButtonProps["variant"];
};
}
type ActionButtonProps<
TSchema extends z.ZodTypeAny,
TAction extends ActionsOf<TSchema>,
> = ActionButtonBaseProps<TSchema, TAction> &
// biome-ignore lint/complexity/noBannedTypes: {} models "branch with no extra fields"
({} extends FieldsOf<TSchema, TAction>
? { fields?: FieldsOf<TSchema, TAction> }
: { fields: FieldsOf<TSchema, TAction> });
/**
* Button that submits a mutation to a route action as `_action` + hidden fields,
* type checked against the route's zod action schema.
*
* @example
* <ActionButton
* schema={lookingSchema}
* action="LIKE"
* fields={{ targetGroupId: group.id }}
* >
* {t("q:looking.groups.actions.invite")}
* </ActionButton>
*/
export function ActionButton<
TSchema extends z.ZodTypeAny,
const TAction extends ActionsOf<TSchema>,
>({
schema,
action,
fields,
formAction,
formClassName,
fetcher: fetcherFromProps,
confirm,
children,
...buttonProps
}: ActionButtonProps<TSchema, TAction>) {
const ownFetcher = useFetcher();
const fetcher = fetcherFromProps ?? ownFetcher;
const fieldEntries = Object.entries(
(fields ?? {}) as Record<string, unknown>,
).flatMap(([name, value]) =>
value === undefined || value === null
? []
: ([[name, serializeFieldValue(value)]] as const),
);
if (confirm) {
return (
<FormWithConfirm
dialogHeading={confirm.dialogHeading}
description={confirm.description}
submitButtonText={confirm.submitButtonText}
submitButtonVariant={confirm.submitButtonVariant}
action={formAction}
fetcher={fetcherFromProps}
fields={[["_action", action], ...fieldEntries]}
>
<SendouButton {...buttonProps}>{children}</SendouButton>
</FormWithConfirm>
);
}
return (
<fetcher.Form method="post" action={formAction} className={formClassName}>
{fieldEntries.map(([name, value]) => (
<input type="hidden" key={name} name={name} value={value} />
))}
<SubmitButton
schema={schema}
_action={action}
state={fetcher.state}
{...buttonProps}
>
{children}
</SubmitButton>
</fetcher.Form>
);
}