diff --git a/packages/basehub/src/events/primitive.tsx b/packages/basehub/src/events/primitive.tsx index 2ca664e..ea5b2b8 100644 --- a/packages/basehub/src/events/primitive.tsx +++ b/packages/basehub/src/events/primitive.tsx @@ -12,6 +12,7 @@ import { // eslint-disable-next-line import/no-unresolved } from "../index"; import type { ResolvedRef } from "../common-types"; +import type { Field } from "../react/form/primitive"; /* ------------------------------------------------------------------------------------------------- * Client @@ -42,7 +43,7 @@ type ExtractEventKey = T extends `${infer Base}:${string}` : T; // Get all event key types (bshb_event_*) -type EventKeys = KeysStartingWith; +export type EventKeys = KeysStartingWith; // Map from event key to its schema type type EventSchemaMap = { @@ -278,3 +279,125 @@ export async function deleteEvent( | { success: true } | { success: false; error: string }; } + +// PARSE FORM DATA HELPER ------------------------------------------------------------------------ +type SafeReturn = + | { success: true; data: T } + | { success: false; errors: Record }; + +export function parseFormData< + Key extends `${EventKeys}:${string}`, + Schema extends Field[], +>( + key: Key, + schema: Schema, + formData: FormData +): SafeReturn]> { + const formattedData: Record = {}; + const errors: Record = {}; + + schema.forEach((field) => { + const key = field.name; + + // Handle multiple values (like multiple select or checkboxes) + if ((field.type === "select" || field.type === "radio") && field.multiple) { + const values = formData.getAll(key).filter(Boolean); + + if (field.required && values.length === 0) { + errors[key] = `${field.label || key} is required`; + } + + formattedData[key] = values.map(String); + return; + } + + const value = formData.get(key); + + // Required field validation + if (field.required && (value === null || value === "")) { + errors[key] = `${field.label || key} is required`; + return; + } + + // Handle empty optional fields + if (value === null || value === "") { + formattedData[key] = field.defaultValue ?? null; + return; + } + + try { + switch (field.type) { + case "checkbox": + formattedData[key] = value === "on" || value === "true"; + break; + + case "email": { + const email = String(value); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + errors[key] = `${field.label || key} must be a valid email address`; + } + formattedData[key] = email; + break; + } + + case "select": + case "radio": { + const stringValue = String(value); + if (field.options.length && !field.options.includes(stringValue)) { + errors[key] = `${ + field.label || key + } must be one of the available options`; + } + formattedData[key] = stringValue; + break; + } + + case "date": + case "datetime": { + const date = new Date(value as string); + if (isNaN(date.getTime())) { + errors[key] = `${field.label || key} must be a valid date`; + break; + } + formattedData[key] = date.toISOString(); + break; + } + + case "number": { + const num = Number(value); + if (isNaN(num)) { + errors[key] = `${field.label || key} must be a valid number`; + break; + } + formattedData[key] = num; + break; + } + + case "file": { + const file = value as File; + if (!(file instanceof File)) { + errors[key] = `${field.label || key} must be a valid file`; + break; + } + formattedData[key] = file; + break; + } + + default: + formattedData[key] = String(value); + } + } catch (error) { + errors[key] = `Invalid value for ${field.label || key}`; + } + }); + + if (Object.keys(errors).length > 0) { + return { success: false, errors }; + } + + return { + data: formattedData as EventSchemaMap[ExtractEventKey], + success: true, + }; +} diff --git a/packages/basehub/src/react/form/primitive.tsx b/packages/basehub/src/react/form/primitive.tsx index f21d5cf..bd9ac46 100644 --- a/packages/basehub/src/react/form/primitive.tsx +++ b/packages/basehub/src/react/form/primitive.tsx @@ -27,6 +27,8 @@ export type Field = { | { type: "radio"; options: string[]; multiple: boolean } ); +// FORM COMPONENT ------------------------------------------------------------------------ + type Handlers = { text: (props: Extract) => ReactNode; textarea: (props: Extract) => ReactNode; @@ -51,7 +53,7 @@ export type HandlerProps = ExtractPropsForHandler< type CustomBlockBase = { readonly __typename: string }; export type CustomBlocksBase = readonly CustomBlockBase[]; -export type FormProps = { +export type FormProps = { schema: Field[]; components?: Partial; disableDefaultComponents?: boolean; @@ -59,11 +61,11 @@ export type FormProps = { action: | { type: "send"; - ingestKey: string; + ingestKey: Key; } | { type: "update"; - adminKey: string; + adminKey: Key; eventId: string; }; } & Omit< @@ -74,14 +76,14 @@ export type FormProps = { "action" | "onSubmit" | "children" >; -export const unstable_Form = ({ +export function unstable_Form({ schema, components, disableDefaultComponents, children, action, ...rest -}: FormProps): ReactNode => { +}: FormProps): ReactNode { const fields = schema as Field[] | undefined; // eslint-disable-next-line react-hooks/rules-of-hooks @@ -143,7 +145,7 @@ export const unstable_Form = ({ {children ?? } ); -}; +} const defaultHandlers: Handlers = { text: (props) => (