Created
July 18, 2022 14:08
-
-
Save jjhiggz/a75d7d62bb786ea0bc931b9ede29b1f7 to your computer and use it in GitHub Desktop.
Action Helpers
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
import { useActionData } from "@remix-run/react"; | |
import useFormReset from "~/hooks/useFormReset"; | |
import type { ActionFunction, LoaderArgs } from "@remix-run/server-runtime"; | |
import invariant from "tiny-invariant"; | |
import { prisma } from "~/db.server"; | |
import { requireUserId } from "~/session.server"; | |
import FormHeader from "~/components/UI/Forms/FormHeader"; | |
import { Form } from "@remix-run/react"; | |
import TextInput from "~/components/UI/Forms/Inputs/TextInput"; | |
import { updateItemSchema } from "~/models/item.server"; | |
import { updateItem } from "~/models/item.server"; | |
import { | |
handleActionErrors, | |
throwIfInvalidAction, | |
validateAction, | |
} from "~/utils/action-helper/action-helper"; | |
import HiddenInput from "~/components/UI/Forms/Inputs/HiddenInput"; | |
import { useRef } from "react"; | |
import { superjsonify, useSuperLoaderData } from "~/utils/remix-superjson"; | |
type ActionData = Awaited<ReturnType<typeof updateItem>>; | |
export const action: ActionFunction = async ({ request }) => { | |
const formData = await request.formData(); | |
const userId = await requireUserId(request); | |
formData.set("userId", userId); | |
return validateAction({ | |
rawData: formData, | |
schema: updateItemSchema, | |
dataFn: updateItem, | |
}) | |
.then(throwIfInvalidAction) | |
.catch((errors) => { | |
console.log("errors", errors); | |
return handleActionErrors(errors); | |
}); | |
}; | |
export const loader = async ({ request, params }: LoaderArgs) => { | |
const userId = await requireUserId(request); | |
invariant(params.itemId, "item not found"); | |
const item = await prisma.item.findFirst({ | |
where: { | |
userId, | |
id: params.itemId, | |
}, | |
}); | |
if (!item) { | |
throw new Response("Not Found", { status: 404 }); | |
} | |
return superjsonify({ item }); | |
}; | |
export default function UpdateItemPage() { | |
const data = useSuperLoaderData<typeof loader>(); | |
const { item } = data; | |
const formRef = useRef<HTMLFormElement>(null); | |
const actionData = useActionData<ActionData>(); | |
useFormReset({ formRef, watch: [data] }); | |
return ( | |
<div className="form-container"> | |
<Form method="post" className="main-form" ref={formRef}> | |
<FormHeader title={item.name} /> | |
<TextInput | |
inputProps={{ | |
placeholder: "Item name", | |
name: "name", | |
defaultValue: item.name, | |
}} | |
labelProps={{ | |
htmlFor: "name", | |
}} | |
labelText="Item Name" | |
errorMessage={actionData?.errors?.name} | |
/> | |
<TextInput | |
inputProps={{ | |
placeholder: "Item description", | |
name: "description", | |
defaultValue: item.description, | |
}} | |
labelProps={{ | |
htmlFor: "description", | |
}} | |
labelText="Item Description" | |
/> | |
<TextInput | |
inputProps={{ | |
placeholder: "Item price", | |
name: "price", | |
}} | |
labelProps={{ | |
htmlFor: "price", | |
}} | |
labelText="Item Price" | |
/> | |
<TextInput | |
inputProps={{ | |
placeholder: "URL Link", | |
name: "photourl", | |
}} | |
labelProps={{ | |
htmlFor: "photo-url", | |
}} | |
labelText="Photo URL" | |
/> | |
<fieldset className="dietary-restrictions"> | |
<legend>Dietary Restrictions</legend> | |
<label htmlFor="">Gluten Free?</label> | |
<input | |
type="checkbox" | |
name="isGlutenFree" | |
defaultChecked={item.isGlutenFree} | |
/> | |
</fieldset> | |
<input type="hidden" name="chill-bruh" value={"fuckmyass"} /> | |
<input type="hidden" name="yo" value={1} /> | |
<HiddenInput defaultValue={item.id} name="id" /> | |
<div className="form-submit"> | |
<button type="submit" className=""> | |
Update item | |
</button> | |
</div> | |
</Form> | |
</div> | |
); | |
} |
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
import { json } from "@remix-run/server-runtime"; | |
import type * as z from "zod"; | |
import type { ZodError, ZodSchema } from "zod"; | |
import type { NestedKeyOf } from "../types/nested-key.types"; | |
export const loaderHelper = () => {}; | |
export type ErrorMap<T extends Object> = Partial< | |
Record<NestedKeyOf<T>, string> | |
>; | |
export type DataFn<DataReturnType, ZSchema extends ZodSchema> = ( | |
data: z.infer<ZSchema> | |
) => Promise<ValidatorReturn<DataReturnType>>; | |
// Will return the type of a validator | |
export type ValidatorReturn<ReturnDataType extends Object> = | |
| { | |
isValid: false; | |
errors: ErrorMap<ReturnDataType>; // for example {name: "Name is required", age: "age can't be a string"} | |
error?: string; // if an error isn't specific to a key | |
data: null; | |
} | |
| { | |
isValid: true; | |
errors?: null; | |
error?: null; | |
data: ReturnDataType; | |
}; | |
export const validateAction = async < | |
ZSchema extends ZodSchema, | |
DataFnReturnType | |
>({ | |
schema, | |
rawData, | |
dataFn, | |
}: { | |
schema: ZSchema; | |
rawData: Partial<z.infer<ZSchema>> | FormData; | |
dataFn: DataFn<DataFnReturnType, ZSchema>; | |
}) => { | |
// First just validate the shape of the data coming in and reject early if anything is wrong | |
const validateSchemaData = validateSchema(schema, rawData); | |
const { data: validatedData, isValid } = validateSchemaData; | |
if (!isValid) { | |
return validateSchemaData; | |
} | |
// Then we need to run the dataFn and validate that. | |
// For example if you try to create a user and the name is already taken, | |
// That is a database problem and will fail here. So this just means we have to write our actual methods to return | |
// isValid, error, data, and errors. | |
return await dataFn(validatedData); | |
}; | |
/** | |
* generates zod errors as a map of key to error message | |
* ```ts | |
* const schema = z.object({ | |
* name: z.string(), | |
* age: z.number(), | |
* }) | |
* | |
* try { | |
* schema.parse({}) | |
* } catch(e) { | |
* console.log(generateZodErrors(e)) | |
* // {name: "Required", age: "Required"} | |
* } | |
* | |
* ``` | |
*/ | |
export const generateZodErrors = <ZSchema extends ZodSchema>( | |
error: ZodError | |
): ErrorMap<z.infer<ZSchema>> => { | |
let errors = new Map(); | |
error.issues.forEach((issues) => { | |
errors.set(issues.path.join("."), issues.message); | |
}); | |
return Object.fromEntries(errors); | |
}; | |
/** | |
* generates schema data, as {errors, data, isValid} | |
* ```ts | |
* const schema = z.object({name: z.string()}) | |
* console.log(validateSchema(schema, {})) | |
* // {errors: {name: "Required"}, data: null, isValid: false} | |
* console.log(validateSchema(schema, {name: "John"})) | |
* // {errors: null, data: {name: "John"}, isValid: true} | |
* | |
* ``` | |
*/ | |
export const validateSchema = <ZSchema extends ZodSchema>( | |
zodSchema: ZSchema, | |
data: any | |
): ValidatorReturn<z.infer<ZSchema>> => { | |
try { | |
return { | |
isValid: true, | |
errors: null, | |
data: zodSchema.parse(data), | |
}; | |
} catch (e) { | |
return { | |
isValid: false, | |
errors: generateZodErrors(e as ZodError), | |
data: null, | |
}; | |
} | |
}; | |
export const throwIfInvalidAction = <T extends { isValid: boolean }>( | |
input: T | |
) => { | |
if (!input.isValid) { | |
throw new Error(JSON.stringify(input)); | |
} | |
return input as T & { isValid: true }; | |
}; | |
export const handleActionErrors = (error: Error) => { | |
return json(JSON.parse(error.message)); | |
}; |
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
export const updateItemSchema = zfd.formData({ | |
name: z.string().min(1), | |
description: z.string(), | |
isGlutenFree: zfd.checkbox(), | |
id: z.string(), | |
userId: z.string().min(1), | |
}); | |
export const updateItem: DataFn<Item, typeof updateItemSchema> = async ( | |
data: z.infer<typeof updateItemSchema> | |
) => { | |
try { | |
return { | |
data: await prisma.item.update({ | |
where: { id: data.id }, | |
data, | |
}), | |
isValid: true, | |
errors: null, | |
}; | |
} catch (e) { | |
return { | |
errors: createLoaderErrorFromPrismaError(e as Error), | |
isValid: false, | |
data: null, | |
}; | |
} | |
}; | |
export const createItemSchema = z.object({ | |
userId: z.string().min(1), // cannot be empty | |
sectionId: z.string().min(1), | |
name: z.string().default("").optional(), | |
description: z.string().default("").optional(), | |
}); | |
export const createItem: DataFn<Item, typeof createItemSchema> = async ({ | |
name, | |
description, | |
userId, | |
sectionId, | |
}) => { | |
const section = await prisma.section | |
.findUnique({ | |
where: { | |
id: sectionId, | |
}, | |
}) | |
.then(falsify) | |
.catch(returnFalse); | |
if (!section) { | |
return { | |
errors: { sectionId: "The parent menu was not found." }, | |
isValid: false, | |
data: null, | |
}; | |
} | |
try { | |
return { | |
isValid: true, | |
data: await prisma.item.create({ | |
data: { | |
menuId: section.menuId, | |
sectionId, | |
name, | |
description, | |
userId, | |
restaurantId: section.restaurantId, | |
order: (await getMaxOrder({ userId, sectionId: section.id })) + 1, | |
}, | |
}), | |
}; | |
} catch (error) { | |
console.error("error", error); | |
return { | |
errors: createLoaderErrorFromPrismaError(error as Error), | |
isValid: false, | |
data: null, | |
}; | |
} | |
}; |
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
export type NestedFiveLayers<ObjectType extends object> = { | |
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object | |
? `${Key}` | |
: `${Key}`; | |
}[keyof ObjectType & (string | number)]; | |
export type NestedFourLayers<ObjectType extends object> = { | |
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object | |
? ObjectType[Key] extends Function | |
? never | |
: `${Key}` | `${Key}.${NestedFiveLayers<ObjectType[Key]>}` | |
: `${Key}`; | |
}[keyof ObjectType & (string | number)]; | |
export type NestedThreeLayers<ObjectType extends object> = { | |
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object | |
? ObjectType[Key] extends Function | |
? never | |
: `${Key}` | `${Key}.${NestedFourLayers<ObjectType[Key]>}` | |
: `${Key}`; | |
}[keyof ObjectType & (string | number)]; | |
export type NestedTwoLayers<ObjectType extends object> = { | |
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object | |
? ObjectType[Key] extends Function | |
? never | |
: `${Key}` | `${Key}.${NestedThreeLayers<ObjectType[Key]>}` | |
: `${Key}`; | |
}[keyof ObjectType & (string | number)]; | |
export type NestedOneLayer<ObjectType extends object> = { | |
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object | |
? ObjectType[Key] extends Function | |
? never | |
: `${Key}` | `${Key}.${NestedTwoLayers<ObjectType[Key]>}` | |
: `${Key}`; | |
}[keyof ObjectType & (string | number)]; | |
export type NestedKeyOf<ObjectType extends object> = { | |
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object | |
? ObjectType[Key] extends Function | |
? never | |
: `${Key}` | `${Key}.${NestedOneLayer<ObjectType[Key]>}` | |
: `${Key}`; | |
}[keyof ObjectType & (string | number)]; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment