Skip to content
This repository has been archived by the owner on Feb 10, 2025. It is now read-only.

Commit

Permalink
feat: modular form (#102)
Browse files Browse the repository at this point in the history
* adds Select

* adds markdownEditor

* add file upload

* progres bar improvement

* adds modular form and custom controled reused fields

* adds index db persist feature

* removed pnpm-lock file

* fix build

* fix build

* hooks - lib refactoring

* minor ui updates

* modular form refactoring

* creates the address allowlist component

* creates the applicationQuestions form component

* creates the fieldArray form component

* creates the Select form component

* creates the FileUpload form component

* creates the MarkdownEditor form component

* creates the Metrics form component

* creates the Round-dates  form component

* creates the DisabledProgram field  form component

* adds the updated story configuration

* cleaning

* renamed lib/form to lib/indexDB
  • Loading branch information
nijoe1 authored Jan 6, 2025
1 parent 9940551 commit 91a3f62
Show file tree
Hide file tree
Showing 68 changed files with 3,261 additions and 9 deletions.
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,19 +170,26 @@
"@radix-ui/react-tooltip": "^1.1.3",
"@storybook/addon-actions": "8.3.5",
"@tanstack/react-query": "^5.59.15",
"@types/papaparse": "^5.3.15",
"@uiw/react-markdown-preview": "^5.1.3",
"@uiw/react-md-editor": "^4.0.4",
"cmdk": "1.0.0",
"embla-carousel-react": "^8.3.0",
"graphql-request": "^7.1.0",
"idb": "^8.0.1",
"input-otp": "^1.2.4",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"next-themes": "^0.3.0",
"papaparse": "^5.4.1",
"react-day-picker": "9.3.2",
"react-hook-form": "^7.53.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.4",
"react-use": "^17.6.0",
"recharts": "^2.13.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0",
"sonner": "^1.5.0",
"tailwind-variants": "^0.2.1",
Expand Down
323 changes: 323 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions src/components/Form/Form.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Meta, StoryObj } from "@storybook/react";

import { Form, FormProps } from "@/components/Form";
import { FormField } from "@/components/Form/types/fieldTypes";

const fields: FormField[] = [
{
field: {
name: "roundName",
label: "Round name",
className: "border-grey-300",
validation: { stringValidation: { minLength: 7 } },
},
component: "Input",
placeholder: "your cool round name",
},

{
field: {
name: "roundDescription",
label: "Round description",
validation: { required: true },
},
component: "MarkdownEditor",
},
{
field: {
name: "select",
label: "Select",
validation: { required: true },
},
component: "Select",
options: [
{
items: [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
],
},
{
items: [
{ label: "Carrot", value: "carrot" },
{ label: "Lettuce", value: "lettuce" },
],
},
],
placeholder: "Select",
variant: "filled",
size: "md",
},
{
field: {
name: "fileUpload",
label: "File upload",
validation: { required: true },
},
component: "FileUpload",
mimeTypes: ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"],
},
];

export default {
title: "Components/Form",
component: Form,
} as Meta;
type Story = StoryObj<FormProps>;

export const Default: Story = {
args: {
fields,
persistKey: "storybook-form",
},
render: (args) => {
return (
<div>
<Form {...args} />
</div>
);
},
};
83 changes: 83 additions & 0 deletions src/components/Form/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ForwardedRef, forwardRef, useImperativeHandle } from "react";
import { FormProvider, useFormContext } from "react-hook-form";

import { useFormWithPersist } from "@/hooks";

import { FormField } from "./types/fieldTypes";
import { buildSchemaFromFields } from "./utils/buildSchemaFromFields";
import { componentRegistry } from "./utils/componentRegistry";

export interface FormProps {
persistKey: string;
fields: FormField[];
defaultValues?: any;
dbName: string;
storeName: string;
}

export const Form = forwardRef(function Form(
{ persistKey, fields, defaultValues, dbName, storeName }: FormProps,
ref: ForwardedRef<{ isFormValid: () => Promise<boolean> }>,
) {
const schema = buildSchemaFromFields(fields);

const form = useFormWithPersist({ schema, defaultValues, persistKey, dbName, storeName });

useImperativeHandle(ref, () => ({
isFormValid: async () => {
try {
const isValid = await form.trigger(); // Trigger validation on all fields
if (!isValid) return false;

return true;
} catch (error) {
console.error(error);
return false;
}
},
}));

return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(() => void 0)} className="flex flex-col gap-4">
{fields.map((field) => (
<FormControl key={field.field.name} field={field} />
))}
</form>
</FormProvider>
);
});
function FormControl({ field }: { field: FormField }) {
const {
register,
formState: { errors },
control,
} = useFormContext();

type FieldOfType = Extract<FormField, { component: typeof field.component }>;
const { field: fieldProps, ...componentProps } = field as FieldOfType;

const registryEntry = componentRegistry[field.component];
const { Component, isControlled } = registryEntry;

const props = isControlled
? { ...componentProps, name: fieldProps.name, control }
: { ...componentProps, ...register(fieldProps.name) };

return (
<div className="flex flex-col justify-center gap-2">
<div className="flex items-center justify-between">
<label htmlFor={fieldProps.name} className="block text-[14px]/[20px] font-medium">
{fieldProps.label}
</label>
{fieldProps.validation?.required && (
<div className="text-[14px]/[16px] text-moss-700">*Required</div>
)}
</div>
<Component {...props} />
{errors[fieldProps.name]?.message && (
<p className="text-sm text-red-300">{String(errors[fieldProps.name]?.message)}</p>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// src/components/Form/FormAllowlistController.tsx
import React, { useRef } from "react";
import { useFormContext, Controller } from "react-hook-form";

import { UploadIcon } from "@heroicons/react/solid";
import Papa from "papaparse";
import { getAddress, isAddress } from "viem";

import { Button } from "@/primitives/Button";
import { TextArea } from "@/primitives/TextArea";

export interface AllowlistFormControllerProps {
/** The name of the form field */
name: string;
}

export const AllowlistFormController: React.FC<AllowlistFormControllerProps> = ({ name }) => {
const { control, setValue } = useFormContext();
const fileInputRef = useRef<HTMLInputElement>(null);

/**
* Handles CSV file upload and extracts valid addresses
* @param event - The input change event
*/
const handleCSVUpload = (event: React.ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
if (!file) return;

Papa.parse(file, {
complete: ({ data }) => {
const uniqueAddresses = Array.from(
new Set(
data
.flat()
.filter((item): item is string => typeof item === "string" && isAddress(item.trim())),
),
).join(",");

const formattedAddresses = uniqueAddresses.split(",").map((addr) => getAddress(addr));

setValue(name, formattedAddresses);
},
skipEmptyLines: true,
});

// Reset the input value to allow re-uploading the same file if needed
event.target.value = "";
};

return (
<div className="flex flex-col gap-4">
<div className="flex justify-start">
<Button
value="Import CSV"
className="bg-grey-100 text-grey-900"
icon={<UploadIcon className="size-4 text-grey-900" />}
onClick={() => fileInputRef.current?.click()}
/>
<input
type="file"
id="csv-upload"
className="hidden"
accept=".csv"
onChange={handleCSVUpload}
ref={fileInputRef}
/>
</div>

<Controller
name={name}
control={control}
render={({ field }) => (
<TextArea
value={field.value}
onChange={(e) => field.onChange(e.target.value.split(","))}
placeholder="Enter all the addresses as a comma-separated list here..."
className="min-h-52 w-full"
/>
)}
/>

<p className="text-sm text-grey-500">
Enter all the addresses as a comma-separated list below. Duplicates and invalid addresses
will automatically be removed.
</p>
</div>
);
};

/**
* Utility function to validate a list of addresses
* @param addresses - A comma-separated string of addresses
* @returns An array of valid, trimmed addresses
*/
export const validateAddresses = (addresses: string): string[] => {
return addresses
.split(",")
.map((addr) => addr.trim())
.filter((addr) => isAddress(addr));
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./AllowlistFormController";
Loading

0 comments on commit 91a3f62

Please sign in to comment.