24 Commits

Author SHA1 Message Date
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
70 changed files with 12873 additions and 21624 deletions

View File

@ -1,5 +1,4 @@
node_modules node_modules
build build
dist dist
.parcel-cache
example 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. --> <!-- 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

@ -2,7 +2,7 @@ name: 'Build'
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]
@ -10,19 +10,19 @@ jobs:
build: build:
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
steps: steps:
- uses: 'actions/checkout@v3.0.0' - uses: 'actions/checkout@v3.5.2'
- name: 'Use Node.js' - name: 'Setup Node.js'
uses: 'actions/setup-node@v3.1.0' uses: 'actions/setup-node@v3.6.0'
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

@ -2,7 +2,7 @@ name: 'Lint'
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]
@ -10,19 +10,19 @@ jobs:
lint: lint:
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
steps: steps:
- uses: 'actions/checkout@v3.0.0' - uses: 'actions/checkout@v3.5.2'
- name: 'Use Node.js' - name: 'Setup Node.js'
uses: 'actions/setup-node@v3.1.0' uses: 'actions/setup-node@v3.6.0'
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

@ -2,26 +2,34 @@ name: 'Release'
on: on:
push: push:
branches: [master, develop] branches: [master]
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@v3.5.2'
- name: 'Use Node.js' - name: 'Setup Node.js'
uses: 'actions/setup-node@v3.1.0' uses: 'actions/setup-node@v3.6.0'
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: 'Verify the integrity of provenance attestations and registry signatures for installed dependencies'
run: 'npm audit signatures'
- name: 'Release' - name: 'Release'
run: 'npm run release' run: 'npm run release'
env: env:

View File

@ -2,7 +2,7 @@ name: 'Test'
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]
@ -10,16 +10,39 @@ jobs:
test: test:
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
steps: steps:
- uses: 'actions/checkout@v3.0.0' - uses: 'actions/checkout@v3.5.2'
- name: 'Use Node.js' - name: 'Setup Node.js'
uses: 'actions/setup-node@v3.1.0' uses: 'actions/setup-node@v3.6.0'
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:
runs-on: 'ubuntu-latest'
steps:
- uses: 'actions/checkout@v3.5.2'
- 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 # production
build build
dist dist
.next
# testing # testing
coverage coverage

View File

@ -1,11 +1,11 @@
{ {
"config": { "config": {
"extends": "markdownlint/style/prettier",
"relative-links": true,
"default": true, "default": true,
"MD013": false, "MD033": false
"MD024": false,
"MD033": false,
"MD041": false
}, },
"globs": ["**/*.{md,mdx}"], "globs": ["**/*.{md,mdx}"],
"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,8 +1,5 @@
{ {
"branches": [ "branches": ["master"],
"master",
{ "name": "develop", "prerelease": "beta", "channel": "beta" }
],
"plugins": [ "plugins": [
[ [
"@semantic-release/commit-analyzer", "@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 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@divlo.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

@ -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). 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 ## 💾 Install
@ -34,7 +34,7 @@ npm install --save react-component-form
## ⚙️ Usage ## ⚙️ Usage
_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'
@ -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: 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. - `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`: 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). - `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 ## ⚙️ Advanced Usage
@ -83,15 +83,20 @@ const schema = {
} }
export const Example = () => { export const Example = () => {
const { errors, handleUseForm } = useForm(schema) const { handleUseForm, errors, message } = useForm(schema)
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()
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 ( return (
@ -100,11 +105,34 @@ export const Example = () => {
{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>}
</Form> </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 ## 💡 Contributing
Anyone can help to improve the project, submit a Feature Request, a bug report or 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)

9773
example/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,38 @@
{ {
"name": "example", "name": "example",
"type": "module", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "parcel index.html", "dev": "next dev",
"build": "parcel build index.html --public-url \"/react-component-form/\"" "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": { "dependencies": {
"react": "file:../node_modules/react", "@sinclair/typebox": "0.28.10",
"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-component-form": "file:..",
"react-dom": "file:../node_modules/react-dom" "react-dom": "18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@parcel/transformer-image": "2.7.0", "@tsconfig/strictest": "2.0.1",
"@types/react": "18.0.17", "@types/node": "20.1.4",
"@types/react-dom": "18.0.6", "@types/react": "18.2.6",
"parcel": "2.7.0", "@types/react-dom": "18.2.4",
"process": "^0.11.10", "autoprefixer": "10.4.14",
"typescript": "4.7.4" "cypress": "12.12.0",
"eslint": "8.40.0",
"eslint-config-next": "13.2.4",
"next-translate-plugin": "2.0.5",
"postcss": "8.4.23",
"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": { "compilerOptions": {
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext", "target": "ESNext",
"jsx": "react", "module": "ESNext",
"lib": ["dom", "dom.iterable", "ESNext"],
"allowJs": true,
"types": ["cypress"],
"noEmit": true,
"moduleResolution": "node", "moduleResolution": "node",
"sourceMap": true, "resolveJsonModule": true,
"strict": true, "jsx": "preserve",
"esModuleInterop": true "incremental": true,
} "exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": false,
"isolatedModules": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
} }

23222
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,57 +17,63 @@
"bugs": { "bugs": {
"url": "https://github.com/Divlo/react-component-form/issues" "url": "https://github.com/Divlo/react-component-form/issues"
}, },
"homepage": "https://github.com/Divlo/react-component-form", "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 \".\"",
"lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"", "lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"",
"release": "semantic-release" "release": "semantic-release"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=16" "react": ">=18.2.0"
}, },
"dependencies": { "dependencies": {
"@sinclair/typebox": "0.24.28", "@sinclair/typebox": "0.28.10",
"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": "17.6.3",
"@commitlint/config-conventional": "17.0.3", "@commitlint/config-conventional": "17.6.3",
"@testing-library/react": "13.3.0", "@testing-library/react": "14.0.0",
"@types/jest": "28.1.8", "@tsconfig/strictest": "2.0.1",
"@types/react": "18.0.17", "@types/jest": "29.5.1",
"@types/react-dom": "18.0.6", "@types/react": "18.2.6",
"@typescript-eslint/eslint-plugin": "5.35.1", "@types/react-dom": "18.2.4",
"@typescript-eslint/parser": "5.35.1", "@typescript-eslint/eslint-plugin": "5.59.5",
"editorconfig-checker": "4.0.2", "@typescript-eslint/parser": "5.59.5",
"esbuild": "0.15.5", "editorconfig-checker": "5.0.1",
"esbuild": "0.17.19",
"esbuild-jest": "0.5.0", "esbuild-jest": "0.5.0",
"eslint": "8.22.0", "eslint": "8.40.0",
"eslint-config-conventions": "3.0.0", "eslint-config-conventions": "9.0.0",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.8.0",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-promise": "6.0.1", "eslint-plugin-promise": "6.1.1",
"eslint-plugin-unicorn": "43.0.2", "eslint-plugin-unicorn": "47.0.0",
"jest": "29.0.0", "jest": "29.5.0",
"jest-environment-jsdom": "29.0.0", "jest-environment-jsdom": "29.5.0",
"markdownlint-cli2": "0.5.1", "markdownlint-cli2": "0.7.1",
"prettier": "2.7.1", "markdownlint-rule-relative-links": "1.2.0",
"prettier": "2.8.8",
"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": "21.0.2",
"tsup": "6.2.2", "tsup": "6.7.0",
"typescript": "4.7.4" "typescript": "5.0.4"
} }
} }

View File

@ -1,7 +1,8 @@
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)
@ -28,14 +29,14 @@ describe('<Form />', () => {
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

@ -4,6 +4,10 @@ export interface FormDataObject {
[key: string]: FormDataEntryValue [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 = ( export type HandleForm = (
formData: FormDataObject, formData: FormDataObject,
formElement: HTMLFormElement formElement: HTMLFormElement

View File

@ -2,7 +2,7 @@ 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'

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 } from '@sinclair/typebox'
import type { ErrorObject } from 'ajv' 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 { useFetchState } from './useFetchState'
import { ajv } from '../utils/ajv' import { ajv } from '../utils/ajv'
import { handleCheckboxBoolean } from '../utils/handleCheckboxBoolean' import { handleCheckboxBoolean } from '../utils/handleCheckboxBoolean'
import { handleOptionalEmptyStringToNull } from '../utils/handleOptionalEmptyStringToNull' import { handleOptionalEmptyStringToNull } from '../utils/handleOptionalEmptyStringToNull'
export type Schema = SchemaOptions export interface Schema {
[property: string | symbol]: any
}
export type Error = ErrorObject export type Error = ErrorObject
@ -16,10 +20,17 @@ export type ErrorsObject<K extends Schema> = {
[key in keyof Partial<K>]: Error[] | undefined [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> = ( export type HandleUseFormCallback<K extends Schema> = (
formData: Static<TObject<K>>, formData: Static<TObject<K>>,
formElement: HTMLFormElement formElement: HTMLFormElement
) => Promise<Message<K> | null> | Message<K> | null ) => Promise<HandleUseFormCallbackResult<K>> | HandleUseFormCallbackResult<K>
export type HandleUseForm<K extends Schema> = ( export type HandleUseForm<K extends Schema> = (
callback?: HandleUseFormCallback<K> callback?: HandleUseFormCallback<K>
@ -27,36 +38,44 @@ export type HandleUseForm<K extends Schema> = (
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 }
} }
export type Message<K extends Schema> = GlobalMessage | PropertiesMessage<K> export type Message<K extends Schema> = GlobalMessage | PropertiesMessage<K>
export interface UseFormResult<K extends Schema> { export interface UseFormResult<K extends Schema> {
/**
* Function to be used with the `onSubmit` or `onChange` prop of the `<Form />` component.
*/
handleUseForm: HandleUseForm<K> handleUseForm: HandleUseForm<K>
/**
* The current state of the form.
*/
readonly fetchState: FetchState readonly fetchState: FetchState
setFetchState: React.Dispatch<React.SetStateAction<FetchState>> setFetchState: React.Dispatch<React.SetStateAction<FetchState>>
/** /**
* Global message of the form (not specific to a property). * Global message of the form (not specific to a property).
*/ */
readonly message: string | null readonly message: string | undefined
setMessage: React.Dispatch<React.SetStateAction<string | null>> 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. * 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> readonly errors: ErrorsObject<K>
} }
@ -69,7 +88,7 @@ export const useForm = <K extends Schema>(
}, [validationSchema]) }, [validationSchema])
const [fetchState, setFetchState] = useFetchState() 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>>( const [errors, setErrors] = useState<ErrorsObject<typeof validationSchema>>(
{} as any {} as any
) )
@ -81,7 +100,7 @@ export const useForm = <K extends Schema>(
const handleUseForm: HandleUseForm<typeof validationSchema> = (callback) => { const handleUseForm: HandleUseForm<typeof validationSchema> = (callback) => {
return async (formData, formElement) => { return async (formData, formElement) => {
setErrors({} as any) setErrors({} as any)
setMessage(null) setMessage(undefined)
formData = handleOptionalEmptyStringToNull( formData = handleOptionalEmptyStringToNull(
formData, formData,
validationSchemaObject.required validationSchemaObject.required
@ -110,8 +129,8 @@ export const useForm = <K extends Schema>(
formElement formElement
) )
if (message != null) { if (message != null) {
const { value = null, 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> =

View File

@ -1,11 +1,11 @@
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]

View File

@ -1,4 +1,6 @@
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 => {

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,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": 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,
outDir: 'build',
dts: true dts: true
}) })