Skip to content

Commit

Permalink
Add React Hook Form and Zod (#1306)
Browse files Browse the repository at this point in the history
* Add packages, set up zod with translations

* Update translation files

* Create form components (from shadcn/ui)

* Add styling for FormLabel

* Create ExampleForm and schema for username/pass

* Add width: 100% to input styling

* Add dropdown to example, render validated data

* Wrap Dropdown in forwardRef for proper control by react-hook-form

* Automatically convert to Number for number inputs on onChange

* Add number field to example form

* Add missing number_input css class

* Fix Checkbox component

* Add Checkbox to example form

* Simplify Checkbox component

* Fix SamfFormFieldTypes after Checkbox changes

* Begin docs

* Biome

* Move schema definition outside ExampleForm

* Update docs

* Update index title
  • Loading branch information
robines authored Sep 25, 2024
1 parent 742c4ac commit 246ad74
Show file tree
Hide file tree
Showing 23 changed files with 913 additions and 159 deletions.
2 changes: 1 addition & 1 deletion docs/technical/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
### Frontend

- [Creating react components (conventions)](/docs/technical/frontend/components.md)
- [Input forms using SamfForm](/docs/technical/frontend/forms.md)
- [Forms and schemas](/docs/technical/frontend/forms.md)
- [Cypress Setup Documentation](/docs/technical/frontend/cypress.md)
### Backend

Expand Down
175 changes: 80 additions & 95 deletions docs/technical/frontend/forms.md
Original file line number Diff line number Diff line change
@@ -1,119 +1,104 @@
[👈 back](/docs/technical/README.md)

# SamfForm
# Forms

SamfForm is a generic react component that makes it very easy to create forms for all kinds of data. The form automatically handles validation and UI for you, so you only have to specify things like field names, input type and labels:
We use [React Hook Form](https://react-hook-form.com/) and [Zod](https://zod.dev/) for building and validating frontend
forms. We also use wrapper components (lifted from [shadcn/ui](https://ui.shadcn.com/docs/components/form)), which wrap
around the React Hook Form library. These projects have excellent documentation, so we won't try to replace them here.
This document will simply act as a "Getting started" guide, along with some quick pointers.

```html
<SamfForm onSubmit={yourSubmitFunction} submitButton="Save Name">
<SamfFormField field="name" type="text" label="Enter name"/>
<SamfFormField field="age" type="number" label="Enter age"/>
</SamfForm>
```

All fields are required by default (so an empty string will not be allowed). When you have filled in the name and click the submit button, `yourSubmitFunction` will be called with the entered data, (eg. `{'name': 'Sanctus', 'age': 69}`) as a parameter.

## Usage
We use [zod-i18n](https://github.com/aiji42/zod-i18n) for translating error messages.

- Use the `<SamfForm>` component to create a new form. The component accepts any children, so you can place things in columns or add whatever wrappers you want.
- Add inputs by placing `<SamfFormField>` components inside the form. You can set labels, value types and validation settings for these inputs.
## Schema

- **IMPORTANT:**
- SamfForm does not care about elements other than `<SamfFormField>`. An `<input>` tag will, for instance, not get validation nor will its data be included in `onSubmit`.
We define all schema fields in `./frontend/schema/`. This means we only have to define fields once, and lets us easily
create new schemas using existing fields. The form schemas themselves are defined where they are used, meaning alongside
the forms.

### Form Properties
Avoid adding options to the fields such as `nullish()`/`optional()`, as those should be decided by the schema using the
fields. The fields should only contain the minimum required for validation. For instance, the `USERNAME` field is
defined like so:

Forms should use either `onSubmit` or `onChange`. Submit is useful for typical forms where you post/put data. By defining a form with a type (inside the `< >` after `SamfForm`) you can easily model any datatype.

#### Posting/putting data
```tsx
function postEvent(event: EventDto) {
// your posting logic
}
```ts
export const USERNAME = z.string().min(USERNAME_LENGTH_MIN).max(USERNAME_LENGTH_MAX);
```

```html
<SamfForm<EventDto> onSubmit={postEvent}>
<!-- Your input fields -->
</SamfForm>
```
This lets us easily use it in a Zod schema, and make it optional like so:

#### Storing data in a state
```ts
const schema = z.object({
username: USERNAME.optional(),
});
```

If the component needs to display some information about the form while you are editing, you can use the `onChange` property to get notified when data changes.
## Defining forms

```tsx
const [event, setEvent] = useState<EventDto>(undefined);
```
```html
<SamfForm<EventDto> onChange={setEvent}>
<!-- Your input fields -->
</SamfForm>
```
Always define forms in their own files, to keep code clean.

You can also use `onValidityChanged` to get a simple boolean indicating if the form is valid or not (eg. if some fields are missing).
To get started, create a new file, for example `YourForm.tsx`. This file will contain the form schema and the form
itself. Define a schema using zod. Remember to reuse fields when possible as mentioned in the section above (we won't do
this here for example's sake).

#### Setting initial data
```typescript jsx
import { z } from 'zod';

If you are changing existing data (for instance when doing a http PATCH), set the `initialData` property of the form. The form expects a partial object which allows you to only include some of the fields in the Dto.
const schema = z.object({
username: z.string().min(3).max(24),
});
```

```tsx
const event: Partial<EventDto> = {
title_nb: 'some title',
title_en: 'some title',
Create your form component, and use the `useForm` hook to create the form.

Create the form component, and use the `useForm` hook with your schema,.

```typescript jsx
export function YourForm() {
// 1. Define the form
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
username: '',
},
});

// 2. Define the submit handler
function onSubmit(values: z.infer<typeof schema>) {
// These values are type-safe and validated
console.log(values);
}
}
```
```html
<SamfForm<EventDto> initialData={event}>
<!-- Your input fields -->
</SamfForm>
```

#### Advanced usage

- Set `validateOnInit` to check validity instantly. Missing fields will be marked with red.
- Set `devMode` to get a live preview of all the form values, errors and fields

### Input Fields

All inputs area created using `<SamfFormField>`. Required properties are:
- `type` Which type of input is used, eg. 'text', 'number', 'image', 'date' etc. See `SamfFormFieldTypes`.
- `field` The name of the property to set. In an `EventDto` you may want to use a field like `title_nb` or `duration`.

Optional properties include:
- `required` whether the field is invalid when empty/undefined. Default `true`.
- `label` a text string label that is shown above the input
- `options` a list of `DropDownOption` used for dropdown inputs
- `defaultOption` a `DropDownOption` set as the default for dropdown inputs
- `validator` a custom validation function which checks special requirements

Example:

```html
<SamfFormField type="text" field="title_en" label="English title" />
<SamfFormField type="text-long" field="description_en' label="English description" />
<SamfFormField type="text" field="social_media_url" label="Social media" required={false}/>
<SamfFormField type="image" field="image" label="Event Image"/>
Now use the `Form` wrapper components to build our form.

```typescript jsx
export function YourForm() {
// ...

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}
```

Option Example (with value type inside the `< >`):
```tsx
const myOptions: DropDownOption<number>[] = [
{value: 1, label: "One"},
{value: 2, label: "Two"},
]
```
```html
<SamfFormField<number>
type="options"
field="some_number_field"
label="Pick a number"
options={myOptions}
defaultOption={myOptions[0]}
/>
```
## Example

## Implementation details
To see an example form in action, check out the form on the [components page](http://localhost:3000/components),
and [its code](../../../frontend/src/Pages/ComponentPage/ExampleForm.tsx).

TODO
119 changes: 119 additions & 0 deletions docs/technical/frontend/samfform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
[👈 back](/docs/technical/README.md)

# SamfForm

SamfForm is a generic react component that makes it very easy to create forms for all kinds of data. The form automatically handles validation and UI for you, so you only have to specify things like field names, input type and labels:

```html
<SamfForm onSubmit={yourSubmitFunction} submitButton="Save Name">
<SamfFormField field="name" type="text" label="Enter name"/>
<SamfFormField field="age" type="number" label="Enter age"/>
</SamfForm>
```

All fields are required by default (so an empty string will not be allowed). When you have filled in the name and click the submit button, `yourSubmitFunction` will be called with the entered data, (eg. `{'name': 'Sanctus', 'age': 69}`) as a parameter.

## Usage

- Use the `<SamfForm>` component to create a new form. The component accepts any children, so you can place things in columns or add whatever wrappers you want.
- Add inputs by placing `<SamfFormField>` components inside the form. You can set labels, value types and validation settings for these inputs.

- **IMPORTANT:**
- SamfForm does not care about elements other than `<SamfFormField>`. An `<input>` tag will, for instance, not get validation nor will its data be included in `onSubmit`.

### Form Properties

Forms should use either `onSubmit` or `onChange`. Submit is useful for typical forms where you post/put data. By defining a form with a type (inside the `< >` after `SamfForm`) you can easily model any datatype.

#### Posting/putting data
```tsx
function postEvent(event: EventDto) {
// your posting logic
}
```

```html
<SamfForm<EventDto> onSubmit={postEvent}>
<!-- Your input fields -->
</SamfForm>
```

#### Storing data in a state

If the component needs to display some information about the form while you are editing, you can use the `onChange` property to get notified when data changes.

```tsx
const [event, setEvent] = useState<EventDto>(undefined);
```
```html
<SamfForm<EventDto> onChange={setEvent}>
<!-- Your input fields -->
</SamfForm>
```

You can also use `onValidityChanged` to get a simple boolean indicating if the form is valid or not (eg. if some fields are missing).

#### Setting initial data

If you are changing existing data (for instance when doing a http PATCH), set the `initialData` property of the form. The form expects a partial object which allows you to only include some of the fields in the Dto.

```tsx
const event: Partial<EventDto> = {
title_nb: 'some title',
title_en: 'some title',
}
```
```html
<SamfForm<EventDto> initialData={event}>
<!-- Your input fields -->
</SamfForm>
```

#### Advanced usage

- Set `validateOnInit` to check validity instantly. Missing fields will be marked with red.
- Set `devMode` to get a live preview of all the form values, errors and fields

### Input Fields

All inputs area created using `<SamfFormField>`. Required properties are:
- `type` Which type of input is used, eg. 'text', 'number', 'image', 'date' etc. See `SamfFormFieldTypes`.
- `field` The name of the property to set. In an `EventDto` you may want to use a field like `title_nb` or `duration`.

Optional properties include:
- `required` whether the field is invalid when empty/undefined. Default `true`.
- `label` a text string label that is shown above the input
- `options` a list of `DropDownOption` used for dropdown inputs
- `defaultOption` a `DropDownOption` set as the default for dropdown inputs
- `validator` a custom validation function which checks special requirements

Example:

```html
<SamfFormField type="text" field="title_en" label="English title" />
<SamfFormField type="text-long" field="description_en' label="English description" />
<SamfFormField type="text" field="social_media_url" label="Social media" required={false}/>
<SamfFormField type="image" field="image" label="Event Image"/>
```
Option Example (with value type inside the `< >`):
```tsx
const myOptions: DropDownOption<number>[] = [
{value: 1, label: "One"},
{value: 2, label: "Two"},
]
```
```html
<SamfFormField<number>
type="options"
field="some_number_field"
label="Pick a number"
options={myOptions}
defaultOption={myOptions[0]}
/>
```
## Implementation details
TODO
7 changes: 6 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
"@babel/core": "^7.23.2",
"@babel/plugin-syntax-flow": "^7.22.5",
"@babel/plugin-transform-react-jsx": "^7.22.15",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-slot": "^1.1.0",
"axios": "^1.7.4",
"classnames": "^2.3.2",
"cmdk": "^0.2.0",
Expand All @@ -56,14 +58,17 @@
"react-cookie": "^6.1.1",
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-hook-form": "^7.53.0",
"react-loading-skeleton": "^3.3.1",
"react-markdown": "^9.0.0",
"react-modal": "^3.16.1",
"react-refresh": "^0.14.0",
"react-router-dom": "^6.16.0",
"react-toastify": "^9.1.3",
"vite-plugin-svgr": "^4.1.0",
"web-vitals": "^3.5.0"
"web-vitals": "^3.5.0",
"zod": "^3.23.8",
"zod-i18n-map": "^2.27.0"
},
"devDependencies": {
"@biomejs/biome": "1.8.2",
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ import 'react-toastify/dist/ReactToastify.min.css';
// Neccessary import for translations.
import { CommandMenu, UserFeedback, useScrollToTop } from './Components';
import './i18n/i18n';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { makeZodI18nMap } from 'zod-i18n-map';

export function App() {
const goatCounterCode = import.meta.env.VITE_GOATCOUNTER_CODE;
const isDev = import.meta.env.DEV;
const localSetup = isDev ? '{"allow_local": true}' : undefined;
const isDarkTheme = useIsDarkTheme();

// Make form error messages automatically translate
const { t } = useTranslation();
z.setErrorMap(makeZodI18nMap({ t, handlePath: false }));

// Must be called within <BrowserRouter> because it uses hook useLocation().
useGoatCounter();
useScrollToTop();
Expand Down
Loading

0 comments on commit 246ad74

Please sign in to comment.