Skip to content

Instantly share code, notes, and snippets.

@tlawrie
Last active July 30, 2024 23:50
Show Gist options
  • Save tlawrie/a93c256cf804b1aa34b422eaea2197f0 to your computer and use it in GitHub Desktop.
Save tlawrie/a93c256cf804b1aa34b422eaea2197f0 to your computer and use it in GitHub Desktop.
Multi-step form using RVF (remix validated form) and shadcn Stepper
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '~/components/ui/select'
import { Button, buttonVariants } from '~/components/ui/button'
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from '~/components/ui/card'
import { ActionFunctionArgs, LoaderFunctionArgs, json, redirect } from '@remix-run/node'
import { Label } from '~/components/ui/label'
import { Input } from '~/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '~/components/ui/table'
import { Step, Stepper, useStepper } from '~/components/stepper'
import { withZod } from '@rvf/zod'
import { z } from 'zod'
import {
FieldArray,
isValidationErrorResponse,
useControlField,
useFieldArray,
useForm,
validationError,
} from '@rvf/remix'
import { zfd } from 'zod-form-data'
import {
Link,
useActionData,
useLoaderData,
useSearchParams,
useSubmit,
} from '@remix-run/react'
import { Textarea } from '~/components/ui/textarea'
import { TrashIcon } from 'lucide-react'
import { createDeclaration, getDeclaration } from '~/clients/declarations.server'
import { useState } from 'react'
import { ProductType, StatusType } from '~/types/declarations'
import { cn } from '~/utils/theme'
import { appLink } from '~/config/app'
import { Separator } from '~/components/ui/separator'
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url)
const searchParams = url.searchParams
let declaration = {}
if (searchParams.has('ref')) {
declaration = await getDeclaration(request, { id: searchParams.get('ref') })
} else if (searchParams.has('month') && searchParams.has('year')) {
declaration = await getDeclaration(request, { id: searchParams.get('ref') })
if (!declaration) {
declaration = {
declaration: { month: searchParams.get('month'), year: searchParams.get('year') },
}
}
}
//TODO: figure out if we should error out the creation if no params are passed
console.log('Loading Declaration: ', declaration)
return json({ status: 'ok', data: declaration })
}
const FormSchema = z.object({})
const validator = withZod(FormSchema)
//Preset declaration object
const blankDeclaration = {
status: StatusType.Draft,
product: ProductType.AluminiumSlab,
declaration: {},
forecast: {},
}
//TODO Implement a Union Validation function and determine a better way to get the form step
export const action = async ({ request, params }: ActionFunctionArgs) => {
const url = new URL(request.url)
const searchParams = url.searchParams
const accountSlug = params.account
const formData = await request.formData()
const formValidatedData = await validator.validate(formData)
const formStep = formValidatedData.formId
//This means that the service treats it as an update
// let id = ''
if (searchParams.has('ref')) {
blankDeclaration.id = searchParams.get('ref')
}
if (formStep === 'step-1') {
//Create object with status saved
const data = await validatorStep1.validate(formData)
if (data.error) return validationError(data.error)
const mergedDeclaration = { ...blankDeclaration, declaration: data.data }
const declaration = await createDeclaration(request, mergedDeclaration)
console.log('Step 1 data: ', declaration)
return json({ status: 'ok', message: 'Saved step 1', data: declaration })
} else if (formStep === 'step-2') {
//Update object with status saved
const data = await validatorStep2.validate(formData)
if (data.error) return validationError(data.error)
return json({ status: 'ok', message: 'Saved step 2', data: '' })
} else if (formStep === 'step-3') {
//Update object with status submitted
return json({ message: 'Submitted Form' })
}
return { message: `Unable to save form` }
}
const steps = [
{ label: 'Declaration', description: '' },
{ label: 'Forecast', description: '' },
{ label: 'Review & Submit', description: '' },
]
const FormSchemaStep1 = z.object({
month: z.string().min(1, 'Declaration month is required.'),
year: z.string().min(1, 'Declaration year is required.'),
// total: zfd.numeric(z.number().min(0, 'Total is required.')),
locations: z.array(
z.object({
location: z.string(),
measurement: z.string(),
quantity: zfd.numeric(z.number().min(0, 'Required.')),
}),
),
comments: z.string().optional(),
})
const validatorStep1 = withZod(FormSchemaStep1)
const FormSchemaStep2 = z.object({
month: z.string().min(1, 'Declaration month is required.'),
year: z.string().min(1, 'Declaration year is required.'),
// total: zfd.numeric(z.number().min(0, 'Total is required.')),
locations: z.array(
z.object({
location: z.string(),
measurement: z.string(),
quantity: zfd.numeric(z.number().min(0, 'Required.')),
}),
),
comments: z.string().optional(),
})
const validatorStep2 = withZod(FormSchemaStep2)
export default function DeclarationFormRoute() {
const [searchParams, setSearchParams] = useSearchParams()
// const { setStep } = useStepper()
let initialStep = 0
if (searchParams.has('s')) {
initialStep = parseInt(searchParams.get('s') || '0')
// setStep(parseInt(searchParams.get('s') || '0'))
}
return (
<div className="space-y-6 pt-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Declaration request</h1>
</div>
<Stepper variant="circle-alt" initialStep={initialStep} steps={steps}>
{steps.map((stepProps, index) => {
if (index === 0) {
return (
<Step key={stepProps.label} {...stepProps}>
<FormStep1 />
</Step>
)
}
if (index === 1) {
return (
<Step key={stepProps.label} {...stepProps}>
<FormStep2 />
</Step>
)
}
return (
<Step key={stepProps.label} {...stepProps}>
<FormStep3 />
</Step>
)
})}
<MyStepperFooter />
</Stepper>
</div>
)
}
function FormStep1() {
const { nextStep } = useStepper()
const submit = useSubmit()
const { status, data } = useLoaderData<typeof loader>()
const result = useActionData<typeof action>()
// const [total, setTotal] = useState<Record<number, number>>({})
/* These can come from the loader */
const defaultValues = {
month: data.declaration.month || 'january',
year: data.declaration.year || '2024',
total: 0,
locations: data.declaration.locations || [
{ location: '', measurement: '', quantity: 0 },
],
comments: '',
}
//Submission will be handled via the Remix Action
const form = useForm({
defaultValues,
validator: validatorStep1,
method: 'post',
id: 'step-1',
onSubmitSuccess: () => {
if (isValidationErrorResponse(result)) return
nextStep()
},
})
return (
<form {...form.getFormProps()}>
<Card>
<CardHeader>
<CardTitle>Declaration</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="product">Product</Label>
<Input id="product" value="Aluminium Slab" disabled />
{/* <Select {...form.getInputProps('product')} disabled>
<SelectTrigger id="product">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Aluminium_slab">Alluminum Slab</SelectItem>
</SelectContent>
</Select>
{form.error('product') && <span>{form.error('product')}</span>} */}
</div>
<div className="flex flex-row space-x-2">
<div className="min-w-80 space-y-2">
<Label htmlFor="month">Month</Label>
<Select {...form.getInputProps('month')}>
<SelectTrigger
id="month"
className="rounded-md border border-gray-300 shadow-sm">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="january">January</SelectItem>
<SelectItem value="february">February</SelectItem>
<SelectItem value="march">March</SelectItem>
<SelectItem value="april">April</SelectItem>
<SelectItem value="may">May</SelectItem>
<SelectItem value="june">June</SelectItem>
<SelectItem value="july">July</SelectItem>
<SelectItem value="august">August</SelectItem>
<SelectItem value="september">September</SelectItem>
<SelectItem value="october">October</SelectItem>
<SelectItem value="november">November</SelectItem>
<SelectItem value="december">December</SelectItem>
</SelectContent>
</Select>
{form.error('month') && <span>{form.error('month')}</span>}
</div>
<div className="min-w-80 space-y-2">
<Label htmlFor="year">Year</Label>
<Select {...form.getInputProps('year')}>
<SelectTrigger
id="year"
className="rounded-md border border-gray-300 shadow-sm">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="2024">2024</SelectItem>
<SelectItem value="2025">2025</SelectItem>
</SelectContent>
</Select>
{form.error('year') && <span>{form.error('year')}</span>}
</div>
</div>
<Table>
<TableHeader>
<TableRow className="border-none">
<TableHead className="pl-0">
<Label className="text-foreground">Location</Label>
</TableHead>
<TableHead className="pl-0">
<Label className="text-foreground">Unit of measurement</Label>
</TableHead>
<TableHead className="pl-0">
<Label className="text-foreground">Quantity</Label>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{form.array('locations').map((key, item, index) => (
<TableRow key={key} className="border-none">
<TableCell className="pl-0">
<Label htmlFor={item.name('location')} className="sr-only">
Location
</Label>
<Select {...item.getInputProps('location')}>
<SelectTrigger
id={item.name('location')}
className="rounded-md border border-gray-300 shadow-sm">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="logan">Logan</SelectItem>
<SelectItem value="oswego">Oswego</SelectItem>
</SelectContent>
</Select>
{item.error('location') && <span>{item.error('location')}</span>}
</TableCell>
<TableCell className="pl-0">
<Label htmlFor={item.name('measurement')} className="sr-only">
Unit of measurement
</Label>
<Select {...item.getInputProps('measurement')}>
<SelectTrigger
id={item.name('measurement')}
className="rounded-md border border-gray-300 shadow-sm">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="kg">Kilogram</SelectItem>
<SelectItem value="ton">Ton</SelectItem>
<SelectItem value="lb">Pound</SelectItem>
</SelectContent>
</Select>
{item.error('measurement') && (
<span>{item.error('measurement')}</span>
)}
</TableCell>
<TableCell className="pl-0">
<Label htmlFor={item.name('quantity')} className="sr-only">
Quantity
</Label>
<Input
type="number"
min="0"
{...item.getInputProps('quantity')}
// onChange={(e) => handleQuantityChange(index, e)}
className="rounded-md border border-gray-300 shadow-sm"
/>
{item.error('quantity') && <span>{item.error('quantity')}</span>}
</TableCell>
<TableCell>
{index !== 0 ? (
<Button
onClick={() => {
form.array('locations').remove(index)
}}
variant="ghost"
size="icon"
type="button">
<TrashIcon />
</Button>
) : (
<div className="h-9 w-9"></div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button
variant="outline"
className={cn(
buttonVariants({ variant: 'outline' }),
'text-[#580720]) mt-4 border-[#580720] bg-[#FDFCFC]',
)}
type="button"
onClick={() =>
form
.array('locations')
.insert(1, { location: '', measurement: '', quantity: 0 })
}>
+ Add location
</Button>
{form.array('locations').error() && (
<div>{form.array('locations').error()}</div>
)}
<div className="mt-4 space-y-2">
<Label htmlFor="comments">Comments</Label>
<Textarea
{...form.getInputProps('comments')}
placeholder="A lovely placeholder"
className="rounded-md border border-gray-300 shadow-sm"
/>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<StepperFormActions
isSubmitting={form.formState.isSubmitting}
showBack={false}
/>
</CardFooter>
</Card>
{/* Useful debugging - work into the wrapper */}
<p>{form.formState.isValid ? 'Valid!' : 'Not Valid'}</p>
{result && !isValidationErrorResponse(result) && <p>{result.message}</p>}
{Object.entries(form.formState.fieldErrors).map(([key, error]) => (
<p>
{key}:{error}
</p>
))}
{/* </ValidatedForm> */}
</form>
)
}
//TODO, pass in the data between steps. probably by hoisting the onSubmitSuccess or the action into the higher wrapping function and passing down to both steps.
//TODO: this shares a majority of the same fields as Step 1
// find a way to componetise and pass in a different scope
function FormStep2() {
const { nextStep } = useStepper()
const result = useActionData<typeof action>()
const form = useForm({
defaultValues: {
month: '',
year: '',
total: 0,
locations: [{ location: '', measurement: '', quantity: 0 }],
comments: '',
},
validator: validatorStep2,
id: 'step-2',
method: 'post',
// handleSubmit: async (_data: z.infer<typeof FormStep1Schema>) => {
// console.log(_data)
// nextStep()
// },
onSubmitSuccess: () => {
if (isValidationErrorResponse(result)) return
nextStep()
},
})
return (
<form {...form.getFormProps()}>
<Card>
<CardHeader>
<CardTitle>Forecast</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex flex-row space-x-2">
<div className="min-w-80 space-y-2">
<Label htmlFor="month">Month</Label>
<Select {...form.getInputProps('month')}>
<SelectTrigger
id="month"
className="rounded-md border border-gray-300 shadow-sm">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="january">January</SelectItem>
<SelectItem value="february">February</SelectItem>
<SelectItem value="march">March</SelectItem>
<SelectItem value="april">April</SelectItem>
<SelectItem value="may">May</SelectItem>
<SelectItem value="june">June</SelectItem>
<SelectItem value="july">July</SelectItem>
<SelectItem value="august">August</SelectItem>
<SelectItem value="september">September</SelectItem>
<SelectItem value="october">October</SelectItem>
<SelectItem value="november">November</SelectItem>
<SelectItem value="december">December</SelectItem>
</SelectContent>
</Select>
{form.error('month') && <span>{form.error('month')}</span>}
</div>
<div className="min-w-80 space-y-2">
<Label htmlFor="year">Year</Label>
<Select {...form.getInputProps('year')}>
<SelectTrigger
id="year"
className="rounded-md border border-gray-300 shadow-sm">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="2024">2024</SelectItem>
<SelectItem value="2025">2025</SelectItem>
</SelectContent>
</Select>
{form.error('year') && <span>{form.error('year')}</span>}
</div>
</div>
<Table>
<TableHeader>
<TableRow className="border-none">
<TableHead className="pl-0">
<Label className="text-foreground">Location</Label>
</TableHead>
<TableHead className="pl-0">
<Label className="text-foreground">Unit of measurement</Label>
</TableHead>
<TableHead className="pl-0">
<Label className="text-foreground">Quantity</Label>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* {items.map(({ defaultValue, key }, index) => ( */}
{/* {(array) => (
<> */}
{form.array('locations').map((key, item, index) => (
<TableRow key={key} className="border-none">
<TableCell className="pl-0">
<Label htmlFor={`locations[${index}].location`} className="sr-only">
Location
</Label>
<Select {...form.getInputProps(`locations[${index}].location`)}>
<SelectTrigger
id={`locations[${index}].location`}
className="rounded-md border border-gray-300 shadow-sm">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="logan">Logan</SelectItem>
<SelectItem value="oswego">Oswego</SelectItem>
</SelectContent>
</Select>
{form.error(`locations[${index}].location`) && (
<span>{form.error(`locations[${index}].location`)}</span>
)}
</TableCell>
<TableCell className="pl-0">
<Label
htmlFor={`locations[${index}].measurement`}
className="sr-only">
Unit of measurement
</Label>
<Select {...form.getInputProps(`locations[${index}].measurement`)}>
<SelectTrigger
id={`locations[${index}].measurement`}
className="rounded-md border border-gray-300 shadow-sm">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="kg">Kilogram</SelectItem>
<SelectItem value="ton">Ton</SelectItem>
<SelectItem value="lb">Pound</SelectItem>
</SelectContent>
</Select>
{form.error(`locations[${index}].measurement`) && (
<span>{form.error(`locations[${index}].measurement`)}</span>
)}
</TableCell>
<TableCell className="pl-0">
<Label htmlFor={`locations[${index}].quantity`} className="sr-only">
Quantity
</Label>
<Input
type="number"
min="0"
{...form.getInputProps(`locations[${index}].quantity`)}
// onChange={(e) => handleQuantityChange(index, e)}
className="rounded-md border border-gray-300 shadow-sm"
/>
{form.error(`locations[${index}].quantity`) && (
<span>{form.error(`locations[${index}].quantity`)}</span>
)}
</TableCell>
<TableCell>
{index !== 0 ? (
<Button
onClick={() => {
form.array('locations').remove(index)
}}
variant="ghost"
size="icon"
type="button">
<TrashIcon />
</Button>
) : (
<div className="h-9 w-9"></div>
)}
</TableCell>
</TableRow>
))}
{/* </> */}
{/* )} */}
</TableBody>
</Table>
<Button
variant="outline"
className={cn(
buttonVariants({ variant: 'outline' }),
'text-[#580720]) mt-4 border-[#580720] bg-[#FDFCFC]',
)}
type="button"
onClick={() =>
form
.array('locations')
.insert(1, { location: '', measurement: '', quantity: 0 })
}>
+ Add location
</Button>
{/* <div className="mt-4 space-y-2">
<Label htmlFor="total">Total</Label>
<Input {...form.getInputProps('total')} type="number" min="0" readOnly />
{form.error('total') && <span>{form.error('total')}</span>}
</div> */}
<div className="mt-4 space-y-2">
<Label htmlFor="comments">Comments</Label>
<Textarea
{...form.getInputProps('comments')}
placeholder="A lovely placeholder"
/>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<StepperFormActions isSubmitting={form.formState.isSubmitting} />
</CardFooter>
</Card>
{/* Useful debugging - work into the wrapper */}
<p>{form.formState.isValid ? 'Valid!' : 'Not Valid'}</p>
{result && !isValidationErrorResponse(result) && <p>{result.message}</p>}
{Object.entries(form.formState.fieldErrors).map(([key, error]) => (
<p>
{key}:{error}
</p>
))}
</form>
)
}
function FormStep3() {
const { status, data } = useLoaderData<typeof loader>()
return (
<Card>
<CardHeader>
<CardTitle>Aluminium Slab</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex flex-row space-x-2">
<SummaryItem
label="Declaration month"
content={`${data.declaration.month} ${data.declaration.year}`}
/>
</div>
<div className="grid grid-cols-3">
<SummaryLabel>Location</SummaryLabel>
<SummaryLabel>Unit of measurement</SummaryLabel>
<SummaryLabel>Quantity</SummaryLabel>
</div>
{data &&
data.declaration.locations.map((item, index) => (
<div className="grid grid-cols-3">
<p>{item.location}</p>
<p>{item.measurement}</p>
<p>{item.quantity}</p>
</div>
))}
<SummaryItem label="Comments" content={data.declaration.comments} />
<Separator />
<div className="flex flex-row space-x-2">
<SummaryItem
label="Forecast month"
content={`${data.forecast.month} ${data.forecast.year}`}
/>
</div>
<div className="grid grid-cols-3">
<SummaryLabel>Location</SummaryLabel>
<SummaryLabel>Unit of measurement</SummaryLabel>
<SummaryLabel>Quantity</SummaryLabel>
</div>
{data &&
data.forecast.locations.map((item, index) => (
<div className="grid grid-cols-3">
<p>{item.location}</p>
<p>{item.measurement}</p>
<p>{item.quantity}</p>
</div>
))}
<SummaryItem label="Comments" content={data.forecast.comments} />
</div>
</CardContent>
<CardFooter className="flex justify-between">
<StepperFormActions />
</CardFooter>
</Card>
)
}
function SummaryLabel({ children }: { children: string }) {
return <p className="font-[#4D4D4D] text-xs tracking-wide">{children}</p>
}
function SummaryItem({ label, content }: { label: string; content: string }) {
return (
<div className="min-w-80 space-y-2">
<SummaryLabel className="font-[#4D4D4D] text-xs tracking-wide">
{label}
</SummaryLabel>
<p className="">{content}</p>
</div>
)
}
function StepperFormActions({
isSubmitting = false,
showBack = true,
account = '',
}: {
isSubmitting?: boolean
showBack?: boolean
account?: string
}) {
const {
prevStep,
nextStep,
resetSteps,
isDisabledStep,
hasCompletedAllSteps,
isLastStep,
activeStep,
} = useStepper()
return (
<div className="flex w-full justify-between gap-2">
<Link
className={cn(
buttonVariants({ variant: 'outline' }),
'text-[#580720]) border-[#580720] bg-[#FDFCFC]',
)}
to={appLink.declarations({
account: account,
})}>
Cancel
</Link>
{/* <Button type="button" variant="secondary" onClick={resetSteps}>
Cancel
</Button> */}
<div className="flex flex-row gap-4">
{showBack && (
<Button
// disabled={isDisabledStep}
onClick={prevStep}
variant="secondary"
type="button">
Back
</Button>
)}
<Button type="submit">
{isLastStep ? 'Submit' : isSubmitting ? 'Saving...!' : 'Save & Continue'}
</Button>
</div>
</div>
)
}
function MyStepperFooter() {
const { activeStep, resetSteps, steps } = useStepper()
if (activeStep !== steps.length) {
return null
}
return (
<div className="flex items-center justify-end gap-2">
<Button onClick={resetSteps}>Reset Stepper with Form</Button>
</div>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment