26 Commits

Author SHA1 Message Date
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
da5d46835d perf: enable tree-shaking 2023-05-13 18:24:44 +02:00
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
882416cb49 build(deps): update latest 2023-05-13 17:00:31 +02:00
040e3a0ae1 style: fix linting 2023-04-02 22:10:52 +02:00
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
69f12002c7 build(deps): update latest
BREAKING CHANGE: peerDependencies: `react@>=18.2.0`
2023-04-02 21:52:34 +02:00
85eb53d60c chore: fix vercel build error for example 2023-01-10 21:53:53 +01:00
45c072f2bd style: fix linting 2023-01-10 21:27:25 +01:00
cdff824ca5 fix: update dependencies to latest 2023-01-10 21:23:32 +01:00
54ef5ceea1 ci: fix timeout 2022-11-08 11:40:39 +01:00
48d4fb6f75 build(deps): bump Next.js to v13 2022-11-08 11:28:57 +01:00
1683474fa6 chore: remove usage of styled-jsx 2022-10-03 21:23:17 +02:00
a37453a115 style(example): fix linting 2022-09-21 09:38:57 +02:00
fcc2b2ea77 fix(types): improve Schema type for useForm 2022-09-21 09:33:09 +02:00
d213893d5d ci: avoid running twice (develop and master branch) [skip-ci] 2022-08-26 23:58:19 +02:00
1125103c45 ci(test): fix folder path for e2e tests 2022-08-26 23:53:13 +02:00
ce884c354d style(example): fix linting 2022-08-26 23:50:28 +02:00
0819304e1e test: add e2e automated tests 2022-08-26 23:47:48 +02:00
50d724eb6a fix(types): improve documentation 2022-08-26 23:05:38 +02:00
c979bab553 chore(example): fix translation with global message 2022-08-26 22:53:59 +02:00
52081972e9 ci: release only stable on master branch 2022-08-26 22:48:52 +02:00
5c49f94b53 docs(readme): add API section for useForm hook 2022-08-26 22:48:23 +02:00
01419426a3 style: fix linting with prettier 2022-08-26 21:39:42 +02:00
676a70b1a9 chore: add example 2022-08-26 20:19:31 +02:00
71 changed files with 12686 additions and 21699 deletions

View File

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

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. -->
## What changes this PR introduce?
# What changes this PR introduce?
## List any relevant issue numbers

View File

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

View File

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

View File

@ -2,26 +2,34 @@ name: 'Release'
on:
push:
branches: [master, develop]
branches: [master]
jobs:
build:
runs-on: 'ubuntu-latest'
permissions:
contents: 'write'
issues: 'write'
pull-requests: 'write'
id-token: 'write'
steps:
- uses: 'actions/checkout@v3.0.0'
- uses: 'actions/checkout@v3.5.3'
- name: 'Use Node.js'
uses: 'actions/setup-node@v3.1.0'
- name: 'Setup Node.js'
uses: 'actions/setup-node@v3.6.0'
with:
node-version: 'lts/*'
cache: 'npm'
- name: 'Install'
run: 'npm install'
- name: 'Install dependencies'
run: 'npm clean-install'
- name: 'Build Package'
run: 'npm run build'
- name: 'Verify the integrity of provenance attestations and registry signatures for installed dependencies'
run: 'npm audit signatures'
- name: 'Release'
run: 'npm run release'
env:

View File

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

1
.gitignore vendored
View File

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

View File

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

1
.npmrc
View File

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

View File

@ -1,8 +1,5 @@
{
"branches": [
"master",
{ "name": "develop", "prerelease": "beta", "channel": "beta" }
],
"branches": ["master"],
"plugins": [
[
"@semantic-release/commit-analyzer",

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
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 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.
## 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
- Reporting a bug.
@ -25,28 +29,4 @@ If you're adding new features to **react-component-form**, please include tests.
## Commits
The commit message guidelines respect
[@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.
The commit message guidelines adheres to [Conventional Commits](https://www.conventionalcommits.org/) and [Semantic Versioning](https://semver.org/) for releases.

View File

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

View File

@ -9,9 +9,9 @@
<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>
<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/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/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/build.yml"><img src="https://github.com/theoludwig/react-component-form/actions/workflows/build.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=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=master" /></a>
<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://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>
@ -24,7 +24,7 @@
There is also a [React Hooks](https://reactjs.org/docs/hooks-intro.html) to be used in combination with the `<Form />` component to validate the data with [Ajv JSON schema validator](https://ajv.js.org/), see [advanced usage](#%EF%B8%8F-advanced-usage).
Demo: [https://divlo.github.io/react-component-form/](https://divlo.github.io/react-component-form/).
Example demo: [https://react-component-form.vercel.app/](https://react-component-form.vercel.app/).
## 💾 Install
@ -56,12 +56,12 @@ export const Example = () => {
}
```
Basically you have access to the same props of the HTML `form` tag in React, but the onSubmit and the onChange props are differents.
Basically you have access to the same props of the HTML `form` tag in React, but the `onSubmit` and the `onChange` props are differents.
Instead to get the `event` param you get `formData` and `formElement` parameters:
- `formData`: It's an object where the keys are the name of your inputs and the current value. Behind the scene, it uses the [FormData](https://developer.mozilla.org/docs/Web/API/FormData) constructor.
- `formElement`: It's the actual HTML form element in the DOM so for example you can access the `.reset()` method on a [HTMLFormElement](https://developer.mozilla.org/docs/Web/API/HTMLFormElement).
- `formData`: Object where the keys are the name of your inputs and the current value. Behind the scene, it uses the [FormData](https://developer.mozilla.org/docs/Web/API/FormData) constructor.
- `formElement`: The HTML form element in the DOM so for example you can access the `.reset()` method on a [HTMLFormElement](https://developer.mozilla.org/docs/Web/API/HTMLFormElement).
## ⚙️ Advanced Usage
@ -83,15 +83,20 @@ const schema = {
}
export const Example = () => {
const { errors, handleUseForm } = useForm(schema)
const { handleUseForm, errors, message } = useForm(schema)
const onSubmit: HandleUseFormCallback<typeof schema> = (
formData,
formElement
) => {
console.log(formData) // { inputName: 'value of the input validated' }
console.log(formData) // { inputName: 'value of the input validated and type-safe' }
formElement.reset()
return null
// The return can be either `null` or an object with a global message of type `'error' | 'success'`.
return {
type: 'success',
message: 'Success: Form submitted'
}
}
return (
@ -100,11 +105,34 @@ export const Example = () => {
{errors.inputName != null && <p>{errors.inputName[0].message}</p>}
<button type='submit'>Submit</button>
{message != null && <p>{message}</p>}
</Form>
)
}
```
## API
### `useForm(schema)`
#### Parameters
- `schema`: The JSON schema to validate the data (recommended to use [@sinclair/typebox](https://www.npmjs.com/package/@sinclair/typebox)).
#### Returns
- `handleUseForm(onSubmit)`: Function to be used with the `onSubmit` or `onChange` prop of the `<Form />` component.
- `fetchState = 'idle'`: The current state of the form (`'error' | 'success' | 'idle' | 'loading'`).
- `setFetchState`: Function to update the `fetchState`.
- `message`: Global message of the form (not specific to a property).
- `setMessage`: Function to update the `message`.
- `errors`: Object of errors:
- Key: correspond to a property in the JSON Schema.
- Value: array of [ajv `ErrorObject`](https://ajv.js.org/api.html#error-objects).
The array will always have at least one element (never empty) in case of errors.
If the value is `undefined`, it means there are no errors for this property.
## 💡 Contributing
Anyone can help to improve the project, submit a Feature Request, a bug report or

3
example/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

39
example/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
cypress/screenshots
cypress/videos
cypress/downloads
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

1
example/.npmrc Normal file
View File

@ -0,0 +1 @@
save-exact=true

7
example/README.md Normal file
View File

@ -0,0 +1,7 @@
# example
This is an example for using `react-component-form` inside a Next.js application with translations thanks to [next-translate](https://www.npmjs.com/package/next-translate).
The application shows how to use the `<Form />` component with the `useForm` hook to validate and submit a form with a `name` input and an `email` input.
The interesting code is in [./components/FormExample.tsx](./components/FormExample.tsx).

View File

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

View File

@ -0,0 +1,72 @@
'use client'
import { Form, useForm } from 'react-component-form'
import type { HandleUseFormCallback } from 'react-component-form'
import useTranslation from 'next-translate/useTranslation'
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) => {
setTimeout(resolve, ms)
})
}
export const FormExample: React.FC = () => {
const { handleUseForm, errors, fetchState, message } = useForm(userSchema)
const { getFirstErrorTranslation } = useFormTranslation()
const { t } = useTranslation()
const onSubmit: HandleUseFormCallback<typeof userSchema> = async (
formData,
formElement
) => {
await fakeServerRequest(2_000)
console.log('onSubmit:', formData)
formElement.reset()
return {
type: 'success',
message: 'common:success-message'
}
}
return (
<section>
<Form
className='mt-6 w-[90%] max-w-xs'
noValidate
onSubmit={handleUseForm(onSubmit)}
>
<Input
type='text'
placeholder={t('common:name')}
name='name'
label={t('common:name')}
error={getFirstErrorTranslation(errors.name)}
/>
<Input
type='text'
placeholder='Email'
name='email'
label='Email'
error={getFirstErrorTranslation(errors.email)}
/>
<Button className='mt-6 w-full' type='submit' data-cy='submit'>
Submit
</Button>
</Form>
<FormState
id='message'
state={fetchState}
message={message != null ? t(message) : undefined}
/>
</section>
)
}

View File

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

View File

@ -0,0 +1,16 @@
export const Arrow: React.FC = () => {
return (
<svg
width='12'
height='8'
viewBox='0 0 12 8'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
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'
/>
</svg>
)
}

View File

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

View File

@ -0,0 +1,80 @@
import { useCallback, useEffect, useState, useRef } from 'react'
import useTranslation from 'next-translate/useTranslation'
import setLanguage from 'next-translate/setLanguage'
import classNames from 'clsx'
import i18n from '../../../i18n.json'
import { Arrow } from './Arrow'
import { LanguageFlag } from './LanguageFlag'
export const Language: React.FC = () => {
const { lang: currentLanguage } = useTranslation()
const [hiddenMenu, setHiddenMenu] = useState(true)
const languageClickRef = useRef<HTMLDivElement | null>(null)
const handleHiddenMenu = useCallback(() => {
setHiddenMenu((oldHiddenMenu) => {
return !oldHiddenMenu
})
}, [])
useEffect(() => {
const handleClickEvent = (event: MouseEvent): void => {
if (languageClickRef.current == null || event.target == null) {
return
}
if (!languageClickRef.current.contains(event.target as Node)) {
setHiddenMenu(true)
}
}
window.document.addEventListener('click', handleClickEvent)
return () => {
return window.removeEventListener('click', handleClickEvent)
}
}, [])
const handleLanguage = async (language: string): Promise<void> => {
await setLanguage(language)
}
return (
<div className='flex cursor-pointer flex-col items-center justify-center'>
<div
ref={languageClickRef}
data-cy='language-click'
className='mr-5 flex items-center'
onClick={handleHiddenMenu}
>
<LanguageFlag language={currentLanguage} />
<Arrow />
</div>
<ul
data-cy='languages-list'
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',
{ hidden: hiddenMenu }
)}
>
{i18n.locales.map((language, index) => {
if (language === currentLanguage) {
return null
}
return (
<li
key={index}
className='flex h-12 w-full items-center justify-center pl-2 hover:bg-[#4f545c] hover:bg-opacity-20'
onClick={async () => {
await handleLanguage(language)
}}
>
<LanguageFlag language={language} />
</li>
)
})}
</ul>
</div>
)
}

View File

@ -0,0 +1,78 @@
import { useEffect, useState } from 'react'
import classNames from 'clsx'
import { useTheme } from 'next-themes'
export const SwitchTheme: React.FC = () => {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
const handleClick = (): void => {
setTheme(theme === 'dark' ? 'light' : 'dark')
}
return (
<div
className='flex items-center'
data-cy='switch-theme-click'
onClick={handleClick}
>
<div className='relative inline-block cursor-pointer touch-pan-x select-none border-0 bg-transparent p-0'>
<div className='h-[24px] w-[50px] rounded-[30px] bg-[#4d4d4d] p-0 text-white transition-all duration-200 ease-in-out'>
<div
data-cy='switch-theme-dark'
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='relative flex h-[10px] w-[10px] items-center justify-center'>
🌜
</span>
</div>
<div
data-cy='switch-theme-light'
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='relative flex h-[10px] w-[10px] items-center justify-center'>
🌞
</span>
</div>
</div>
<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
data-cy='switch-theme-input'
type='checkbox'
aria-label='Dark mode toggle'
className='absolute m-[-1px] h-[1px] w-[1px] overflow-hidden border-0 p-0'
defaultChecked
/>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export * from './Header'

View File

@ -0,0 +1,19 @@
import classNames from 'clsx'
export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {}
export const Button: React.FC<ButtonProps> = (props) => {
const { children, className, ...rest } = props
return (
<button
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',
className
)}
{...rest}
>
{children}
</button>
)
}

View File

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

View File

@ -0,0 +1,37 @@
import classNames from 'clsx'
import { FormState } from './FormState'
export interface InputProps extends React.ComponentPropsWithRef<'input'> {
label: string
error?: string
className?: string
}
export const Input: React.FC<InputProps> = (props) => {
const { label, name, className, error, ...rest } = props
return (
<div className='flex flex-col'>
<div className={classNames('mt-6 mb-2 flex justify-between', className)}>
<label className='pl-1' htmlFor={name}>
{label}
</label>
</div>
<div className='relative mt-0'>
<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'
{...rest}
id={name}
name={name}
data-cy={`input-${name ?? 'name'}`}
/>
<FormState
id={`error-${name ?? 'input'}`}
state={error == null ? 'idle' : 'error'}
message={error}
/>
</div>
</div>
)
}

View File

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

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 } = props
return (
<div className={props.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

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

12
example/cypress.config.ts Normal file
View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

View File

@ -1 +0,0 @@
declare module '*.jpg'

View File

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

7
example/i18n.json Normal file
View File

@ -0,0 +1,7 @@
{
"locales": ["en", "fr"],
"defaultLocale": "en",
"pages": {
"*": ["common"]
}
}

View File

@ -1,112 +0,0 @@
*,
*::after,
*::before {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
text-align: left;
background-color: #fff;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 500;
line-height: 1.2;
}
h2 {
font-size: 2rem;
}
h4 {
font-size: 1.5rem;
}
h5 {
font-size: 1.25rem;
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.result-container {
border-top: 0.2px solid lightgray;
border-bottom: 0.2px solid lightgray;
width: 100%;
text-align: center;
}
.github-logo {
position: fixed;
top: 10px;
right: 10px;
}
.title-install {
color: gray;
margin-bottom: 50px;
margin-top: 0;
}
form {
margin-bottom: 40px;
}
label {
display: inline-block;
margin-bottom: 0.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-control {
display: block;
width: 100%;
height: calc(1.5em + 0.75rem + 2px);
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #495057;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.btn {
display: inline-block;
font-weight: 400;
color: #212529;
text-align: center;
vertical-align: middle;
-webkit-user-select: none;
user-select: none;
background-color: transparent;
border: 1px solid transparent;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
border-radius: 0.25rem;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
cursor: pointer;
}
.btn-primary {
color: #fff;
background-color: #007bff;
border-color: #007bff;
}
.btn-primary:hover {
color: #fff;
background-color: #0069d9;
border-color: #0062cc;
}

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="./github.jpg" type="image/jpg" />
<title>react-component-form</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View File

@ -1,38 +0,0 @@
import { createRoot } from 'react-dom/client'
import React from 'react'
import { Form, useForm } from 'react-component-form'
import type { HandleUseFormCallback } from 'react-component-form'
const schema = {
inputName: {
type: 'string',
minLength: 3,
maxLength: 20
}
}
export const Example = () => {
const { errors, handleUseForm } = useForm(schema)
const onSubmit: HandleUseFormCallback<typeof schema> = (
formData,
formElement
) => {
console.log(formData) // { inputName: 'value of the input validated' }
formElement.reset()
return null
}
return (
<Form onSubmit={handleUseForm(onSubmit)}>
<input type='text' name='inputName' />
{errors.inputName != null && <p>{errors.inputName[0].message}</p>}
<button type='submit'>Submit</button>
</Form>
)
}
const container = document.getElementById('root') as HTMLElement
const root = createRoot(container)
root.render(<Example />)

View File

@ -0,0 +1,15 @@
{
"about": "This is an example of using <0>`react-component-form`</0> inside a Next.js application. The application shows how to use the <0>`<Form />`</0> component with the <0>`useForm`</0> hook to validate and submit a form with a <0>`name`</0> and an <0>`email`</0> input.",
"name": "Name",
"error": "Error",
"success": "Success",
"success-message": "The form has been submitted.",
"page-not-found": "This page could not be found.",
"server-error": "Internal Server Error.",
"return-to-home-page": "Return to the home page?",
"required": "Oops, this field is required 🙈.",
"minLength": "The field must contain at least {expected} characters.",
"maxLength": "The field must contain at most {expected} characters.",
"invalid-email": "Mmm… It seems that this email is not valid 🤔.",
"invalid": "Invalid value."
}

View File

@ -0,0 +1,15 @@
{
"about": "Ceci est un exemple d'utilisation de <0>`react-component-form`</0> dans une application Next.js. L'application montre comment utiliser le composant <0>`<Form />`</0> avec le hook <0>`useForm`</0> hook pour valider et soumettre un formulaire avec un input `name` et `email`.",
"name": "Nom",
"error": "Erreur",
"success": "Succès",
"success-message": "Le formulaire a été envoyé.",
"page-not-found": "Cette page est introuvable.",
"server-error": "Erreur interne du serveur.",
"return-to-home-page": "Revenir à la page d'accueil ?",
"required": "Oups, ce champ est obligatoire 🙈.",
"minLength": "Le champ doit contenir au moins {expected} caractères.",
"maxLength": "Le champ doit contenir au plus {expected} caractères.",
"invalid-email": "Mmm… Il semblerait que cet email ne soit pas valide 🤔.",
"invalid": "Valeur invalide."
}

11
example/models/User.ts Normal file
View File

@ -0,0 +1,11 @@
import type { Static } from '@sinclair/typebox'
import { Type } from '@sinclair/typebox'
export const userSchema = {
name: Type.String({ minLength: 3, maxLength: 10 }),
email: Type.String({ minLength: 1, maxLength: 254, format: 'email' })
}
export const userObjectSchema = Type.Object(userSchema)
export type User = Static<typeof userObjectSchema>

8
example/next.config.js Executable file
View File

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

9602
example/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,38 @@
{
"name": "example",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "parcel index.html",
"build": "parcel build index.html --public-url \"/react-component-form/\""
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test:e2e": "start-server-and-test \"start\" \"http://127.0.0.1:3000\" \"cypress run\"",
"test:dev": "start-server-and-test \"dev\" \"http://127.0.0.1:3000\" \"cypress open\""
},
"dependencies": {
"react": "file:../node_modules/react",
"@sinclair/typebox": "0.29.0",
"clsx": "1.2.1",
"next": "13.2.4",
"next-themes": "0.2.1",
"next-translate": "2.0.5",
"react": "18.2.0",
"react-component-form": "file:..",
"react-dom": "file:../node_modules/react-dom"
"react-dom": "18.2.0"
},
"devDependencies": {
"@parcel/transformer-image": "2.7.0",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6",
"parcel": "2.7.0",
"process": "^0.11.10",
"typescript": "4.7.4"
"@tsconfig/strictest": "2.0.1",
"@types/node": "20.3.3",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"autoprefixer": "10.4.14",
"cypress": "12.16.0",
"eslint": "8.44.0",
"eslint-config-next": "13.2.4",
"next-translate-plugin": "2.0.5",
"postcss": "8.4.24",
"start-server-and-test": "2.0.0",
"tailwindcss": "3.3.2",
"typescript": "5.0.4"
}
}

14
example/pages/_app.tsx Normal file
View File

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

View File

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

30
example/pages/index.tsx Normal file
View File

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

View File

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

BIN
example/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,30 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M46 4.6C41.3 1.7 35.9 0 30 0V4.6H46Z" fill="#ED4C5C"/>
<path d="M30 9.2H51.6C49.9 7.5 48 5.9 46 4.6H30V9.2Z" fill="white"/>
<path d="M30 13.8H55.3C54.2 12.1 53 10.6 51.7 9.2H30V13.8Z" fill="#ED4C5C"/>
<path d="M30 18.4H57.7C57 16.8 56.2 15.2 55.3 13.8H30V18.4Z" fill="white"/>
<path d="M30 23H59.2C58.8 21.4 58.3 19.9 57.7 18.4H30V23Z" fill="#ED4C5C"/>
<path d="M30 27.7H59.9C59.8 26.1 59.5 24.6 59.2 23.1H30V27.7Z" fill="white"/>
<path d="M59.9 27.7H30V30H0C0 30.8 -9.68575e-08 31.5 0.0999999 32.3H59.9C60 31.5 60 30.8 60 30C60 29.2 60 28.4 59.9 27.7Z" fill="#ED4C5C"/>
<path d="M0.800006 36.9H59.2C59.6 35.4 59.8 33.9 59.9 32.3H0.100006C0.200006 33.8 0.400006 35.4 0.800006 36.9Z" fill="white"/>
<path d="M2.3 41.5H57.7C58.3 40 58.8 38.5 59.2 36.9H0.800003C1.2 38.5 1.7 40 2.3 41.5Z" fill="#ED4C5C"/>
<path d="M4.7 46.1H55.3C56.2 44.6 57 43.1 57.7 41.5H2.3C3 43.1 3.8 44.6 4.7 46.1Z" fill="white"/>
<path d="M8.3 50.7H51.7C53 49.3 54.3 47.7 55.3 46.1H4.7C5.7 47.8 7 49.3 8.3 50.7Z" fill="#ED4C5C"/>
<path d="M13.9 55.3H46.1C48.2 54 50 52.4 51.7 50.7H8.3C10 52.5 11.9 54 13.9 55.3Z" fill="white"/>
<path d="M30 60C35.9 60 41.4 58.3 46.1 55.3H13.9C18.6 58.3 24.1 60 30 60Z" fill="#ED4C5C"/>
<path d="M14 4.6C11.9 5.9 10 7.5 8.3 9.2C6.9 10.6 5.7 12.2 4.7 13.8C3.8 15.3 2.9 16.8 2.3 18.4C1.7 19.9 1.2 21.4 0.8 23C0.4 24.5 0.2 26 0.0999999 27.6C-9.68575e-08 28.4 0 29.2 0 30H30V0C24.1 0 18.7 1.7 14 4.6Z" fill="#428BC1"/>
<path d="M23 1L23.5 2.5H25L23.8 3.5L24.2 5L23 4.1L21.8 5L22.2 3.5L21 2.5H22.5L23 1Z" fill="white"/>
<path d="M27 7L27.5 8.5H29L27.8 9.5L28.2 11L27 10.1L25.8 11L26.2 9.5L25 8.5H26.5L27 7Z" fill="white"/>
<path d="M19 7L19.5 8.5H21L19.8 9.5L20.2 11L19 10.1L17.8 11L18.2 9.5L17 8.5H18.5L19 7Z" fill="white"/>
<path d="M23 13L23.5 14.5H25L23.8 15.5L24.2 17L23 16.1L21.8 17L22.2 15.5L21 14.5H22.5L23 13Z" fill="white"/>
<path d="M15 13L15.5 14.5H17L15.8 15.5L16.2 17L15 16.1L13.8 17L14.2 15.5L13 14.5H14.5L15 13Z" fill="white"/>
<path d="M7 13L7.5 14.5H9L7.8 15.5L8.2 17L7 16.1L5.8 17L6.2 15.5L5 14.5H6.5L7 13Z" fill="white"/>
<path d="M27 19L27.5 20.5H29L27.8 21.5L28.2 23L27 22.1L25.8 23L26.2 21.5L25 20.5H26.5L27 19Z" fill="white"/>
<path d="M19 19L19.5 20.5H21L19.8 21.5L20.2 23L19 22.1L17.8 23L18.2 21.5L17 20.5H18.5L19 19Z" fill="white"/>
<path d="M11 19L11.5 20.5H13L11.8 21.5L12.2 23L11 22.1L9.8 23L10.2 21.5L9 20.5H10.5L11 19Z" fill="white"/>
<path d="M23 25L23.5 26.5H25L23.8 27.5L24.2 29L23 28.1L21.8 29L22.2 27.5L21 26.5H22.5L23 25Z" fill="white"/>
<path d="M15 25L15.5 26.5H17L15.8 27.5L16.2 29L15 28.1L13.8 29L14.2 27.5L13 26.5H14.5L15 25Z" fill="white"/>
<path d="M7 25L7.5 26.5H9L7.8 27.5L8.2 29L7 28.1L5.8 29L6.2 27.5L5 26.5H6.5L7 25Z" fill="white"/>
<path d="M9.79999 11L11 10.1L12.2 11L11.7 9.5L12.9 8.5H11.4L11 7L10.5 8.5H9.09999L10.3 9.4L9.79999 11Z" fill="white"/>
<path d="M1.79999 23L2.99999 22.1L4.19999 23L3.69999 21.5L4.89999 20.5H3.49999L2.99999 19L2.49999 20.5H1.49999C1.49999 20.6 1.39999 20.7 1.39999 20.8L2.19999 21.4L1.79999 23Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,12 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M0 17.5C0 25.1417 4.9 31.6167 11.6667 34.0084V0.991699C4.9 3.38337 0 9.85837 0 17.5Z" fill="#428BC1"/>
<path d="M35 17.5C35 9.85837 30.1584 3.38337 23.3334 0.991699V34.0084C30.1584 31.6167 35 25.1417 35 17.5Z" fill="#ED4C5C"/>
<path d="M11.6666 34.0083C13.475 34.65 15.4583 35 17.5 35C19.5416 35 21.525 34.65 23.3333 34.0083V0.991667C21.525 0.35 19.6 0 17.5 0C15.4 0 13.475 0.35 11.6666 0.991667V34.0083Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 659 B

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

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

View File

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

23257
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,70 +4,76 @@
"public": true,
"type": "module",
"description": "Manage React Forms with ease.",
"author": "Divlo <contact@divlo.fr>",
"author": "Théo LUDWIG <contact@theoludwig.fr>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Divlo/react-component-form.git"
"url": "https://github.com/theoludwig/react-component-form.git"
},
"keywords": [
"react-form",
"react-component-form"
],
"bugs": {
"url": "https://github.com/Divlo/react-component-form/issues"
"url": "https://github.com/theoludwig/react-component-form/issues"
},
"homepage": "https://github.com/Divlo/react-component-form",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"homepage": "https://react-component-form.vercel.app/",
"main": "build/index.js",
"types": "build/index.d.ts",
"files": [
"dist"
"build"
],
"publishConfig": {
"access": "public",
"provenance": true
},
"scripts": {
"build": "tsup",
"test": "jest",
"lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint-cli2",
"lint:typescript": "eslint \"**/*.{js,jsx,ts,tsx}\"",
"lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"",
"lint:eslint": "eslint .",
"lint:prettier": "prettier . --check --ignore-path .gitignore",
"release": "semantic-release"
},
"peerDependencies": {
"react": ">=16"
"react": ">=18.2.0"
},
"dependencies": {
"@sinclair/typebox": "0.24.28",
"ajv": "8.11.0",
"@sinclair/typebox": "0.29.0",
"ajv": "8.12.0",
"ajv-formats": "2.1.1"
},
"devDependencies": {
"@commitlint/cli": "17.0.3",
"@commitlint/config-conventional": "17.0.3",
"@testing-library/react": "13.3.0",
"@types/jest": "28.1.8",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6",
"@typescript-eslint/eslint-plugin": "5.35.1",
"@typescript-eslint/parser": "5.35.1",
"editorconfig-checker": "4.0.2",
"esbuild": "0.15.5",
"@commitlint/cli": "17.6.6",
"@commitlint/config-conventional": "17.6.6",
"@testing-library/react": "14.0.0",
"@tsconfig/strictest": "2.0.1",
"@types/jest": "29.5.2",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"@typescript-eslint/eslint-plugin": "5.60.1",
"@typescript-eslint/parser": "5.60.1",
"editorconfig-checker": "5.1.1",
"esbuild": "0.18.11",
"esbuild-jest": "0.5.0",
"eslint": "8.22.0",
"eslint-config-conventions": "3.0.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.26.0",
"eslint": "8.44.0",
"eslint-config-conventions": "10.0.0",
"eslint-config-prettier": "8.8.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-promise": "6.0.1",
"eslint-plugin-unicorn": "43.0.2",
"jest": "29.0.0",
"jest-environment-jsdom": "29.0.0",
"markdownlint-cli2": "0.5.1",
"prettier": "2.7.1",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-unicorn": "47.0.0",
"jest": "29.5.0",
"jest-environment-jsdom": "29.5.0",
"markdownlint-cli2": "0.8.1",
"markdownlint-rule-relative-links": "2.1.0",
"prettier": "2.8.8",
"react": "18.2.0",
"react-dom": "18.2.0",
"semantic-release": "19.0.5",
"tsup": "6.2.2",
"typescript": "4.7.4"
"semantic-release": "21.0.6",
"tsup": "7.1.0",
"typescript": "5.0.4"
}
}

View File

@ -1,7 +1,8 @@
import React from 'react'
import { render, cleanup, fireEvent } from '@testing-library/react'
import { Form, HandleForm } from '..'
import type { HandleForm } from '..'
import { Form } from '..'
afterEach(cleanup)
@ -28,14 +29,14 @@ describe('<Form />', () => {
const text = 'some random text'
fireEvent.change(inputForm, { target: { value: text } })
expect(formData.inputName).toEqual(text)
expect(formData['inputName']).toEqual(text)
expect(formElement instanceof HTMLFormElement).toBeTruthy()
formData = {}
formElement = null
fireEvent.click(buttonSubmit)
expect(Object.keys(formData).length).toEqual(1)
expect(formData.inputName).toEqual(text)
expect(formData['inputName']).toEqual(text)
expect(formElement instanceof HTMLFormElement).toBeTruthy()
})
})

View File

@ -4,6 +4,10 @@ export interface FormDataObject {
[key: string]: FormDataEntryValue
}
/**
* @param formData Object where the keys are the name of your inputs and the current value.
* @param formElement The HTML form element in the DOM.
*/
export type HandleForm = (
formData: FormDataObject,
formElement: HTMLFormElement

View File

@ -2,7 +2,7 @@ import { useState } from 'react'
export const fetchState = ['idle', 'loading', 'error', 'success'] as const
export type FetchState = typeof fetchState[number]
export type FetchState = (typeof fetchState)[number]
export const useFetchState = (
initialFetchState: FetchState = 'idle'

View File

@ -1,14 +1,18 @@
import { useMemo, useState } from 'react'
import { SchemaOptions, Static, TObject, Type } from '@sinclair/typebox'
import type { Static, TObject } from '@sinclair/typebox'
import { Type } from '@sinclair/typebox'
import type { ErrorObject } from 'ajv'
import type { HandleForm } from '../components/Form'
import { FetchState, useFetchState } from './useFetchState'
import type { FetchState } from './useFetchState'
import { useFetchState } from './useFetchState'
import { ajv } from '../utils/ajv'
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
@ -16,10 +20,17 @@ export type ErrorsObject<K extends Schema> = {
[key in keyof Partial<K>]: Error[] | undefined
}
export type HandleUseFormCallbackResult<K extends Schema> = Message<K> | null
/**
* @param formData Object where the keys are the name of your inputs and the current value.
* @param formElement The HTML form element in the DOM.
* @returns The return can be either `null` or an object with a global message of type `'error' | 'success'`.
*/
export type HandleUseFormCallback<K extends Schema> = (
formData: Static<TObject<K>>,
formElement: HTMLFormElement
) => Promise<Message<K> | null> | Message<K> | null
) => Promise<HandleUseFormCallbackResult<K>> | HandleUseFormCallbackResult<K>
export type HandleUseForm<K extends Schema> = (
callback?: HandleUseFormCallback<K>
@ -27,36 +38,44 @@ export type HandleUseForm<K extends Schema> = (
export interface GlobalMessage {
type: 'error' | 'success'
value?: string
message?: string
properties?: undefined
}
export interface PropertiesMessage<K extends Schema> {
type: 'error'
value?: string
message?: string
properties: { [key in keyof Partial<K>]: string }
}
export type Message<K extends Schema> = GlobalMessage | PropertiesMessage<K>
export interface UseFormResult<K extends Schema> {
/**
* Function to be used with the `onSubmit` or `onChange` prop of the `<Form />` component.
*/
handleUseForm: HandleUseForm<K>
/**
* The current state of the form.
*/
readonly fetchState: FetchState
setFetchState: React.Dispatch<React.SetStateAction<FetchState>>
/**
* Global message of the form (not specific to a property).
*/
readonly message: string | null
setMessage: React.Dispatch<React.SetStateAction<string | null>>
readonly message: string | undefined
setMessage: React.Dispatch<React.SetStateAction<string | undefined>>
/**
* Errors for each property.
* Object of errors:
* - Key: correspond to a property in the JSON Schema.
* - Value: array of {@link ErrorObject}.
*
* The array will always have at least one element (never empty) in case of errors.
*
* `undefined` means no errors.
* If the value is `undefined`, it means there are no errors for this property.
*/
readonly errors: ErrorsObject<K>
}
@ -69,7 +88,7 @@ export const useForm = <K extends Schema>(
}, [validationSchema])
const [fetchState, setFetchState] = useFetchState()
const [message, setMessage] = useState<string | null>(null)
const [message, setMessage] = useState<string | undefined>(undefined)
const [errors, setErrors] = useState<ErrorsObject<typeof validationSchema>>(
{} as any
)
@ -81,7 +100,7 @@ export const useForm = <K extends Schema>(
const handleUseForm: HandleUseForm<typeof validationSchema> = (callback) => {
return async (formData, formElement) => {
setErrors({} as any)
setMessage(null)
setMessage(undefined)
formData = handleOptionalEmptyStringToNull(
formData,
validationSchemaObject.required
@ -110,8 +129,8 @@ export const useForm = <K extends Schema>(
formElement
)
if (message != null) {
const { value = null, type, properties } = message
setMessage(value)
const { message: messageValue, type, properties } = message
setMessage(messageValue)
setFetchState(type)
if (type === 'error') {
const propertiesErrors: ErrorsObject<typeof validationSchema> =

View File

@ -1,11 +1,11 @@
import type { TObject } from '@sinclair/typebox'
import type { ObjectAny } from './types'
import type { Schema } from '../hooks/useForm'
export const handleCheckboxBoolean = (
object: ObjectAny,
validateSchemaObject: TObject<ObjectAny>
): ObjectAny => {
object: Schema,
validateSchemaObject: TObject<Schema>
): Schema => {
const booleanProperties: string[] = []
for (const property in validateSchemaObject.properties) {
const rule = validateSchemaObject.properties[property]

View File

@ -1,4 +1,6 @@
export const handleOptionalEmptyStringToNull = <K>(
import type { Schema } from '../hooks/useForm'
export const handleOptionalEmptyStringToNull = <K extends Schema>(
object: K,
required: string[] = []
): 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"],
"compilerOptions": {
"module": "ESNext",
@ -7,16 +8,8 @@
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"moduleResolution": "node",
"jsx": "react",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
}
}

View File

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