Last active
April 27, 2025 07:49
-
-
Save DennisKraaijeveld/cf4d51a2ad4f537344599dd9171f3517 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Generic hook for submitting data via fetcher using FormData. | |
* Relies on React Router 7's type generation for the action's return type. | |
* | |
* @template TSchema - The Zod schema type for input data validation and type inference. | |
* @template TActionReturn - The specific return type of the target route's action function (inferred via RR7 typegen). | |
* @param _schema - Zod schema (used primarily for type inference of the 'data' in submit). | |
* @param actionUrl - The target URL for the fetcher submission. | |
* @param prepareFormData - A function that takes the typesafe data object (z.infer<TSchema>) and returns a FormData instance. | |
*/ | |
export function useGenericFormDataFetcher< | |
TSchema extends z.ZodTypeAny, | |
TActionReturn, | |
>( | |
_schema: TSchema, // Parameter used for type inference | |
actionUrl: string, | |
prepareFormData: (data: z.infer<TSchema>) => FormData, | |
) { | |
const fetcher = useFetcher<TActionReturn>() | |
// Infer the data type from the schema for the submit function's parameter | |
type TData = z.infer<TSchema> | |
return { | |
...fetcher, | |
submit: (data: TData) => { | |
const formData = prepareFormData(data) | |
void fetcher.submit(formData, { | |
method: 'POST', | |
action: actionUrl, | |
}) | |
}, | |
} | |
} | |
// How to use | |
import { useFetcher } from 'react-router' | |
import { z } from 'zod' | |
import { getSession } from '#app/middleware/session.server.ts' | |
import { getUser } from '#app/utils/auth.server.ts' | |
import { prisma } from '#app/utils/db.server.ts' | |
import { type Route } from './+types/all' | |
// Exporting the types action response. Doing this so its easier to just import the type in components | |
export type AutomationActionType = Route.ComponentProps['actionData'] | |
export const DeleteAutomationIntent = 'delete' | |
export const DeleteBulkAutomationIntent = 'delete-bulk' | |
export const deleteWorkflowSchema = z.discriminatedUnion('intent', [ | |
z.object({ | |
intent: z.literal(DeleteAutomationIntent), | |
workflowId: z.string().min(1), | |
}), | |
z.object({ | |
intent: z.literal(DeleteBulkAutomationIntent), | |
// Parsing JSON | |
workflowIds: z | |
.string() | |
.min(1) | |
.transform((val) => JSON.parse(val)) | |
.pipe(z.array(z.string().min(1)).min(1)), | |
}), | |
]) | |
export type DeleteAutomationFormData = z.infer<typeof deleteWorkflowSchema> | |
export async function action({ request, context }: Route.ActionArgs) { | |
const session = getSession(context) | |
const [, workspaceId] = await getUser(session) | |
const formData = await request.formData() | |
const formEntries = Object.fromEntries(formData.entries()) | |
const result = deleteWorkflowSchema.safeParse(formEntries) | |
if (!result.success) { | |
return { | |
title: 'Error', | |
description: 'Failed to delete automation. Please try again.', | |
} | |
} | |
switch (result.data.intent) { | |
case DeleteAutomationIntent: | |
try { | |
const workflowId = result.data.workflowId | |
await prisma.workflow.delete({ | |
where: { id: workflowId, workspaceId: workspaceId }, | |
}) | |
return { | |
title: 'Success', | |
description: 'Automation removed successfully.', | |
type: 'success', | |
} | |
} catch (e: unknown) { | |
return { | |
title: 'Error', | |
description: 'Failed to delete automation. Please try again.', | |
type: 'error', | |
} | |
} | |
case DeleteBulkAutomationIntent: | |
try { | |
const workflowIds = result.data.workflowIds | |
await prisma.workflow.deleteMany({ | |
where: { id: { in: workflowIds }, workspaceId: workspaceId }, | |
}) | |
return { | |
title: 'Success', | |
description: 'Automations removed successfully.', | |
type: 'success', | |
} | |
} catch (e: unknown) { | |
return { | |
title: 'Error', | |
description: 'Failed to delete automations. Please try again.', | |
type: 'error', | |
} | |
} | |
default: | |
return { | |
title: 'Error', | |
description: 'Failed to delete automation. Please try again.', | |
type: 'error', | |
} | |
} | |
} | |
// Fetcher in component | |
import { | |
deleteWorkflowSchema, | |
useGenericFormDataFetcher, | |
type DeleteAutomationFormData, | |
type AutomationActionType, | |
DeleteAutomationIntent, | |
DeleteBulkAutomationIntent, | |
} from '#app/routes/_dashboard+/automations+/all.tsx' | |
export function DeleteAutomationsDialog({ | |
workflows, | |
showTrigger = true, | |
onSuccess, | |
...props | |
}: DeleteAutomationsDialogProps) { | |
const { onOpenChange } = props | |
// Passing in the schema, the route and a helper util to append to FormData. | |
const deleteFetcher = useGenericFormDataFetcher< | |
typeof deleteWorkflowSchema, | |
AutomationActionType | |
>(deleteWorkflowSchema, '/automations/all', prepareDeleteFormData) | |
function onDelete() { | |
startDeleteTransition(async () => { | |
invariant(workflows.length > 0, 'At least one workflow is required') | |
if (workflows.length === 1) { | |
const workflow = workflows[0] | |
invariant(workflow?.id, 'Workflow ID is required') | |
const data: DeleteAutomationFormData = { | |
intent: DeleteAutomationIntent, | |
workflowId: workflow.id, | |
} | |
// This is typesafe now | |
deleteFetcher.submit(data) | |
} else { | |
const workflowIds = workflows.map((workflow) => workflow.id) | |
invariant( | |
workflowIds.length > 0, | |
'At least one workflow ID is required', | |
) | |
const data: DeleteAutomationFormData = { | |
intent: DeleteBulkAutomationIntent, | |
workflowIds, | |
} | |
// This is typesafe now | |
deleteFetcher.submit(data) | |
} | |
}) | |
} | |
useEffect(() => { | |
// This is typesafe now | |
if (deleteFetcher.data?.type === 'success') { | |
toast.success(deleteFetcher.data.title, { | |
description: deleteFetcher.data.description, | |
}) | |
} else if (deleteFetcher.data?.type === 'error') { | |
toast.error(deleteFetcher.data.title, { | |
description: deleteFetcher.data.description, | |
}) | |
} | |
}, [deleteFetcher.data]) | |
return ( <div /> ) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment