Compare commits

..

25 Commits

Author SHA1 Message Date
f21e98175f
fix: archive repository
This project is not anymore maintained.
2024-11-11 14:02:41 +01:00
141abe01b3
fix: update dependencies to latest (notably @sinclair/typebox) 2024-01-30 01:45:09 +01:00
d9af7f415c
docs(license): add email address 2024-01-30 01:37:56 +01:00
4a975e431a
docs: fix badges 2023-12-26 22:39:17 +01:00
8168a18ede
fix: update dependencies to latest 2023-12-26 22:24:32 +01:00
a49e844c70
chore: better Prettier config for easier reviews 2023-10-23 23:26:27 +02:00
1224ece116
chore: add missing package-lock.json
All checks were successful
Build / build (push) Successful in 1m57s
Lint / lint (push) Successful in 48s
Test / test (push) Successful in 40s
Test / test-e2e (push) Successful in 2m10s
2023-07-18 23:28:17 +02:00
41b4472870
build(deps): update latest 2023-07-18 23:16:52 +02:00
6cb0c3fb1b
fix: update author - Théo LUDWIG 2023-07-02 16:27:26 +02:00
81290836f3
fix: update dependencies to latest 2023-07-02 16:23:44 +02:00
Divlo
da5d46835d
perf: enable tree-shaking 2023-05-13 18:24:44 +02:00
Divlo
ef5635380c
feat: add npm package provenance
Ref: https://github.blog/2023-04-19-introducing-npm-package-provenance/
2023-05-13 17:02:20 +02:00
Divlo
882416cb49
build(deps): update latest 2023-05-13 17:00:31 +02:00
Divlo
040e3a0ae1
style: fix linting 2023-04-02 22:10:52 +02:00
Divlo
5bb73df804
fix: rename value to message in HandleUseFormCallback return type
BREAKING CHANGE: Migrate your onSubmit handlers to return a `message` instead of `value`
2023-04-02 22:08:32 +02:00
Divlo
69f12002c7
build(deps): update latest
BREAKING CHANGE: peerDependencies: `react@>=18.2.0`
2023-04-02 21:52:34 +02:00
Divlo
85eb53d60c
chore: fix vercel build error for example 2023-01-10 21:53:53 +01:00
Divlo
45c072f2bd
style: fix linting 2023-01-10 21:27:25 +01:00
Divlo
cdff824ca5
fix: update dependencies to latest 2023-01-10 21:23:32 +01:00
Divlo
54ef5ceea1
ci: fix timeout 2022-11-08 11:40:39 +01:00
Divlo
48d4fb6f75
build(deps): bump Next.js to v13 2022-11-08 11:28:57 +01:00
Divlo
1683474fa6
chore: remove usage of styled-jsx 2022-10-03 21:23:17 +02:00
Divlo
a37453a115
style(example): fix linting 2022-09-21 09:38:57 +02:00
Divlo
fcc2b2ea77
fix(types): improve Schema type for useForm 2022-09-21 09:33:09 +02:00
Divlo
d213893d5d
ci: avoid running twice (develop and master branch) [skip-ci] 2022-08-26 23:58:19 +02:00
65 changed files with 10803 additions and 24259 deletions

View File

@ -1,4 +1,4 @@
# For more information see: https://editorconfig.org/ # https://editorconfig.org/
root = true root = true

View File

@ -1,5 +0,0 @@
node_modules
build
dist
.parcel-cache
example

View File

@ -1,6 +1,8 @@
{ {
"extends": ["conventions", "prettier"], "extends": ["conventions", "prettier"],
"ignorePatterns": ["tsup.config.js", "example"],
"plugins": ["prettier", "import", "unicorn"], "plugins": ["prettier", "import", "unicorn"],
"parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"project": "./tsconfig.json" "project": "./tsconfig.json"
}, },

View File

@ -1,8 +1,8 @@
--- ---
name: '🐛 Bug Report' name: "🐛 Bug Report"
about: 'Report an unexpected problem or unintended behavior.' about: "Report an unexpected problem or unintended behavior."
title: '[Bug]' title: "[Bug]"
labels: 'bug' labels: "bug"
--- ---
<!-- <!--

View File

@ -1,8 +1,8 @@
--- ---
name: '📜 Documentation' name: "📜 Documentation"
about: 'Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...).' about: "Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...)."
title: '[Documentation]' title: "[Documentation]"
labels: 'documentation' labels: "documentation"
--- ---
<!-- Please make sure your issue has not already been fixed. --> <!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
--- ---
name: '✨ Feature Request' name: "✨ Feature Request"
about: 'Suggest a new feature idea.' about: "Suggest a new feature idea."
title: '[Feature]' title: "[Feature]"
labels: 'feature request' labels: "feature request"
--- ---
<!-- Please make sure your issue has not already been fixed. --> <!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
--- ---
name: '🔧 Improvement' name: "🔧 Improvement"
about: 'Improve structure/format/performance/refactor/tests of the code.' about: "Improve structure/format/performance/refactor/tests of the code."
title: '[Improvement]' title: "[Improvement]"
labels: 'improvement' labels: "improvement"
--- ---
<!-- Please make sure your issue has not already been fixed. --> <!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
--- ---
name: '🙋 Question' name: "🙋 Question"
about: 'Further information is requested.' about: "Further information is requested."
title: '[Question]' title: "[Question]"
labels: 'question' labels: "question"
--- ---
### Question ### Question

View File

@ -1,6 +1,6 @@
<!-- Please first discuss the change you wish to make via issue before making a change. It might avoid a waste of your time. --> <!-- Please first discuss the change you wish to make via issue before making a change. It might avoid a waste of your time. -->
## What changes this PR introduce? # What changes this PR introduce?
## List any relevant issue numbers ## List any relevant issue numbers

View File

@ -1,28 +1,28 @@
name: 'Build' name: "Build"
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]
jobs: jobs:
build: build:
runs-on: 'ubuntu-latest' runs-on: "ubuntu-latest"
steps: steps:
- uses: 'actions/checkout@v3.0.0' - uses: "actions/checkout@v4.1.1"
- name: 'Use Node.js' - name: "Setup Node.js"
uses: 'actions/setup-node@v3.1.0' uses: "actions/setup-node@v4.0.1"
with: with:
node-version: 'lts/*' node-version: "lts/*"
cache: 'npm' cache: "npm"
- name: 'Install' - name: "Install dependencies"
run: 'npm install' run: "npm clean-install"
- name: 'Build Package' - name: "Build Package"
run: 'npm run build' run: "npm run build"
- name: 'Build Example' - name: "Build Example"
run: 'cd example && npm install && npm run build' run: "cd example && npm clean-install && npm run build"

View File

@ -1,28 +1,28 @@
name: 'Lint' name: "Lint"
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]
jobs: jobs:
lint: lint:
runs-on: 'ubuntu-latest' runs-on: "ubuntu-latest"
steps: steps:
- uses: 'actions/checkout@v3.0.0' - uses: "actions/checkout@v4.1.1"
- name: 'Use Node.js' - name: "Setup Node.js"
uses: 'actions/setup-node@v3.1.0' uses: "actions/setup-node@v4.0.1"
with: with:
node-version: 'lts/*' node-version: "lts/*"
cache: 'npm' cache: "npm"
- name: 'Install' - name: "Install dependencies"
run: 'npm install' run: "npm clean-install"
- run: 'npm run lint:commit -- --to "${{ github.sha }}"' - run: 'npm run lint:commit -- --to "${{ github.sha }}"'
- run: 'npm run lint:editorconfig' - run: "npm run lint:editorconfig"
- run: 'npm run lint:markdown' - run: "npm run lint:markdown"
- run: 'npm run lint:typescript' - run: "npm run lint:eslint"
- run: 'npm run lint:prettier' - run: "npm run lint:prettier"

View File

@ -1,4 +1,4 @@
name: 'Release' name: "Release"
on: on:
push: push:
@ -6,24 +6,32 @@ on:
jobs: jobs:
build: build:
runs-on: 'ubuntu-latest' runs-on: "ubuntu-latest"
permissions:
contents: "write"
issues: "write"
pull-requests: "write"
id-token: "write"
steps: steps:
- uses: 'actions/checkout@v3.0.0' - uses: "actions/checkout@v4.1.1"
- name: 'Use Node.js' - name: "Setup Node.js"
uses: 'actions/setup-node@v3.1.0' uses: "actions/setup-node@v4.0.1"
with: with:
node-version: 'lts/*' node-version: "lts/*"
cache: 'npm' cache: "npm"
- name: 'Install' - name: "Install dependencies"
run: 'npm install' run: "npm clean-install"
- name: 'Build Package' - name: "Build Package"
run: 'npm run build' run: "npm run build"
- name: 'Release' - name: "Verify the integrity of provenance attestations and registry signatures for installed dependencies"
run: 'npm run release' run: "npm audit signatures"
- name: "Release"
run: "npm run release"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -1,48 +1,48 @@
name: 'Test' name: "Test"
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]
jobs: jobs:
test: test:
runs-on: 'ubuntu-latest' runs-on: "ubuntu-latest"
steps: steps:
- uses: 'actions/checkout@v3.0.0' - uses: "actions/checkout@v4.1.1"
- name: 'Use Node.js' - name: "Setup Node.js"
uses: 'actions/setup-node@v3.1.0' uses: "actions/setup-node@v4.0.1"
with: with:
node-version: 'lts/*' node-version: "lts/*"
cache: 'npm' cache: "npm"
- name: 'Install' - name: "Install dependencies"
run: 'npm install' run: "npm clean-install"
- name: 'Test' - name: "Test"
run: 'npm run test' run: "npm run test"
test-e2e: test-e2e:
runs-on: 'ubuntu-latest' runs-on: "ubuntu-latest"
steps: steps:
- uses: 'actions/checkout@v3.0.0' - uses: "actions/checkout@v4.1.1"
- name: 'Use Node.js' - name: "Setup Node.js"
uses: 'actions/setup-node@v3.1.0' uses: "actions/setup-node@v4.0.1"
with: with:
node-version: 'lts/*' node-version: "lts/*"
cache: 'npm' cache: "npm"
- name: 'Install' - name: "Install dependencies"
run: 'npm install' run: "npm clean-install"
- name: 'Build Package' - name: "Build Package"
run: 'npm run build' run: "npm run build"
- name: 'Build Example' - name: "Build Example"
run: 'cd example && npm install && npm run build' run: "cd example && npm clean-install && npm run build"
- name: 'End To End (e2e) Test Example' - name: "End To End (e2e) Test Example"
run: 'cd example && npm run test:e2e' run: "cd example && npm run test:e2e"

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ node_modules
# production # production
build build
dist dist
.next
# testing # testing
coverage coverage

View File

@ -1,11 +1,11 @@
{ {
"config": { "config": {
"extends": "markdownlint/style/prettier",
"default": true, "default": true,
"MD013": false, "relative-links": true,
"MD024": false, "no-inline-html": false,
"MD033": false,
"MD041": false
}, },
"globs": ["**/*.{md,mdx}"], "globs": ["**/*.md"],
"ignores": ["**/node_modules"] "ignores": ["**/node_modules"],
"customRules": ["markdownlint-rule-relative-links"],
} }

1
.npmrc
View File

@ -1 +1,2 @@
save-exact=true save-exact=true
provenance=true

View File

@ -1,6 +1,3 @@
{ {
"singleQuote": true, "semi": false
"jsxSingleQuote": true,
"semi": false,
"trailingComma": "none"
} }

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
contact@divlo.fr. <contact@theoludwig.fr>.
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the

View File

@ -6,6 +6,10 @@ Thanks a lot for your interest in contributing to **react-component-form**! 🎉
**react-component-form** has adopted the [Contributor Covenant](https://www.contributor-covenant.org/) as its Code of Conduct, and we expect project participants to adhere to it. Please read [the full text](./CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated. **react-component-form** has adopted the [Contributor Covenant](https://www.contributor-covenant.org/) as its Code of Conduct, and we expect project participants to adhere to it. Please read [the full text](./CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated.
## Open Development
All work on **react-component-form** happens directly on this repository. Both core team members and external contributors send pull requests which go through the same review process.
## Types of contributions ## Types of contributions
- Reporting a bug. - Reporting a bug.
@ -25,28 +29,4 @@ If you're adding new features to **react-component-form**, please include tests.
## Commits ## Commits
The commit message guidelines respect The commit message guidelines adheres to [Conventional Commits](https://www.conventionalcommits.org/) and [Semantic Versioning](https://semver.org/) for releases.
[@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional)
and [Semantic Versioning](https://semver.org/) for releases.
### Types
Types define which kind of changes you made to the project.
| Types | Description |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| feat | A new feature. |
| fix | A bug fix. |
| docs | Documentation only changes. |
| style | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc). |
| refactor | A code change that neither fixes a bug nor adds a feature. |
| perf | A code change that improves performance. |
| test | Adding missing tests or correcting existing tests. |
| build | Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm). |
| ci | Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs). |
| chore | Other changes that don't modify src or test files. |
| revert | Reverts a previous commit. |
### Scopes
Scopes define what part of the code changed.

View File

@ -1,6 +1,6 @@
MIT License # MIT License
Copyright (c) Divlo Copyright (c) Théo LUDWIG <contact@theoludwig.fr>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -5,13 +5,17 @@
</p> </p>
<p align="center"> <p align="center">
<a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a> <strong>⚠️ This project is not maintained anymore, you can still use the code as you wish and fork it to maintain it yourself.</strong>
</p>
<p align="center">
<a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="CONTRIBUTING /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/licence-MIT-blue.svg" alt="Licence MIT"/></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/licence-MIT-blue.svg" alt="Licence MIT"/></a>
<a href="./CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Contributor Covenant" /></a> <a href="./CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Contributor Covenant" /></a>
<br /> <br />
<a href="https://github.com/Divlo/react-component-form/actions/workflows/build.yml"><img src="https://github.com/Divlo/react-component-form/actions/workflows/build.yml/badge.svg?branch=master" /></a> <a href="https://github.com/theoludwig/react-component-form/actions/workflows/build.yml"><img src="https://github.com/theoludwig/react-component-form/actions/workflows/build.yml/badge.svg?branch=develop" alt="Build" /></a>
<a href="https://github.com/Divlo/react-component-form/actions/workflows/lint.yml"><img src="https://github.com/Divlo/react-component-form/actions/workflows/lint.yml/badge.svg?branch=master" /></a> <a href="https://github.com/theoludwig/react-component-form/actions/workflows/lint.yml"><img src="https://github.com/theoludwig/react-component-form/actions/workflows/lint.yml/badge.svg?branch=develop" alt="Lint" /></a>
<a href="https://github.com/Divlo/react-component-form/actions/workflows/test.yml"><img src="https://github.com/Divlo/react-component-form/actions/workflows/test.yml/badge.svg?branch=master" /></a> <a href="https://github.com/theoludwig/react-component-form/actions/workflows/test.yml"><img src="https://github.com/theoludwig/react-component-form/actions/workflows/test.yml/badge.svg?branch=develop" alt="Test" /></a>
<br /> <br />
<a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a> <a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a>
<a href="https://github.com/semantic-release/semantic-release"><img src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg" alt="semantic-release" /></a> <a href="https://github.com/semantic-release/semantic-release"><img src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg" alt="semantic-release" /></a>
@ -37,9 +41,9 @@ npm install --save react-component-form
_Note: The examples use TypeScript, but obviously you can use JavaScript. Be aware that `HandleForm` is the type definition for the `onChange` and `onSubmit` props._ _Note: The examples use TypeScript, but obviously you can use JavaScript. Be aware that `HandleForm` is the type definition for the `onChange` and `onSubmit` props._
```tsx ```tsx
import React from 'react' import React from "react"
import { Form } from 'react-component-form' import { Form } from "react-component-form"
import type { HandleForm } from 'react-component-form' import type { HandleForm } from "react-component-form"
export const Example = () => { export const Example = () => {
const handleSubmit: HandleForm = (formData, formElement) => { const handleSubmit: HandleForm = (formData, formElement) => {
@ -49,8 +53,8 @@ export const Example = () => {
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<input type='text' name='inputName' /> <input type="text" name="inputName" />
<button type='submit'>Submit</button> <button type="submit">Submit</button>
</Form> </Form>
) )
} }
@ -70,16 +74,16 @@ This example shows how to use the `<Form />` component with `useForm` hook to va
You can see a more detailled example in the [./example](./example) folder. You can see a more detailled example in the [./example](./example) folder.
```tsx ```tsx
import React from 'react' import React from "react"
import { Form, useForm } from 'react-component-form' import { Form, useForm } from "react-component-form"
import type { HandleUseFormCallback } from 'react-component-form' import type { HandleUseFormCallback } from "react-component-form"
const schema = { const schema = {
inputName: { inputName: {
type: 'string', type: "string",
minLength: 3, minLength: 3,
maxLength: 20 maxLength: 20,
} },
} }
export const Example = () => { export const Example = () => {
@ -87,24 +91,24 @@ export const Example = () => {
const onSubmit: HandleUseFormCallback<typeof schema> = ( const onSubmit: HandleUseFormCallback<typeof schema> = (
formData, formData,
formElement formElement,
) => { ) => {
console.log(formData) // { inputName: 'value of the input validated' } console.log(formData) // { inputName: 'value of the input validated and type-safe' }
formElement.reset() formElement.reset()
// The return can be either `null` or an object with a global message of type `'error' | 'success'`. // The return can be either `null` or an object with a global message of type `'error' | 'success'`.
return { return {
type: 'success', type: "success",
value: 'Success: Form submitted' message: "Success: Form submitted",
} }
} }
return ( return (
<Form onSubmit={handleUseForm(onSubmit)}> <Form onSubmit={handleUseForm(onSubmit)}>
<input type='text' name='inputName' /> <input type="text" name="inputName" />
{errors.inputName != null && <p>{errors.inputName[0].message}</p>} {errors.inputName != null && <p>{errors.inputName[0].message}</p>}
<button type='submit'>Submit</button> <button type="submit">Submit</button>
{message != null && <p>{message}</p>} {message != null && <p>{message}</p>}
</Form> </Form>

View File

@ -1,27 +1,27 @@
import Translation from 'next-translate/Trans' import Translation from "next-translate/Trans"
import { Link } from './design/Link' import { Link } from "./design/Link"
import { TextSpecial } from './design/TextSpecial' import { TextSpecial } from "./design/TextSpecial"
export const About: React.FC = () => { export const About: React.FC = () => {
return ( return (
<section className='text-center mt-6'> <section className="text-center mt-6">
<h1 className='text-4xl'>{'<Form />'}</h1> <h1 className="text-4xl">{"<Form />"}</h1>
<h2 className='text-xl dark:text-gray-300 text-gray-600 mt-4'> <h2 className="text-xl dark:text-gray-300 text-gray-600 mt-4">
npm install --save{' '} npm install --save{" "}
<Link <Link
href='https://www.npmjs.com/package/react-component-form' href="https://www.npmjs.com/package/react-component-form"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
react-component-form react-component-form
</Link> </Link>
</h2> </h2>
<p className='max-w-lg mt-6 text-base' data-cy='main-description'> <p className="max-w-lg mt-6 text-base" data-cy="main-description">
<Translation <Translation
i18nKey='common:about' i18nKey="common:about"
components={[<TextSpecial key='special' />]} components={[<TextSpecial key="special" />]}
/> />
</p> </p>
</section> </section>

View File

@ -1,13 +1,16 @@
import { Form, HandleUseFormCallback, useForm } from 'react-component-form' "use client"
import useTranslation from 'next-translate/useTranslation'
import { Input } from './design/Input' import { Form, useForm } from "react-component-form"
import { Button } from './design/Button' import type { HandleUseFormCallback } from "react-component-form"
import { useFormTranslation } from '../hooks/useFormTranslation' import useTranslation from "next-translate/useTranslation"
import { userSchema } from '../models/User'
import { FormState } from './design/FormState'
const simulateServerRequest = async (ms: number): Promise<void> => { import { Input } from "./design/Input"
import { Button } from "./design/Button"
import { useFormTranslation } from "../hooks/useFormTranslation"
import { userSchema } from "../models/User"
import { FormState } from "./design/FormState"
const fakeServerRequest = async (ms: number): Promise<void> => {
return await new Promise((resolve) => { return await new Promise((resolve) => {
setTimeout(resolve, ms) setTimeout(resolve, ms)
}) })
@ -20,47 +23,47 @@ export const FormExample: React.FC = () => {
const onSubmit: HandleUseFormCallback<typeof userSchema> = async ( const onSubmit: HandleUseFormCallback<typeof userSchema> = async (
formData, formData,
formElement formElement,
) => { ) => {
await simulateServerRequest(2000) await fakeServerRequest(2_000)
console.log('onSubmit:', formData) console.log("onSubmit:", formData)
formElement.reset() formElement.reset()
return { return {
type: 'success', type: "success",
value: 'common:success-message' message: "common:success-message",
} }
} }
return ( return (
<section> <section>
<Form <Form
className='mt-6 w-[90%] max-w-xs' className="mt-6 w-[90%] max-w-xs"
noValidate noValidate
onSubmit={handleUseForm(onSubmit)} onSubmit={handleUseForm(onSubmit)}
> >
<Input <Input
type='text' type="text"
placeholder={t('common:name')} placeholder={t("common:name")}
name='name' name="name"
label={t('common:name')} label={t("common:name")}
error={getFirstErrorTranslation(errors.name)} error={getFirstErrorTranslation(errors.name)}
/> />
<Input <Input
type='text' type="text"
placeholder='Email' placeholder="Email"
name='email' name="email"
label='Email' label="Email"
error={getFirstErrorTranslation(errors.email)} error={getFirstErrorTranslation(errors.email)}
/> />
<Button className='mt-6 w-full' type='submit' data-cy='submit'> <Button className="mt-6 w-full" type="submit" data-cy="submit">
Submit Submit
</Button> </Button>
</Form> </Form>
<FormState <FormState
id='message' id="message"
state={fetchState} state={fetchState}
message={message != null ? t(message) : undefined} message={message != null ? t(message) : undefined}
/> />

View File

@ -1,9 +1,9 @@
import { Language } from './Language' import { Language } from "./Language"
import { SwitchTheme } from './SwitchTheme' import { SwitchTheme } from "./SwitchTheme"
export const Header: React.FC = () => { export const Header: React.FC = () => {
return ( return (
<header className='flex justify-center mt-6'> <header className="flex justify-center mt-6">
<Language /> <Language />
<SwitchTheme /> <SwitchTheme />
</header> </header>

View File

@ -1,15 +1,15 @@
export const Arrow: React.FC = () => { export const Arrow: React.FC = () => {
return ( return (
<svg <svg
width='12' width="12"
height='8' height="8"
viewBox='0 0 12 8' viewBox="0 0 12 8"
fill='none' fill="none"
xmlns='http://www.w3.org/2000/svg' xmlns="http://www.w3.org/2000/svg"
> >
<path <path
className='fill-current text-black dark:text-white' className="fill-current text-black dark:text-white"
d='M9.8024 0.292969L5.61855 4.58597L1.43469 0.292969L0.0566406 1.70697L5.61855 7.41397L11.1805 1.70697L9.8024 0.292969Z' d="M9.8024 0.292969L5.61855 4.58597L1.43469 0.292969L0.0566406 1.70697L5.61855 7.41397L11.1805 1.70697L9.8024 0.292969Z"
/> />
</svg> </svg>
) )

View File

@ -1,4 +1,4 @@
import Image from 'next/image' import Image from "next/image"
export interface LanguageFlagProps { export interface LanguageFlagProps {
language: string language: string
@ -16,7 +16,7 @@ export const LanguageFlag: React.FC<LanguageFlagProps> = (props) => {
src={`/images/languages/${language}.svg`} src={`/images/languages/${language}.svg`}
alt={language} alt={language}
/> />
<p data-cy='language-flag-text' className='mx-2 text-base'> <p data-cy="language-flag-text" className="mx-2 text-base">
{language.toUpperCase()} {language.toUpperCase()}
</p> </p>
</> </>

View File

@ -1,11 +1,11 @@
import { useCallback, useEffect, useState, useRef } from 'react' import { useCallback, useEffect, useState, useRef } from "react"
import useTranslation from 'next-translate/useTranslation' import useTranslation from "next-translate/useTranslation"
import setLanguage from 'next-translate/setLanguage' import setLanguage from "next-translate/setLanguage"
import classNames from 'clsx' import classNames from "clsx"
import i18n from '../../../i18n.json' import i18n from "../../../i18n.json"
import { Arrow } from './Arrow' import { Arrow } from "./Arrow"
import { LanguageFlag } from './LanguageFlag' import { LanguageFlag } from "./LanguageFlag"
export const Language: React.FC = () => { export const Language: React.FC = () => {
const { lang: currentLanguage } = useTranslation() const { lang: currentLanguage } = useTranslation()
@ -13,7 +13,9 @@ export const Language: React.FC = () => {
const languageClickRef = useRef<HTMLDivElement | null>(null) const languageClickRef = useRef<HTMLDivElement | null>(null)
const handleHiddenMenu = useCallback(() => { const handleHiddenMenu = useCallback(() => {
setHiddenMenu((oldHiddenMenu) => !oldHiddenMenu) setHiddenMenu((oldHiddenMenu) => {
return !oldHiddenMenu
})
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -26,10 +28,10 @@ export const Language: React.FC = () => {
} }
} }
window.document.addEventListener('click', handleClickEvent) window.document.addEventListener("click", handleClickEvent)
return () => { return () => {
return window.removeEventListener('click', handleClickEvent) return window.removeEventListener("click", handleClickEvent)
} }
}, []) }, [])
@ -38,11 +40,11 @@ export const Language: React.FC = () => {
} }
return ( return (
<div className='flex cursor-pointer flex-col items-center justify-center'> <div className="flex cursor-pointer flex-col items-center justify-center">
<div <div
ref={languageClickRef} ref={languageClickRef}
data-cy='language-click' data-cy="language-click"
className='mr-5 flex items-center' className="mr-5 flex items-center"
onClick={handleHiddenMenu} onClick={handleHiddenMenu}
> >
<LanguageFlag language={currentLanguage} /> <LanguageFlag language={currentLanguage} />
@ -50,10 +52,10 @@ export const Language: React.FC = () => {
</div> </div>
<ul <ul
data-cy='languages-list' data-cy="languages-list"
className={classNames( className={classNames(
'absolute top-14 z-10 mt-3 mr-4 flex w-24 list-none flex-col items-center justify-center rounded-lg bg-white p-0 shadow-lightFlag dark:bg-black dark:shadow-darkFlag', "absolute top-14 z-10 mt-3 mr-4 flex w-24 list-none flex-col items-center justify-center rounded-lg bg-white p-0 shadow-lightFlag dark:bg-black dark:shadow-darkFlag",
{ hidden: hiddenMenu } { hidden: hiddenMenu },
)} )}
> >
{i18n.locales.map((language, index) => { {i18n.locales.map((language, index) => {
@ -63,8 +65,10 @@ export const Language: React.FC = () => {
return ( return (
<li <li
key={index} key={index}
className='flex h-12 w-full items-center justify-center pl-2 hover:bg-[#4f545c] hover:bg-opacity-20' className="flex h-12 w-full items-center justify-center pl-2 hover:bg-[#4f545c] hover:bg-opacity-20"
onClick={async () => await handleLanguage(language)} onClick={async () => {
await handleLanguage(language)
}}
> >
<LanguageFlag language={language} /> <LanguageFlag language={language} />
</li> </li>

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from "react"
import { useTheme } from 'next-themes' import classNames from "clsx"
import { useTheme } from "next-themes"
export const SwitchTheme: React.FC = () => { export const SwitchTheme: React.FC = () => {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
@ -14,113 +15,64 @@ export const SwitchTheme: React.FC = () => {
} }
const handleClick = (): void => { const handleClick = (): void => {
setTheme(theme === 'dark' ? 'light' : 'dark') setTheme(theme === "dark" ? "light" : "dark")
} }
return ( return (
<>
<div <div
className='flex items-center' className="flex items-center"
data-cy='switch-theme-click' data-cy="switch-theme-click"
onClick={handleClick} onClick={handleClick}
> >
<div className='toggle-theme-button relative inline-block cursor-pointer bg-transparent'> <div className="relative inline-block cursor-pointer touch-pan-x select-none border-0 bg-transparent p-0">
<div className='toggle-track'> <div className="h-[24px] w-[50px] rounded-[30px] bg-[#4d4d4d] p-0 text-white transition-all duration-200 ease-in-out">
<div <div
data-cy='switch-theme-dark' data-cy="switch-theme-dark"
className='toggle-track-check absolute' className={classNames(
"absolute top-0 bottom-0 left-[8px] mt-auto mb-auto h-[10px] w-[14px] leading-[0] transition-opacity duration-[250ms] ease-in-out",
{
"opacity-100": theme === "dark",
"opacity-0": theme === "light",
},
)}
> >
<span className='toggle_Dark relative flex items-center justify-center'> <span className="relative flex h-[10px] w-[10px] items-center justify-center">
🌜 🌜
</span> </span>
</div> </div>
<div <div
data-cy='switch-theme-light' data-cy="switch-theme-light"
className='toggle-track-x absolute' className={classNames(
"absolute right-[10px] top-0 bottom-0 mt-auto mb-auto h-[10px] w-[10px] leading-[0]",
{
"opacity-100": theme === "light",
"opacity-0": theme === "dark",
},
)}
> >
<span className='toggle_Light relative flex items-center justify-center'> <span className="relative flex h-[10px] w-[10px] items-center justify-center">
🌞 🌞
</span> </span>
</div> </div>
</div> </div>
<div className='toggle-thumb absolute' /> <div
className={classNames(
"absolute top-[1px] box-border h-[22px] w-[22px] rounded-[50%] bg-[#fafafa] text-white transition-all duration-[250ms] ease-in-out",
{
"left-[27px]": theme === "dark",
"left-0": theme === "light",
},
)}
style={{ border: "1px solid #4d4d4d" }}
/>
<input <input
data-cy='switch-theme-input' data-cy="switch-theme-input"
type='checkbox' type="checkbox"
aria-label='Dark mode toggle' aria-label="Dark mode toggle"
className='toggle-screenreader-only absolute overflow-hidden' className="absolute m-[-1px] h-[1px] w-[1px] overflow-hidden border-0 p-0"
defaultChecked defaultChecked
/> />
</div> </div>
</div> </div>
<style jsx>
{`
.toggle-theme-button {
touch-action: pan-x;
border: 0;
padding: 0;
user-select: none;
}
.toggle-track {
width: 50px;
height: 24px;
padding: 0;
border-radius: 30px;
background-color: #4d4d4d;
transition: all 0.2s ease;
color: #fff;
}
.toggle-track-check {
width: 14px;
height: 10px;
top: 0;
bottom: 0;
margin-top: auto;
margin-bottom: auto;
line-height: 0;
left: 8px;
opacity: ${theme === 'dark' ? 1 : 0};
transition: opacity 0.25s ease;
}
.toggle-track-x {
width: 10px;
height: 10px;
top: 0;
bottom: 0;
margin-top: auto;
margin-bottom: auto;
line-height: 0;
right: 10px;
opacity: ${theme === 'dark' ? 0 : 1};
}
.toggle_Dark,
.toggle_Light {
height: 10px;
width: 10px;
}
.toggle-thumb {
left: ${theme === 'dark' ? '27px' : '0px'};
width: 22px;
height: 22px;
border: 1px solid #4d4d4d;
border-radius: 50%;
background-color: #fafafa;
box-sizing: border-box;
transition: all 0.25s ease;
top: 1px;
color: #fff;
}
.toggle-screenreader-only {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
padding: 0;
width: 1px;
}
`}
</style>
</>
) )
} }

View File

@ -1 +1 @@
export * from './Header' export * from "./Header"

View File

@ -1,6 +1,6 @@
import classNames from 'clsx' import classNames from "clsx"
export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {} export interface ButtonProps extends React.ComponentPropsWithoutRef<"button"> {}
export const Button: React.FC<ButtonProps> = (props) => { export const Button: React.FC<ButtonProps> = (props) => {
const { children, className, ...rest } = props const { children, className, ...rest } = props
@ -8,8 +8,8 @@ export const Button: React.FC<ButtonProps> = (props) => {
return ( return (
<button <button
className={classNames( className={classNames(
'py-2 px-6 font-paragraph rounded-lg bg-transparent border hover:text-white dark:hover:text-black fill-current stroke-current transform transition-colors duration-300 ease-in-out focus:outline-none focus:text-white dark:focus:text-black border-green-800 dark:border-green-400 text-green-800 dark:text-green-400 hover:bg-green-800 focus:bg-green-800 dark:focus:bg-green-400 dark:hover:bg-green-400', "py-2 px-6 font-paragraph rounded-lg bg-transparent border hover:text-white dark:hover:text-black fill-current stroke-current transform transition-colors duration-300 ease-in-out focus:outline-none focus:text-white dark:focus:text-black border-green-800 dark:border-green-400 text-green-800 dark:text-green-400 hover:bg-green-800 focus:bg-green-800 dark:focus:bg-green-400 dark:hover:bg-green-400",
className className,
)} )}
{...rest} {...rest}
> >

View File

@ -1,10 +1,10 @@
import classNames from 'clsx' import classNames from "clsx"
import useTranslation from 'next-translate/useTranslation' import useTranslation from "next-translate/useTranslation"
import type { FetchState as FormStateType } from 'react-component-form' import type { FetchState as FormStateType } from "react-component-form"
import { Loader } from './Loader' import { Loader } from "./Loader"
export interface FormStateProps extends React.ComponentPropsWithoutRef<'div'> { export interface FormStateProps extends React.ComponentPropsWithoutRef<"div"> {
state: FormStateType state: FormStateType
message?: string message?: string
id?: string id?: string
@ -14,15 +14,15 @@ export const FormState: React.FC<FormStateProps> = (props) => {
const { state, message, id, ...rest } = props const { state, message, id, ...rest } = props
const { t } = useTranslation() const { t } = useTranslation()
if (state === 'loading') { if (state === "loading") {
return ( return (
<div data-cy='loader' className='mt-8 flex justify-center'> <div data-cy="loader" className="mt-8 flex justify-center">
<Loader /> <Loader />
</div> </div>
) )
} }
if (state === 'idle' || message == null) { if (state === "idle" || message == null) {
return null return null
} }
@ -32,15 +32,15 @@ export const FormState: React.FC<FormStateProps> = (props) => {
{...rest} {...rest}
className={classNames( className={classNames(
props.className, props.className,
'mt-6 flex max-w-xl items-center text-center font-medium', "mt-6 flex max-w-xl items-center text-center font-medium",
{ {
'text-red-800 dark:text-red-400': state === 'error', "text-red-800 dark:text-red-400": state === "error",
'text-green-800 dark:text-green-400': state === 'success' "text-green-800 dark:text-green-400": state === "success",
} },
)} )}
> >
<div className='inline bg-cover font-headline' /> <div className="inline bg-cover font-headline" />
<span id={id} className='pl-2'> <span id={id} className="pl-2">
<b>{t(`common:${state}`)}:</b> {message} <b>{t(`common:${state}`)}:</b> {message}
</span> </span>
</div> </div>

View File

@ -1,8 +1,8 @@
import classNames from 'clsx' import classNames from "clsx"
import { FormState } from './FormState' import { FormState } from "./FormState"
export interface InputProps extends React.ComponentPropsWithRef<'input'> { export interface InputProps extends React.ComponentPropsWithRef<"input"> {
label: string label: string
error?: string error?: string
className?: string className?: string
@ -12,23 +12,23 @@ export const Input: React.FC<InputProps> = (props) => {
const { label, name, className, error, ...rest } = props const { label, name, className, error, ...rest } = props
return ( return (
<div className='flex flex-col'> <div className="flex flex-col">
<div className={classNames('mt-6 mb-2 flex justify-between', className)}> <div className={classNames("mt-6 mb-2 flex justify-between", className)}>
<label className='pl-1' htmlFor={name}> <label className="pl-1" htmlFor={name}>
{label} {label}
</label> </label>
</div> </div>
<div className='relative mt-0'> <div className="relative mt-0">
<input <input
className='h-11 w-full rounded-lg border border-transparent bg-[#f1f1f1] px-3 font-paragraph leading-10 text-[#2a2a2a] caret-green-600 focus:border focus:shadow-green focus:outline-none' className="h-11 w-full rounded-lg border border-transparent bg-[#f1f1f1] px-3 font-paragraph leading-10 text-[#2a2a2a] caret-green-600 focus:border focus:shadow-green focus:outline-none"
{...rest} {...rest}
id={name} id={name}
name={name} name={name}
data-cy={`input-${name ?? 'name'}`} data-cy={`input-${name ?? "name"}`}
/> />
<FormState <FormState
id={`error-${name ?? 'input'}`} id={`error-${name ?? "input"}`}
state={error == null ? 'idle' : 'error'} state={error == null ? "idle" : "error"}
message={error} message={error}
/> />
</div> </div>

View File

@ -1,6 +1,6 @@
import classNames from 'clsx' import classNames from "clsx"
export interface LinkProps extends React.ComponentPropsWithoutRef<'a'> {} export interface LinkProps extends React.ComponentPropsWithoutRef<"a"> {}
export const Link: React.FC<LinkProps> = (props) => { export const Link: React.FC<LinkProps> = (props) => {
const { children, className, ...rest } = props const { children, className, ...rest } = props
@ -8,8 +8,8 @@ export const Link: React.FC<LinkProps> = (props) => {
return ( return (
<a <a
className={classNames( className={classNames(
'text-green-800 hover:underline dark:text-green-400', "text-green-800 hover:underline dark:text-green-400",
className className,
)} )}
{...rest} {...rest}
> >

View File

@ -1,81 +0,0 @@
export interface LoaderProps {
width?: number
height?: number
className?: string
}
export const Loader: React.FC<LoaderProps> = (props) => {
const { width = 50, height = 50 } = props
return (
<div className={props.className}>
<div data-cy='progress-spinner' className='progress-spinner'>
<svg className='progress-spinner-svg' viewBox='25 25 50 50'>
<circle
className='progress-spinner-circle'
cx='50'
cy='50'
r='20'
fill='none'
strokeWidth='2'
strokeMiterlimit='10'
/>
</svg>
</div>
<style jsx>
{`
.progress-spinner {
position: relative;
margin: 0 auto;
width: ${width}px;
height: ${height}px;
}
.progress-spinner::before {
content: '';
display: block;
padding-top: 100%;
}
.progress-spinner-svg {
animation: progress-spinner-rotate 2s linear infinite;
height: 100%;
transform-origin: center center;
width: 100%;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}
.progress-spinner-circle {
stroke-dasharray: 89, 200;
stroke-dashoffset: 0;
stroke: #27b05e;
animation: progress-spinner-dash 1.5s ease-in-out infinite;
stroke-linecap: round;
}
@keyframes progress-spinner-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes progress-spinner-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
`}
</style>
</div>
)
}

View File

@ -0,0 +1,39 @@
@keyframes progressSpinnerRotate {
100% {
transform: rotate(360deg);
}
}
@keyframes progressSpinnerDash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
.progressSpinnerSvg {
animation: progressSpinnerRotate 2s linear infinite;
height: 100%;
transform-origin: center center;
width: 100%;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}
.progressSpinnerCircle {
stroke-dasharray: 89, 200;
stroke-dashoffset: 0;
stroke: #27b05e;
animation: progressSpinnerDash 1.5s ease-in-out infinite;
stroke-linecap: round;
}

View File

@ -0,0 +1,33 @@
import styles from "./Loader.module.css"
export interface LoaderProps {
width?: number
height?: number
className?: string
}
export const Loader: React.FC<LoaderProps> = (props) => {
const { width = 50, height = 50, className } = props
return (
<div className={className}>
<div
data-cy="progress-spinner"
className="relative my-0 mx-auto before:content-none before:block before:pt-[100%]"
style={{ width: `${width}px`, height: `${height}px` }}
>
<svg className={styles["progressSpinnerSvg"]} viewBox="25 25 50 50">
<circle
className={styles["progressSpinnerCircle"]}
cx="50"
cy="50"
r="20"
fill="none"
strokeWidth="2"
strokeMiterlimit="10"
/>
</svg>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export * from "./Loader"

View File

@ -1,14 +1,14 @@
import classNames from 'clsx' import classNames from "clsx"
export interface TextSpecialProps export interface TextSpecialProps
extends React.ComponentPropsWithoutRef<'span'> {} extends React.ComponentPropsWithoutRef<"span"> {}
export const TextSpecial: React.FC<TextSpecialProps> = (props) => { export const TextSpecial: React.FC<TextSpecialProps> = (props) => {
const { children, className, ...rest } = props const { children, className, ...rest } = props
return ( return (
<span <span
className={classNames('text-green-800 dark:text-green-400', className)} className={classNames("text-green-800 dark:text-green-400", className)}
{...rest} {...rest}
> >
{children} {children}

View File

@ -1,13 +1,12 @@
import { defineConfig } from 'cypress' import { defineConfig } from "cypress"
export default defineConfig({ export default defineConfig({
fixturesFolder: false, fixturesFolder: false,
video: false, video: false,
downloadsFolder: undefined, downloadsFolder: undefined,
screenshotOnRunFailure: false, screenshotOnRunFailure: false,
e2e: { e2e: {
baseUrl: 'http://localhost:3000', baseUrl: "http://127.0.0.1:3000",
supportFile: false supportFile: false,
} },
}) })

View File

@ -1,63 +1,63 @@
describe('Form', () => { describe("Form", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/') cy.visit("/")
}) })
it('suceeds, reset input values and display the global success message', () => { it("succeeds, reset input values and display the global success message", () => {
cy.get('[data-cy=input-name]').type('John') cy.get("[data-cy=input-name]").type("John")
cy.get('[data-cy=input-email]').type('john@john.com') cy.get("[data-cy=input-email]").type("john@john.com")
cy.get('#error-name').should('not.exist') cy.get("#error-name").should("not.exist")
cy.get('#error-email').should('not.exist') cy.get("#error-email").should("not.exist")
cy.get('[data-cy=submit]').click() cy.get("[data-cy=submit]").click()
cy.get('[data-cy=input-name]').should('have.value', '') cy.get("[data-cy=input-name]").should("have.value", "")
cy.get('[data-cy=input-email]').should('have.value', '') cy.get("[data-cy=input-email]").should("have.value", "")
cy.get('#message').should( cy.get("#message").should(
'have.text', "have.text",
'Success: The form has been submitted.' "Success: The form has been submitted.",
) )
}) })
it('fails with all inputs as required with error messages and update error messages when updating language (translation)', () => { it("fails with all inputs as required with error messages and update error messages when updating language (translation)", () => {
const requiredErrorMessage = { const requiredErrorMessage = {
en: 'Error: Oops, this field is required 🙈.', en: "Error: Oops, this field is required 🙈.",
fr: 'Erreur: Oups, ce champ est obligatoire 🙈.' fr: "Erreur: Oups, ce champ est obligatoire 🙈.",
} }
cy.get('#error-name').should('not.exist') cy.get("#error-name").should("not.exist")
cy.get('#error-email').should('not.exist') cy.get("#error-email").should("not.exist")
cy.get('[data-cy=submit]').click() cy.get("[data-cy=submit]").click()
cy.get('#error-name').should('have.text', requiredErrorMessage.en) cy.get("#error-name").should("have.text", requiredErrorMessage.en)
cy.get('#error-email').should('have.text', requiredErrorMessage.en) cy.get("#error-email").should("have.text", requiredErrorMessage.en)
cy.get('[data-cy=language-click]').click() cy.get("[data-cy=language-click]").click()
cy.get('[data-cy=languages-list] > li:first-child').contains('FR').click() cy.get("[data-cy=languages-list] > li:first-child").contains("FR").click()
cy.get('#error-name').should('have.text', requiredErrorMessage.fr) cy.get("#error-name").should("have.text", requiredErrorMessage.fr)
cy.get('#error-email').should('have.text', requiredErrorMessage.fr) cy.get("#error-email").should("have.text", requiredErrorMessage.fr)
}) })
it('fails with invalid name (less than 3 characters)', () => { it("fails with invalid name (less than 3 characters)", () => {
cy.get('[data-cy=input-name]').type('a') cy.get("[data-cy=input-name]").type("a")
cy.get('[data-cy=submit]').click() cy.get("[data-cy=submit]").click()
cy.get('#error-name').should( cy.get("#error-name").should(
'have.text', "have.text",
'Error: The field must contain at least 3 characters.' "Error: The field must contain at least 3 characters.",
) )
}) })
it('fails with invalid name (more than 10 characters)', () => { it("fails with invalid name (more than 10 characters)", () => {
cy.get('[data-cy=input-name]').type('12345678910aaaa') cy.get("[data-cy=input-name]").type("12345678910aaaa")
cy.get('[data-cy=submit]').click() cy.get("[data-cy=submit]").click()
cy.get('#error-name').should( cy.get("#error-name").should(
'have.text', "have.text",
'Error: The field must contain at most 10 characters.' "Error: The field must contain at most 10 characters.",
) )
}) })
it('fails with wrong email format', () => { it("fails with wrong email format", () => {
cy.get('#error-email').should('not.exist') cy.get("#error-email").should("not.exist")
cy.get('[data-cy=input-email]').type('test') cy.get("[data-cy=input-email]").type("test")
cy.get('[data-cy=submit]').click() cy.get("[data-cy=submit]").click()
cy.get('#error-email').should( cy.get("#error-email").should(
'have.text', "have.text",
'Error: Mmm… It seems that this email is not valid 🤔.' "Error: Mmm… It seems that this email is not valid 🤔.",
) )
}) })
}) })

View File

@ -1,47 +1,47 @@
describe('Header', () => { describe("Header", () => {
beforeEach(() => cy.visit('/')) beforeEach(() => cy.visit("/"))
describe('Switch theme color (dark/light)', () => { describe("Switch theme color (dark/light)", () => {
it('should switch theme from `dark` (default) to `light`', () => { it("should switch theme from `dark` (default) to `light`", () => {
cy.get('[data-cy=switch-theme-dark]').should('be.visible') cy.get("[data-cy=switch-theme-dark]").should("be.visible")
cy.get('[data-cy=switch-theme-light]').should('not.be.visible') cy.get("[data-cy=switch-theme-light]").should("not.be.visible")
cy.get('body').should( cy.get("body").should(
'not.have.css', "not.have.css",
'background-color', "background-color",
'rgb(255, 255, 255)' "rgb(255, 255, 255)",
) )
cy.get('[data-cy=switch-theme-click]').click() cy.get("[data-cy=switch-theme-click]").click()
cy.get('[data-cy=switch-theme-dark]').should('not.be.visible') cy.get("[data-cy=switch-theme-dark]").should("not.be.visible")
cy.get('[data-cy=switch-theme-light]').should('be.visible') cy.get("[data-cy=switch-theme-light]").should("be.visible")
cy.get('body').should( cy.get("body").should(
'have.css', "have.css",
'background-color', "background-color",
'rgb(255, 255, 255)' "rgb(255, 255, 255)",
) )
}) })
}) })
describe('Switch Language', () => { describe("Switch Language", () => {
it('should switch language from EN (default) to FR', () => { it("should switch language from EN (default) to FR", () => {
cy.get('[data-cy=main-description]').contains('This is an example') cy.get("[data-cy=main-description]").contains("This is an example")
cy.get('[data-cy=language-flag-text]').contains('EN') cy.get("[data-cy=language-flag-text]").contains("EN")
cy.get('[data-cy=languages-list]').should('not.be.visible') cy.get("[data-cy=languages-list]").should("not.be.visible")
cy.get('[data-cy=language-click]').click() cy.get("[data-cy=language-click]").click()
cy.get('[data-cy=languages-list]').should('be.visible') cy.get("[data-cy=languages-list]").should("be.visible")
cy.get('[data-cy=languages-list] > li:first-child').contains('FR').click() cy.get("[data-cy=languages-list] > li:first-child").contains("FR").click()
cy.get('[data-cy=languages-list]').should('not.be.visible') cy.get("[data-cy=languages-list]").should("not.be.visible")
cy.get('[data-cy=language-flag-text]').contains('FR') cy.get("[data-cy=language-flag-text]").contains("FR")
cy.get('[data-cy=main-description]').contains('Ceci est un exemple') cy.get("[data-cy=main-description]").contains("Ceci est un exemple")
}) })
it('should close the language list menu when clicking outside', () => { it("should close the language list menu when clicking outside", () => {
cy.get('[data-cy=languages-list]').should('not.be.visible') cy.get("[data-cy=languages-list]").should("not.be.visible")
cy.get('[data-cy=language-click]').click() cy.get("[data-cy=language-click]").click()
cy.get('[data-cy=languages-list]').should('be.visible') cy.get("[data-cy=languages-list]").should("be.visible")
cy.get('[data-cy=main-description]').click() cy.get("[data-cy=main-description]").click()
cy.get('[data-cy=languages-list]').should('not.be.visible') cy.get("[data-cy=languages-list]").should("not.be.visible")
}) })
}) })
}) })

View File

@ -1,45 +1,45 @@
import useTranslation from 'next-translate/useTranslation' import useTranslation from "next-translate/useTranslation"
import type { Error } from 'react-component-form' import type { Error } from "react-component-form"
const knownErrorKeywords = ['minLength', 'maxLength', 'format'] const knownErrorKeywords = ["minLength", "maxLength", "format"]
const getErrorTranslationKey = (error: Error): string => { const getErrorTranslationKey = (error: Error): string => {
if (knownErrorKeywords.includes(error?.keyword)) { if (knownErrorKeywords.includes(error?.keyword)) {
if ( if (
error.keyword === 'minLength' && error.keyword === "minLength" &&
typeof error.data === 'string' && typeof error.data === "string" &&
error.data.length === 0 error.data.length === 0
) { ) {
return 'common:required' return "common:required"
} }
if (error.keyword === 'format') { if (error.keyword === "format") {
if (error.params.format === 'email') { if (error.params["format"] === "email") {
return 'common:invalid-email' return "common:invalid-email"
} }
return 'common:invalid' return "common:invalid"
} }
return `common:${error.keyword}` return `common:${error.keyword}`
} }
return 'common:invalid' return "common:invalid"
} }
export const useFormTranslation = () => { export const useFormTranslation = () => {
const { t } = useTranslation() const { t } = useTranslation()
const getErrorTranslation = ( const getErrorTranslation = (
error: Error | undefined error: Error | undefined,
): string | undefined => { ): string | undefined => {
if (error != null) { if (error != null) {
return t(getErrorTranslationKey(error)).replace( return t(getErrorTranslationKey(error)).replace(
'{expected}', "{expected}",
error?.params?.limit error?.params?.["limit"],
) )
} }
return undefined return undefined
} }
const getFirstErrorTranslation = ( const getFirstErrorTranslation = (
errors: Error[] | undefined errors: Error[] | undefined,
): string | undefined => { ): string | undefined => {
if (errors != null) { if (errors != null) {
return getErrorTranslation(errors[0]) return getErrorTranslation(errors[0])

View File

@ -1,8 +1,9 @@
import { Static, Type } from '@sinclair/typebox' import type { Static } from "@sinclair/typebox"
import { Type } from "@sinclair/typebox"
export const userSchema = { export const userSchema = {
name: Type.String({ minLength: 3, maxLength: 10 }), name: Type.String({ minLength: 3, maxLength: 10 }),
email: Type.String({ minLength: 1, maxLength: 254, format: 'email' }) email: Type.String({ minLength: 1, maxLength: 254, format: "email" }),
} }
export const userObjectSchema = Type.Object(userSchema) export const userObjectSchema = Type.Object(userSchema)

View File

@ -1,8 +1,8 @@
const nextTranslate = require('next-translate') const nextTranslate = require("next-translate-plugin")
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true reactStrictMode: true,
} }
module.exports = nextTranslate(nextConfig) module.exports = nextTranslate(nextConfig)

6980
example/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,30 +7,32 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test:e2e": "start-server-and-test \"start\" \"http://localhost:3000\" \"cypress run\"", "test:e2e": "start-server-and-test \"start\" \"http://127.0.0.1:3000\" \"cypress run\"",
"test:dev": "start-server-and-test \"dev\" \"http://localhost:3000\" \"cypress open\"" "test:dev": "start-server-and-test \"dev\" \"http://127.0.0.1:3000\" \"cypress open\""
}, },
"dependencies": { "dependencies": {
"@sinclair/typebox": "0.24.28", "@sinclair/typebox": "0.32.13",
"clsx": "1.2.1", "clsx": "2.1.0",
"next": "12.2.5", "next": "13.2.4",
"next-themes": "0.2.0", "next-themes": "0.2.1",
"next-translate": "1.5.0", "next-translate": "2.0.5",
"react": "18.2.0", "react": "18.2.0",
"react-component-form": "file:..", "react-component-form": "file:..",
"react-dom": "18.2.0" "react-dom": "18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "18.7.13", "@tsconfig/strictest": "2.0.2",
"@types/react": "18.0.17", "@types/node": "20.11.10",
"@types/react-dom": "18.0.6", "@types/react": "18.2.48",
"autoprefixer": "10.4.8", "@types/react-dom": "18.2.18",
"cypress": "10.6.0", "autoprefixer": "10.4.17",
"eslint": "8.22.0", "cypress": "13.6.3",
"eslint-config-next": "12.2.5", "eslint": "8.56.0",
"postcss": "8.4.16", "eslint-config-next": "13.2.4",
"start-server-and-test": "1.14.0", "next-translate-plugin": "2.0.5",
"tailwindcss": "3.1.8", "postcss": "8.4.33",
"typescript": "4.8.2" "start-server-and-test": "2.0.3",
"tailwindcss": "3.4.1",
"typescript": "5.3.3"
} }
} }

View File

@ -1,11 +1,11 @@
import type { AppProps } from 'next/app' import type { AppType } from "next/app"
import { ThemeProvider } from 'next-themes' import { ThemeProvider } from "next-themes"
import '../styles/globals.css' import "../styles/globals.css"
const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => { const MyApp: AppType = ({ Component, pageProps }) => {
return ( return (
<ThemeProvider attribute='class' defaultTheme='dark'> <ThemeProvider attribute="class" defaultTheme="dark">
<Component {...pageProps} /> <Component {...pageProps} />
</ThemeProvider> </ThemeProvider>
) )

View File

@ -1,10 +1,10 @@
import { Html, Head, Main, NextScript } from 'next/document' import { Html, Head, Main, NextScript } from "next/document"
const Document: React.FC = () => { const Document: React.FC = () => {
return ( return (
<Html> <Html>
<Head /> <Head />
<body className='bg-white text-black dark:bg-black dark:text-white'> <body className="bg-white text-black dark:bg-black dark:text-white">
<Main /> <Main />
<NextScript /> <NextScript />
</body> </body>

View File

@ -1,21 +1,21 @@
import type { GetStaticProps, NextPage } from 'next' import type { GetStaticProps, NextPage } from "next"
import Head from 'next/head' import Head from "next/head"
import { About } from '../components/About' import { About } from "../components/About"
import { FormExample } from '../components/FormExample' import { FormExample } from "../components/FormExample"
import { Header } from '../components/Header' import { Header } from "../components/Header"
const Home: NextPage = () => { const Home: NextPage = () => {
return ( return (
<> <>
<Head> <Head>
<title>react-component-form</title> <title>react-component-form</title>
<meta name='description' content='Manage React Forms with ease.' /> <meta name="description" content="Manage React Forms with ease." />
<link rel='icon' href='/favicon.ico' /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Header /> <Header />
<main className='flex flex-col justify-center items-center mt-4'> <main className="flex flex-col justify-center items-center mt-4">
<About /> <About />
<FormExample /> <FormExample />
</main> </main>

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {} autoprefixer: {},
} },
} }

View File

@ -1,18 +1,20 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { const tailwindConfig = {
content: [ content: [
'./pages/**/*.{js,ts,jsx,tsx}', "./pages/**/*.{js,ts,jsx,tsx}",
'./components/**/*.{js,ts,jsx,tsx}' "./components/**/*.{js,ts,jsx,tsx}",
], ],
darkMode: 'class', darkMode: "class",
theme: { theme: {
extend: { extend: {
colors: { colors: {
black: '#212121', black: "#212121",
success: '#45C85A', success: "#45C85A",
error: '#C84545' error: "#C84545",
}
}
}, },
plugins: [] },
},
plugins: [],
} }
module.exports = tailwindConfig

View File

@ -1,21 +1,21 @@
{ {
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "ESNext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "baseUrl": ".",
"strict": true,
"types": ["cypress"], "types": ["cypress"],
"forceConsistentCasingInFileNames": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true,
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true "incremental": true,
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": false,
"isolatedModules": true,
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"] "exclude": ["node_modules"],
} }

26645
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,70 +4,76 @@
"public": true, "public": true,
"type": "module", "type": "module",
"description": "Manage React Forms with ease.", "description": "Manage React Forms with ease.",
"author": "Divlo <contact@divlo.fr>", "author": "Théo LUDWIG <contact@theoludwig.fr>",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/Divlo/react-component-form.git" "url": "https://github.com/theoludwig/react-component-form.git"
}, },
"keywords": [ "keywords": [
"react-form", "react-form",
"react-component-form" "react-component-form"
], ],
"bugs": { "bugs": {
"url": "https://github.com/Divlo/react-component-form/issues" "url": "https://github.com/theoludwig/react-component-form/issues"
}, },
"homepage": "https://react-component-form.vercel.app/", "homepage": "https://react-component-form.vercel.app/",
"main": "dist/index.js", "main": "build/index.js",
"types": "dist/index.d.ts", "types": "build/index.d.ts",
"files": [ "files": [
"dist" "build"
], ],
"publishConfig": {
"access": "public",
"provenance": true
},
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"test": "jest", "test": "jest",
"lint:commit": "commitlint", "lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker", "lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint-cli2", "lint:markdown": "markdownlint-cli2",
"lint:typescript": "eslint \"**/*.{js,jsx,ts,tsx}\"", "lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
"lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"", "lint:prettier": "prettier . --check",
"release": "semantic-release" "release": "semantic-release"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=16" "react": ">=18.2.0"
}, },
"dependencies": { "dependencies": {
"@sinclair/typebox": "0.24.28", "@sinclair/typebox": "0.32.13",
"ajv": "8.11.0", "ajv": "8.12.0",
"ajv-formats": "2.1.1" "ajv-formats": "2.1.1"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "17.0.3", "@commitlint/cli": "18.6.0",
"@commitlint/config-conventional": "17.0.3", "@commitlint/config-conventional": "18.6.0",
"@testing-library/react": "13.3.0", "@testing-library/react": "14.1.2",
"@types/jest": "28.1.8", "@tsconfig/strictest": "2.0.2",
"@types/react": "18.0.17", "@types/jest": "29.5.11",
"@types/react-dom": "18.0.6", "@types/react": "18.2.48",
"@typescript-eslint/eslint-plugin": "5.35.1", "@types/react-dom": "18.2.18",
"@typescript-eslint/parser": "5.35.1", "@typescript-eslint/eslint-plugin": "6.20.0",
"editorconfig-checker": "4.0.2", "@typescript-eslint/parser": "6.20.0",
"esbuild": "0.15.5", "editorconfig-checker": "5.1.2",
"esbuild": "0.20.0",
"esbuild-jest": "0.5.0", "esbuild-jest": "0.5.0",
"eslint": "8.22.0", "eslint": "8.56.0",
"eslint-config-conventions": "3.0.0", "eslint-config-conventions": "13.1.0",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "5.1.3",
"eslint-plugin-promise": "6.0.1", "eslint-plugin-promise": "6.1.1",
"eslint-plugin-unicorn": "43.0.2", "eslint-plugin-unicorn": "50.0.1",
"jest": "29.0.0", "jest": "29.7.0",
"jest-environment-jsdom": "29.0.0", "jest-environment-jsdom": "29.7.0",
"markdownlint-cli2": "0.5.1", "markdownlint-cli2": "0.12.1",
"prettier": "2.7.1", "markdownlint-rule-relative-links": "2.2.0",
"prettier": "3.2.4",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"semantic-release": "19.0.5", "semantic-release": "23.0.0",
"tsup": "6.2.2", "tsup": "8.0.1",
"typescript": "4.7.4" "typescript": "5.3.3"
} }
} }

View File

@ -1,12 +1,13 @@
import React from 'react' import React from "react"
import { render, cleanup, fireEvent } from '@testing-library/react' import { render, cleanup, fireEvent } from "@testing-library/react"
import { Form, HandleForm } from '..' import type { HandleForm } from ".."
import { Form } from ".."
afterEach(cleanup) afterEach(cleanup)
describe('<Form />', () => { describe("<Form />", () => {
it('should get the formData and formElement onSubmit and onChange', () => { it("should get the formData and formElement onSubmit and onChange", () => {
let formData: { [k: string]: any } = {} let formData: { [k: string]: any } = {}
let formElement: any = null let formElement: any = null
const handleSubmitChange: HandleForm = (data, element) => { const handleSubmitChange: HandleForm = (data, element) => {
@ -15,27 +16,27 @@ describe('<Form />', () => {
} }
const formComponent = render( const formComponent = render(
<Form onSubmit={handleSubmitChange} onChange={handleSubmitChange}> <Form onSubmit={handleSubmitChange} onChange={handleSubmitChange}>
<input data-testid='input-form' type='text' name='inputName' /> <input data-testid="input-form" type="text" name="inputName" />
<button data-testid='button-submit' type='submit'> <button data-testid="button-submit" type="submit">
Submit Submit
</button> </button>
</Form> </Form>,
) )
const inputForm = formComponent.getByTestId( const inputForm = formComponent.getByTestId(
'input-form' "input-form",
) as HTMLInputElement ) as HTMLInputElement
const buttonSubmit = formComponent.getByTestId('button-submit') const buttonSubmit = formComponent.getByTestId("button-submit")
const text = 'some random text' const text = "some random text"
fireEvent.change(inputForm, { target: { value: text } }) fireEvent.change(inputForm, { target: { value: text } })
expect(formData.inputName).toEqual(text) expect(formData["inputName"]).toEqual(text)
expect(formElement instanceof HTMLFormElement).toBeTruthy() expect(formElement instanceof HTMLFormElement).toBeTruthy()
formData = {} formData = {}
formElement = null formElement = null
fireEvent.click(buttonSubmit) fireEvent.click(buttonSubmit)
expect(Object.keys(formData).length).toEqual(1) expect(Object.keys(formData).length).toEqual(1)
expect(formData.inputName).toEqual(text) expect(formData["inputName"]).toEqual(text)
expect(formElement instanceof HTMLFormElement).toBeTruthy() expect(formElement instanceof HTMLFormElement).toBeTruthy()
}) })
}) })

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react' import React, { useRef } from "react"
export interface FormDataObject { export interface FormDataObject {
[key: string]: FormDataEntryValue [key: string]: FormDataEntryValue
@ -10,11 +10,11 @@ export interface FormDataObject {
*/ */
export type HandleForm = ( export type HandleForm = (
formData: FormDataObject, formData: FormDataObject,
formElement: HTMLFormElement formElement: HTMLFormElement,
) => void | Promise<void> ) => void | Promise<void>
interface ReactFormProps interface ReactFormProps
extends Omit<React.HTMLProps<HTMLFormElement>, 'onSubmit' | 'onChange'> {} extends Omit<React.HTMLProps<HTMLFormElement>, "onSubmit" | "onChange"> {}
export interface FormProps extends ReactFormProps { export interface FormProps extends ReactFormProps {
onSubmit?: HandleForm onSubmit?: HandleForm
@ -22,7 +22,7 @@ export interface FormProps extends ReactFormProps {
} }
export const getFormDataObject = ( export const getFormDataObject = (
formElement: HTMLFormElement formElement: HTMLFormElement,
): FormDataObject => { ): FormDataObject => {
return Object.fromEntries<FormDataEntryValue>(new FormData(formElement)) return Object.fromEntries<FormDataEntryValue>(new FormData(formElement))
} }

View File

@ -1,14 +1,14 @@
import { useState } from 'react' import { useState } from "react"
export const fetchState = ['idle', 'loading', 'error', 'success'] as const export const fetchState = ["idle", "loading", "error", "success"] as const
export type FetchState = typeof fetchState[number] export type FetchState = (typeof fetchState)[number]
export const useFetchState = ( export const useFetchState = (
initialFetchState: FetchState = 'idle' initialFetchState: FetchState = "idle",
): [ ): [
fetchState: FetchState, fetchState: FetchState,
setFetchState: React.Dispatch<React.SetStateAction<FetchState>> setFetchState: React.Dispatch<React.SetStateAction<FetchState>>,
] => { ] => {
const [fetchState, setFetchState] = useState<FetchState>(initialFetchState) const [fetchState, setFetchState] = useState<FetchState>(initialFetchState)
return [fetchState, setFetchState] return [fetchState, setFetchState]

View File

@ -1,14 +1,18 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from "react"
import { SchemaOptions, Static, TObject, Type } from '@sinclair/typebox' import type { Static, TObject } from "@sinclair/typebox"
import type { ErrorObject } from 'ajv' import { Type } from "@sinclair/typebox"
import type { ErrorObject } from "ajv"
import type { HandleForm } from '../components/Form' import type { HandleForm } from "../components/Form"
import { FetchState, useFetchState } from './useFetchState' import type { FetchState } from "./useFetchState"
import { ajv } from '../utils/ajv' import { useFetchState } from "./useFetchState"
import { handleCheckboxBoolean } from '../utils/handleCheckboxBoolean' import { ajv } from "../utils/ajv"
import { handleOptionalEmptyStringToNull } from '../utils/handleOptionalEmptyStringToNull' import { handleCheckboxBoolean } from "../utils/handleCheckboxBoolean"
import { handleOptionalEmptyStringToNull } from "../utils/handleOptionalEmptyStringToNull"
export type Schema = SchemaOptions export interface Schema {
[property: string | symbol]: any
}
export type Error = ErrorObject export type Error = ErrorObject
@ -25,22 +29,22 @@ export type HandleUseFormCallbackResult<K extends Schema> = Message<K> | null
*/ */
export type HandleUseFormCallback<K extends Schema> = ( export type HandleUseFormCallback<K extends Schema> = (
formData: Static<TObject<K>>, formData: Static<TObject<K>>,
formElement: HTMLFormElement formElement: HTMLFormElement,
) => Promise<HandleUseFormCallbackResult<K>> | HandleUseFormCallbackResult<K> ) => Promise<HandleUseFormCallbackResult<K>> | HandleUseFormCallbackResult<K>
export type HandleUseForm<K extends Schema> = ( export type HandleUseForm<K extends Schema> = (
callback?: HandleUseFormCallback<K> callback?: HandleUseFormCallback<K>,
) => HandleForm ) => HandleForm
export interface GlobalMessage { export interface GlobalMessage {
type: 'error' | 'success' type: "error" | "success"
value?: string message?: string
properties?: undefined properties?: undefined
} }
export interface PropertiesMessage<K extends Schema> { export interface PropertiesMessage<K extends Schema> {
type: 'error' type: "error"
value?: string message?: string
properties: { [key in keyof Partial<K>]: string } properties: { [key in keyof Partial<K>]: string }
} }
@ -61,7 +65,7 @@ export interface UseFormResult<K extends Schema> {
/** /**
* Global message of the form (not specific to a property). * Global message of the form (not specific to a property).
*/ */
readonly message?: string readonly message: string | undefined
setMessage: React.Dispatch<React.SetStateAction<string | undefined>> setMessage: React.Dispatch<React.SetStateAction<string | undefined>>
/** /**
@ -77,7 +81,7 @@ export interface UseFormResult<K extends Schema> {
} }
export const useForm = <K extends Schema>( export const useForm = <K extends Schema>(
validationSchema: K validationSchema: K,
): UseFormResult<typeof validationSchema> => { ): UseFormResult<typeof validationSchema> => {
const validationSchemaObject = useMemo(() => { const validationSchemaObject = useMemo(() => {
return Type.Object(validationSchema) return Type.Object(validationSchema)
@ -86,7 +90,7 @@ export const useForm = <K extends Schema>(
const [fetchState, setFetchState] = useFetchState() const [fetchState, setFetchState] = useFetchState()
const [message, setMessage] = useState<string | undefined>(undefined) const [message, setMessage] = useState<string | undefined>(undefined)
const [errors, setErrors] = useState<ErrorsObject<typeof validationSchema>>( const [errors, setErrors] = useState<ErrorsObject<typeof validationSchema>>(
{} as any {} as any,
) )
const validate = useMemo(() => { const validate = useMemo(() => {
@ -99,12 +103,12 @@ export const useForm = <K extends Schema>(
setMessage(undefined) setMessage(undefined)
formData = handleOptionalEmptyStringToNull( formData = handleOptionalEmptyStringToNull(
formData, formData,
validationSchemaObject.required validationSchemaObject.required,
) )
formData = handleCheckboxBoolean(formData, validationSchemaObject) formData = handleCheckboxBoolean(formData, validationSchemaObject)
const isValid = validate(formData) const isValid = validate(formData)
if (!isValid) { if (!isValid) {
setFetchState('error') setFetchState("error")
const errors: ErrorsObject<typeof validationSchema> = {} as any const errors: ErrorsObject<typeof validationSchema> = {} as any
for (const property in validationSchemaObject.properties) { for (const property in validationSchemaObject.properties) {
const errorsForProperty = validate.errors?.filter((error) => { const errorsForProperty = validate.errors?.filter((error) => {
@ -119,28 +123,28 @@ export const useForm = <K extends Schema>(
} else { } else {
setErrors({} as any) setErrors({} as any)
if (callback != null) { if (callback != null) {
setFetchState('loading') setFetchState("loading")
const message = await callback( const message = await callback(
formData as Static<TObject<typeof validationSchema>>, formData as Static<TObject<typeof validationSchema>>,
formElement formElement,
) )
if (message != null) { if (message != null) {
const { value, type, properties } = message const { message: messageValue, type, properties } = message
setMessage(value) setMessage(messageValue)
setFetchState(type) setFetchState(type)
if (type === 'error') { if (type === "error") {
const propertiesErrors: ErrorsObject<typeof validationSchema> = const propertiesErrors: ErrorsObject<typeof validationSchema> =
{} as any {} as any
for (const property in properties) { for (const property in properties) {
propertiesErrors[property] = [ propertiesErrors[property] = [
{ {
keyword: 'message', keyword: "message",
message: properties[property], message: properties[property],
instancePath: `/${property}`, instancePath: `/${property}`,
schemaPath: `#/properties/${property}/message`, schemaPath: `#/properties/${property}/message`,
params: {}, params: {},
data: formData[property] data: formData[property],
} },
] ]
} }
setErrors(propertiesErrors) setErrors(propertiesErrors)
@ -157,6 +161,6 @@ export const useForm = <K extends Schema>(
setFetchState, setFetchState,
message, message,
setMessage, setMessage,
errors errors,
} }
} }

View File

@ -1,4 +1,4 @@
export * from './components/Form' export * from "./components/Form"
export * from './hooks/useFetchState' export * from "./hooks/useFetchState"
export * from './hooks/useForm' export * from "./hooks/useForm"
export * from './utils/ajv' export * from "./utils/ajv"

View File

@ -1,25 +1,25 @@
import addFormats from 'ajv-formats' import addFormats from "ajv-formats"
import Ajv from 'ajv' import Ajv from "ajv"
export const ajv = addFormats( export const ajv = addFormats(
new Ajv({ new Ajv({
allErrors: true, allErrors: true,
verbose: true verbose: true,
}), }),
[ [
'date-time', "date-time",
'time', "time",
'date', "date",
'email', "email",
'hostname', "hostname",
'ipv4', "ipv4",
'ipv6', "ipv6",
'uri', "uri",
'uri-reference', "uri-reference",
'uuid', "uuid",
'uri-template', "uri-template",
'json-pointer', "json-pointer",
'relative-json-pointer', "relative-json-pointer",
'regex' "regex",
] ],
) )

View File

@ -1,15 +1,15 @@
import type { TObject } from '@sinclair/typebox' import type { TObject } from "@sinclair/typebox"
import type { ObjectAny } from './types' import type { Schema } from "../hooks/useForm"
export const handleCheckboxBoolean = ( export const handleCheckboxBoolean = (
object: ObjectAny, object: Schema,
validateSchemaObject: TObject<ObjectAny> validateSchemaObject: TObject<Schema>,
): ObjectAny => { ): Schema => {
const booleanProperties: string[] = [] const booleanProperties: string[] = []
for (const property in validateSchemaObject.properties) { for (const property in validateSchemaObject.properties) {
const rule = validateSchemaObject.properties[property] const rule = validateSchemaObject.properties[property]
if (rule.type === 'boolean') { if (rule.type === "boolean") {
booleanProperties.push(property) booleanProperties.push(property)
} }
} }
@ -18,7 +18,7 @@ export const handleCheckboxBoolean = (
object[booleanProperty] = object[booleanProperty] =
validateSchemaObject.properties[booleanProperty].default validateSchemaObject.properties[booleanProperty].default
} else { } else {
object[booleanProperty] = object[booleanProperty] === 'on' object[booleanProperty] = object[booleanProperty] === "on"
} }
} }
return object return object

View File

@ -1,17 +1,19 @@
export const handleOptionalEmptyStringToNull = <K>( import type { Schema } from "../hooks/useForm"
export const handleOptionalEmptyStringToNull = <K extends Schema>(
object: K, object: K,
required: string[] = [] required: string[] = [],
): K => { ): K => {
return Object.fromEntries( return Object.fromEntries(
Object.entries(object).map(([key, value]) => { Object.entries(object).map(([key, value]) => {
if ( if (
typeof value === 'string' && typeof value === "string" &&
value.length === 0 && value.length === 0 &&
!required.includes(key) !required.includes(key)
) { ) {
return [key, null] return [key, null]
} }
return [key, value] return [key, value]
}) }),
) as K ) as K
} }

View File

@ -1,3 +0,0 @@
export interface ObjectAny {
[key: string]: any
}

View File

@ -1,4 +1,5 @@
{ {
"extends": "@tsconfig/strictest/tsconfig.json",
"include": ["src", "types"], "include": ["src", "types"],
"compilerOptions": { "compilerOptions": {
"module": "ESNext", "module": "ESNext",
@ -7,16 +8,8 @@
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
"rootDir": "./src", "rootDir": "./src",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"moduleResolution": "node", "moduleResolution": "node",
"jsx": "react", "jsx": "react",
"esModuleInterop": true, "noEmit": true,
"skipLibCheck": true, },
"forceConsistentCasingInFileNames": true,
"noEmit": true
}
} }

View File

@ -1,12 +1,13 @@
import { defineConfig } from 'tsup' import { defineConfig } from "tsup"
export default defineConfig({ export default defineConfig({
entry: ['src/index.ts'], entry: ["src/**/*.{ts,tsx}", "!src/**/*.test.{ts,tsx}"],
sourcemap: true, sourcemap: false,
clean: true, clean: true,
platform: 'browser', platform: "browser",
target: 'esnext', target: "esnext",
format: ['esm'], format: ["esm"],
minify: true, minify: false,
dts: true outDir: "build",
dts: true,
}) })