diff --git a/.changeset/angry-cooks-yell.md b/.changeset/angry-cooks-yell.md new file mode 100644 index 00000000..b2daa292 --- /dev/null +++ b/.changeset/angry-cooks-yell.md @@ -0,0 +1,9 @@ +--- +'@felte/common': minor +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add `addField` helper function diff --git a/.changeset/beige-tomatoes-collect.md b/.changeset/beige-tomatoes-collect.md new file mode 100644 index 00000000..a3b2037c --- /dev/null +++ b/.changeset/beige-tomatoes-collect.md @@ -0,0 +1,6 @@ +--- +'@felte/core': patch +'@felte/react': patch +--- + +Fix hot module reloading diff --git a/.changeset/blue-bags-lie.md b/.changeset/blue-bags-lie.md new file mode 100644 index 00000000..58aabaf0 --- /dev/null +++ b/.changeset/blue-bags-lie.md @@ -0,0 +1,10 @@ +--- +'@felte/core': minor +'@felte/reporter-dom': minor +'@felte/reporter-react': minor +'@felte/reporter-solid': minor +'@felte/reporter-svelte': minor +'@felte/reporter-tippy': minor +--- + +Change responsibility for adding `aria-invalid` to fields to `@felte/core` diff --git a/.changeset/breezy-shrimps-think.md b/.changeset/breezy-shrimps-think.md new file mode 100644 index 00000000..fd246f3f --- /dev/null +++ b/.changeset/breezy-shrimps-think.md @@ -0,0 +1,5 @@ +--- +'@felte/core': patch +--- + +Fix some values disappearing from DOM when removing a field from an array diff --git a/.changeset/breezy-steaks-matter.md b/.changeset/breezy-steaks-matter.md new file mode 100644 index 00000000..41d6824a --- /dev/null +++ b/.changeset/breezy-steaks-matter.md @@ -0,0 +1,9 @@ +--- +'@felte/common': minor +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Improve types diff --git a/.changeset/calm-foxes-battle.md b/.changeset/calm-foxes-battle.md new file mode 100644 index 00000000..7839e159 --- /dev/null +++ b/.changeset/calm-foxes-battle.md @@ -0,0 +1,7 @@ +--- +'@felte/reporter-react': minor +'@felte/reporter-solid': minor +'@felte/reporter-svelte': minor +--- + +Add `level` prop to select from which store to obtain validation message. Possible values: `'error' | 'warning'` diff --git a/.changeset/calm-mugs-film.md b/.changeset/calm-mugs-film.md new file mode 100644 index 00000000..b1a8a7df --- /dev/null +++ b/.changeset/calm-mugs-film.md @@ -0,0 +1,9 @@ +--- +'@felte/common': minor +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add isValidating store diff --git a/.changeset/chatty-bikes-rule.md b/.changeset/chatty-bikes-rule.md new file mode 100644 index 00000000..5e46f210 --- /dev/null +++ b/.changeset/chatty-bikes-rule.md @@ -0,0 +1,5 @@ +--- +'@felte/react': patch +--- + +Fix equality checker for files diff --git a/.changeset/chatty-cobras-kick.md b/.changeset/chatty-cobras-kick.md new file mode 100644 index 00000000..19bbb211 --- /dev/null +++ b/.changeset/chatty-cobras-kick.md @@ -0,0 +1,10 @@ +--- +'@felte/common': patch +'@felte/core': patch +'@felte/extender-persist': patch +'felte': patch +'@felte/react': patch +'@felte/solid': patch +--- + +Fix unset on Safari diff --git a/.changeset/chilly-pigs-sing.md b/.changeset/chilly-pigs-sing.md new file mode 100644 index 00000000..bca8f39f --- /dev/null +++ b/.changeset/chilly-pigs-sing.md @@ -0,0 +1,10 @@ +--- +'@felte/reporter-cvapi': minor +'@felte/reporter-dom': minor +'@felte/reporter-react': minor +'@felte/reporter-solid': minor +'@felte/reporter-svelte': minor +'@felte/reporter-tippy': minor +--- + +Ensure good behaviour with controls created by `useField`/`createField` by only focusing non-hidden inputs diff --git a/.changeset/clever-moles-switch.md b/.changeset/clever-moles-switch.md new file mode 100644 index 00000000..0c29125d --- /dev/null +++ b/.changeset/clever-moles-switch.md @@ -0,0 +1,6 @@ +--- +'@felte/reporter-tippy': minor +'@felte/reporter-dom': minor +--- + +Add support for displaying warnings. diff --git a/.changeset/cool-flowers-beg.md b/.changeset/cool-flowers-beg.md new file mode 100644 index 00000000..c1b822bd --- /dev/null +++ b/.changeset/cool-flowers-beg.md @@ -0,0 +1,8 @@ +--- +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: Stop proxying inputs. This was causing all sorts of race conditions which were a headache to solve. Instead we're going to keep a single recommendation: If you wish to programatically set the value of an input, use the `setFields` helper. diff --git a/.changeset/curly-plums-warn.md b/.changeset/curly-plums-warn.md new file mode 100644 index 00000000..2f0d5046 --- /dev/null +++ b/.changeset/curly-plums-warn.md @@ -0,0 +1,9 @@ +--- +'@felte/common': patch +'@felte/core': patch +'felte': patch +'@felte/react': patch +'@felte/solid': patch +--- + +Point "browser" field to esm bundle diff --git a/.changeset/curvy-peas-sit.md b/.changeset/curvy-peas-sit.md new file mode 100644 index 00000000..9da18f48 --- /dev/null +++ b/.changeset/curvy-peas-sit.md @@ -0,0 +1,5 @@ +--- +'@felte/reporter-svelte': major +--- + +BREAKING: change export name to `reporter` to be consistent with other packages diff --git a/.changeset/cyan-cups-glow.md b/.changeset/cyan-cups-glow.md new file mode 100644 index 00000000..ed22d088 --- /dev/null +++ b/.changeset/cyan-cups-glow.md @@ -0,0 +1,5 @@ +--- +'@felte/solid': patch +--- + +Fix updater subscribing to whole store when calling diff --git a/.changeset/dry-panthers-bow.md b/.changeset/dry-panthers-bow.md new file mode 100644 index 00000000..bc5590c4 --- /dev/null +++ b/.changeset/dry-panthers-bow.md @@ -0,0 +1,8 @@ +--- +'@felte/core': patch +'felte': patch +'@felte/react': patch +'@felte/solid': patch +--- + +Fix when publishing as modules diff --git a/.changeset/eighty-maps-admire.md b/.changeset/eighty-maps-admire.md new file mode 100644 index 00000000..ea4967bb --- /dev/null +++ b/.changeset/eighty-maps-admire.md @@ -0,0 +1,5 @@ +--- +'@felte/reporter-tippy': patch +--- + +Set appropriate store depending on level diff --git a/.changeset/eighty-pillows-pay.md b/.changeset/eighty-pillows-pay.md new file mode 100644 index 00000000..a9dcfe39 --- /dev/null +++ b/.changeset/eighty-pillows-pay.md @@ -0,0 +1,8 @@ +--- +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add `feltesuccess` and `felteerror` events diff --git a/.changeset/fifty-bears-type.md b/.changeset/fifty-bears-type.md new file mode 100644 index 00000000..cd2a85fc --- /dev/null +++ b/.changeset/fifty-bears-type.md @@ -0,0 +1,6 @@ +--- +'@felte/common': minor +'@felte/core': minor +--- + +Update types diff --git a/.changeset/flat-beds-collect.md b/.changeset/flat-beds-collect.md new file mode 100644 index 00000000..9eea4113 --- /dev/null +++ b/.changeset/flat-beds-collect.md @@ -0,0 +1,5 @@ +--- +'@felte/core': patch +--- + +Unset also `touched`, `warnings` and `errors` stores when fields are marked for removal diff --git a/.changeset/fluffy-ads-hammer.md b/.changeset/fluffy-ads-hammer.md new file mode 100644 index 00000000..759a479e --- /dev/null +++ b/.changeset/fluffy-ads-hammer.md @@ -0,0 +1,8 @@ +--- +'@felte/common': minor +'@felte/core': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Make string paths for accessors type safe diff --git a/.changeset/fluffy-clocks-fold.md b/.changeset/fluffy-clocks-fold.md new file mode 100644 index 00000000..8381a8f9 --- /dev/null +++ b/.changeset/fluffy-clocks-fold.md @@ -0,0 +1,5 @@ +--- +'@felte/reporter-react': patch +--- + +Use setTimeout to guarantee DOM has rendered before retrieving values diff --git a/.changeset/fluffy-fishes-mate.md b/.changeset/fluffy-fishes-mate.md new file mode 100644 index 00000000..89851489 --- /dev/null +++ b/.changeset/fluffy-fishes-mate.md @@ -0,0 +1,5 @@ +--- +'@felte/common': patch +--- + +Clone object on update function diff --git a/.changeset/fuzzy-pears-float.md b/.changeset/fuzzy-pears-float.md new file mode 100644 index 00000000..cbf360c1 --- /dev/null +++ b/.changeset/fuzzy-pears-float.md @@ -0,0 +1,9 @@ +--- +'@felte/common': major +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: When removing an input from an array of inputs, Felte now splices the array instead of setting the value to `null`/`undefined`. This means that an `index` on an array of inputs is no longer a _unique_ identifier and the value can move around if fields are added/removed. diff --git a/.changeset/gentle-pianos-rest.md b/.changeset/gentle-pianos-rest.md new file mode 100644 index 00000000..5b10282d --- /dev/null +++ b/.changeset/gentle-pianos-rest.md @@ -0,0 +1,9 @@ +--- +'@felte/common': minor +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add helper functions to context passed to `onSuccess`, `onSubmit` and `onError` diff --git a/.changeset/gold-vans-bow.md b/.changeset/gold-vans-bow.md new file mode 100644 index 00000000..1a906f84 --- /dev/null +++ b/.changeset/gold-vans-bow.md @@ -0,0 +1,9 @@ +--- +'@felte/common': major +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: `errors` and `warning` stores will either have `null` or an array of strings as errors diff --git a/.changeset/healthy-lamps-sleep.md b/.changeset/healthy-lamps-sleep.md new file mode 100644 index 00000000..d3309c1e --- /dev/null +++ b/.changeset/healthy-lamps-sleep.md @@ -0,0 +1,9 @@ +--- +'@felte/common': minor +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add `interacted` store to show which is the last field the user has interacted with diff --git a/.changeset/heavy-adults-laugh.md b/.changeset/heavy-adults-laugh.md new file mode 100644 index 00000000..d5ec6f23 --- /dev/null +++ b/.changeset/heavy-adults-laugh.md @@ -0,0 +1,5 @@ +--- +'@felte/solid': patch +--- + +Use Solid's observable function instead of our own diff --git a/.changeset/heavy-panthers-tap.md b/.changeset/heavy-panthers-tap.md new file mode 100644 index 00000000..f1e82deb --- /dev/null +++ b/.changeset/heavy-panthers-tap.md @@ -0,0 +1,9 @@ +--- +'@felte/common': patch +'@felte/core': patch +'felte': patch +'@felte/react': patch +'@felte/solid': patch +--- + +Use `preserveModules` for better tree-shaking diff --git a/.changeset/large-phones-melt.md b/.changeset/large-phones-melt.md new file mode 100644 index 00000000..6dd7ba8d --- /dev/null +++ b/.changeset/large-phones-melt.md @@ -0,0 +1,9 @@ +--- +'@felte/common': patch +'@felte/core': patch +'felte': patch +'@felte/react': patch +'@felte/solid': patch +--- + +Add type for keyed Data diff --git a/.changeset/late-shrimps-tie.md b/.changeset/late-shrimps-tie.md new file mode 100644 index 00000000..035af949 --- /dev/null +++ b/.changeset/late-shrimps-tie.md @@ -0,0 +1,5 @@ +--- +'@felte/core': patch +--- + +Calls `reset` helper when a `reset` event is dispatched by the form (e.g. when using a `button[type="reset"]` diff --git a/.changeset/lucky-beds-brake.md b/.changeset/lucky-beds-brake.md new file mode 100644 index 00000000..2bee04cc --- /dev/null +++ b/.changeset/lucky-beds-brake.md @@ -0,0 +1,8 @@ +--- +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add isValidating store diff --git a/.changeset/mean-oranges-bathe.md b/.changeset/mean-oranges-bathe.md new file mode 100644 index 00000000..68730048 --- /dev/null +++ b/.changeset/mean-oranges-bathe.md @@ -0,0 +1,8 @@ +--- +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: `setFields` no longer touches a field by default. It needs to be explicit and it's only possible when passing a string path. E.g. `setField(‘email’ , 'zaphod@beeblebrox.com')` now is `setFields('email', 'zaphod@beeblebrox.com', true)`. diff --git a/.changeset/mighty-feet-remember.md b/.changeset/mighty-feet-remember.md new file mode 100644 index 00000000..83931b9a --- /dev/null +++ b/.changeset/mighty-feet-remember.md @@ -0,0 +1,10 @@ +--- +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: Remove `data-felte-unset-on-remove` in favour of `data-felte-keep-on-remove`. Felte will now remove fields by default if removed from the DOM. + +To keep the same behaviour as before, add `data-felte-keep-on-remove` to any dynamic inputs you had that didn't have `data-felte-unset-on-remove` previously. And remove `data-felte-unset-on-remove` from the inputs that had it, or replace it for `data-felte-keep-on-remove="false"` if it was used to override a parent's attribute. diff --git a/.changeset/mighty-years-matter.md b/.changeset/mighty-years-matter.md new file mode 100644 index 00000000..4ba1d7d9 --- /dev/null +++ b/.changeset/mighty-years-matter.md @@ -0,0 +1,8 @@ +--- +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: apply transforms to initialValues diff --git a/.changeset/nasty-geckos-invent.md b/.changeset/nasty-geckos-invent.md new file mode 100644 index 00000000..6f9fe73e --- /dev/null +++ b/.changeset/nasty-geckos-invent.md @@ -0,0 +1,8 @@ +--- +'@felte/core': patch +'felte': patch +'@felte/react': patch +'@felte/solid': patch +--- + +Export events as types diff --git a/.changeset/neat-seals-tickle.md b/.changeset/neat-seals-tickle.md new file mode 100644 index 00000000..4e7459a8 --- /dev/null +++ b/.changeset/neat-seals-tickle.md @@ -0,0 +1,5 @@ +--- +'@felte/core': patch +--- + +Wait for DOM element to be mounted on createField/useField diff --git a/.changeset/nine-birds-knock.md b/.changeset/nine-birds-knock.md new file mode 100644 index 00000000..f250463d --- /dev/null +++ b/.changeset/nine-birds-knock.md @@ -0,0 +1,7 @@ +--- +'@felte/reporter-react': patch +'@felte/reporter-solid': patch +'@felte/reporter-svelte': patch +--- + +Fix types diff --git a/.changeset/ninety-trees-sneeze.md b/.changeset/ninety-trees-sneeze.md new file mode 100644 index 00000000..d0660db5 --- /dev/null +++ b/.changeset/ninety-trees-sneeze.md @@ -0,0 +1,5 @@ +--- +'@felte/common': minor +--- + +Add isEqual utility to check for strict equality diff --git a/.changeset/perfect-socks-crash.md b/.changeset/perfect-socks-crash.md new file mode 100644 index 00000000..4844c3a6 --- /dev/null +++ b/.changeset/perfect-socks-crash.md @@ -0,0 +1,6 @@ +--- +'@felte/react': patch +'@felte/reporter-react': patch +--- + +Update peer dependencies diff --git a/.changeset/pink-horses-exist.md b/.changeset/pink-horses-exist.md new file mode 100644 index 00000000..d97ad591 --- /dev/null +++ b/.changeset/pink-horses-exist.md @@ -0,0 +1,5 @@ +--- +'@felte/reporter-solid': patch +--- + +Apply patch from 0.1.15 diff --git a/.changeset/pink-walls-prove.md b/.changeset/pink-walls-prove.md new file mode 100644 index 00000000..ee1ffca6 --- /dev/null +++ b/.changeset/pink-walls-prove.md @@ -0,0 +1,21 @@ +--- +'@felte/reporter-react': major +'@felte/reporter-solid': major +'@felte/reporter-svelte': major +--- + +BREAKING: Remove `index` prop support + +This was done in order to allow and simplify further improvements of the type system. + +This means this: + +```html + +``` + +Should be changed to this: + +```html + +``` diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..6b061820 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,103 @@ +{ + "mode": "pre", + "tag": "next", + "initialVersions": { + "@felte/common": "0.6.0", + "@felte/core": "0.3.0", + "@felte/extender-persist": "0.1.17", + "felte": "0.9.0", + "@felte/react": "0.1.1", + "@felte/reporter-cvapi": "0.1.16", + "@felte/reporter-dom": "0.3.13", + "@felte/reporter-react": "0.1.0", + "@felte/reporter-solid": "0.1.14", + "@felte/reporter-svelte": "0.3.20", + "@felte/reporter-tippy": "0.3.12", + "@felte/site": "0.0.8", + "@felte/solid": "0.3.0", + "@felte/validator-superstruct": "0.3.2", + "@felte/validator-vest": "0.1.0", + "@felte/validator-yup": "0.2.13", + "@felte/validator-zod": "0.3.8", + "@example/react-basic": "0.1.0", + "@example/solid-basic": "0.0.0", + "@example/svelte-basic": "1.0.0" + }, + "changesets": [ + "angry-cooks-yell", + "beige-tomatoes-collect", + "blue-bags-lie", + "breezy-shrimps-think", + "breezy-steaks-matter", + "calm-foxes-battle", + "calm-mugs-film", + "chatty-bikes-rule", + "chatty-cobras-kick", + "chilly-pigs-sing", + "clever-moles-switch", + "cool-flowers-beg", + "curly-plums-warn", + "curvy-peas-sit", + "cyan-cups-glow", + "dry-panthers-bow", + "eighty-maps-admire", + "eighty-pillows-pay", + "fifty-bears-type", + "flat-beds-collect", + "fluffy-ads-hammer", + "fluffy-clocks-fold", + "fluffy-fishes-mate", + "fuzzy-pears-float", + "gentle-pianos-rest", + "gold-vans-bow", + "healthy-lamps-sleep", + "heavy-adults-laugh", + "heavy-panthers-tap", + "large-phones-melt", + "late-shrimps-tie", + "lucky-beds-brake", + "mean-oranges-bathe", + "mighty-feet-remember", + "mighty-years-matter", + "nasty-geckos-invent", + "neat-seals-tickle", + "nine-birds-knock", + "ninety-trees-sneeze", + "perfect-socks-crash", + "pink-horses-exist", + "pink-walls-prove", + "purple-spoons-trade", + "quick-trainers-grow", + "real-bottles-try", + "real-jobs-shop", + "rude-stingrays-call", + "rude-suns-complain", + "shaggy-ligers-count", + "shaggy-rabbits-tease", + "sharp-suns-check", + "shiny-llamas-explode", + "short-moons-explode", + "six-olives-exist", + "six-suits-fold", + "small-pots-jam", + "smooth-melons-learn", + "spicy-zoos-smoke", + "tall-flies-turn", + "tall-taxis-cross", + "tame-cycles-lie", + "tasty-swans-teach", + "thick-eels-knock", + "tiny-toys-tease", + "tough-buttons-fetch", + "tough-dogs-press", + "tough-swans-enjoy", + "tricky-phones-cough", + "twelve-suits-reply", + "unlucky-baboons-tan", + "weak-grapes-love", + "weak-rats-shake", + "wild-pumas-serve", + "wise-papayas-cheer", + "wise-planes-provide" + ] +} diff --git a/.changeset/purple-spoons-trade.md b/.changeset/purple-spoons-trade.md new file mode 100644 index 00000000..9dc36469 --- /dev/null +++ b/.changeset/purple-spoons-trade.md @@ -0,0 +1,5 @@ +--- +'@felte/solid': major +--- + +BREAKING: `data`, `errors`, `warnings` and `touched` are no longer stores but accessors diff --git a/.changeset/quick-trainers-grow.md b/.changeset/quick-trainers-grow.md new file mode 100644 index 00000000..982a1bac --- /dev/null +++ b/.changeset/quick-trainers-grow.md @@ -0,0 +1,5 @@ +--- +'@felte/react': patch +--- + +Use ref for form instead of state callback diff --git a/.changeset/real-bottles-try.md b/.changeset/real-bottles-try.md new file mode 100644 index 00000000..b64edf88 --- /dev/null +++ b/.changeset/real-bottles-try.md @@ -0,0 +1,8 @@ +--- +'@felte/validator-superstruct': major +'@felte/validator-vest': major +'@felte/validator-yup': major +'@felte/validator-zod': major +--- + +BREAKING: instead of extending Felte's config, now validators accept a configuration object directly. This allows for extending Felte with multiple schemas/suites/structs diff --git a/.changeset/real-jobs-shop.md b/.changeset/real-jobs-shop.md new file mode 100644 index 00000000..ac6a392e --- /dev/null +++ b/.changeset/real-jobs-shop.md @@ -0,0 +1,6 @@ +--- +'@felte/react': patch +'@felte/site': patch +--- + +Check for strict equality on value change diff --git a/.changeset/rude-stingrays-call.md b/.changeset/rude-stingrays-call.md new file mode 100644 index 00000000..13b48e8a --- /dev/null +++ b/.changeset/rude-stingrays-call.md @@ -0,0 +1,21 @@ +--- +'@felte/common': major +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +'@felte/validator-superstruct': major +'@felte/validator-vest': major +'@felte/validator-yup': major +'@felte/validator-zod': major +--- + +BREAKING: Remove `addWarnValidator` in favour of options to `addValidator`. + +This gives a smaller and more unified API, as well as opening to add more options in the future. + +If you have an extender using `addWarnValidator`, you must update it by calling `addValidator` instead with the following options: + +```javascript +addValidator(yourValidationFunction, { level: 'warning' }); +``` diff --git a/.changeset/rude-suns-complain.md b/.changeset/rude-suns-complain.md new file mode 100644 index 00000000..b37cd927 --- /dev/null +++ b/.changeset/rude-suns-complain.md @@ -0,0 +1,9 @@ +--- +'@felte/common': minor +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add `swapFields` and `moveField` helper functions diff --git a/.changeset/shaggy-ligers-count.md b/.changeset/shaggy-ligers-count.md new file mode 100644 index 00000000..a33d33d7 --- /dev/null +++ b/.changeset/shaggy-ligers-count.md @@ -0,0 +1,5 @@ +--- +'@felte/react': patch +--- + +Fix `stop is not a function` when using hmr diff --git a/.changeset/shaggy-rabbits-tease.md b/.changeset/shaggy-rabbits-tease.md new file mode 100644 index 00000000..1ae00f67 --- /dev/null +++ b/.changeset/shaggy-rabbits-tease.md @@ -0,0 +1,5 @@ +--- +'@felte/react': minor +--- + +Export `useAccessor` diff --git a/.changeset/sharp-suns-check.md b/.changeset/sharp-suns-check.md new file mode 100644 index 00000000..c72b6f84 --- /dev/null +++ b/.changeset/sharp-suns-check.md @@ -0,0 +1,7 @@ +--- +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add support for custom controls with `createField`/`useField` diff --git a/.changeset/shiny-llamas-explode.md b/.changeset/shiny-llamas-explode.md new file mode 100644 index 00000000..ad651c8e --- /dev/null +++ b/.changeset/shiny-llamas-explode.md @@ -0,0 +1,5 @@ +--- +'@felte/react': patch +--- + +Set initial value on first subscription to prevent re-renders diff --git a/.changeset/short-moons-explode.md b/.changeset/short-moons-explode.md new file mode 100644 index 00000000..0ee9d9ef --- /dev/null +++ b/.changeset/short-moons-explode.md @@ -0,0 +1,5 @@ +--- +'@felte/common': minor +--- + +Export `mergeErrors` util diff --git a/.changeset/six-olives-exist.md b/.changeset/six-olives-exist.md new file mode 100644 index 00000000..66399ab0 --- /dev/null +++ b/.changeset/six-olives-exist.md @@ -0,0 +1,17 @@ +--- +'@felte/common': major +'@felte/core': major +'@felte/extender-persist': major +'@felte/reporter-cvapi': major +'@felte/reporter-dom': major +'@felte/reporter-react': major +'@felte/reporter-solid': major +'@felte/reporter-svelte': major +'@felte/reporter-tippy': major +'@felte/validator-superstruct': major +'@felte/validator-vest': major +'@felte/validator-yup': major +'@felte/validator-zod': major +--- + +Pass a new property `stage` to extenders to distinguish between setup, mount and update stages diff --git a/.changeset/six-suits-fold.md b/.changeset/six-suits-fold.md new file mode 100644 index 00000000..04e8ac81 --- /dev/null +++ b/.changeset/six-suits-fold.md @@ -0,0 +1,12 @@ +--- +'@felte/common': major +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: Helpers have been completely reworked. +`setField` and `setFields` have been unified in a single `setFields` helper. +Others such as `setError` and `setWarning` have been pluralized to `setErrors` and `setWarnings` since now they can accept the whole object. +`setTouched` now requires to be passed the value to assign. E.g. `setTouched('path')` is now `setTouched('path', true)`. It no longer accepts an index as an argument since that can be assigned in the path itself using `[]`. diff --git a/.changeset/small-pots-jam.md b/.changeset/small-pots-jam.md new file mode 100644 index 00000000..7529ef2f --- /dev/null +++ b/.changeset/small-pots-jam.md @@ -0,0 +1,8 @@ +--- +'@felte/common': major +'@felte/core': major +'felte': major +'@felte/solid': major +--- + +BREAKING: Remove `getField` helper in favor of `getValue` export. E.g. `getField('email')` now is `getValue($data, 'email')` and accessors. diff --git a/.changeset/smooth-melons-learn.md b/.changeset/smooth-melons-learn.md new file mode 100644 index 00000000..787c1356 --- /dev/null +++ b/.changeset/smooth-melons-learn.md @@ -0,0 +1,8 @@ +--- +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add default submit handler diff --git a/.changeset/spicy-zoos-smoke.md b/.changeset/spicy-zoos-smoke.md new file mode 100644 index 00000000..50a06df2 --- /dev/null +++ b/.changeset/spicy-zoos-smoke.md @@ -0,0 +1,5 @@ +--- +'@felte/common': patch +--- + +Fix deepSome handling arrays diff --git a/.changeset/tall-flies-turn.md b/.changeset/tall-flies-turn.md new file mode 100644 index 00000000..7c3e6aa1 --- /dev/null +++ b/.changeset/tall-flies-turn.md @@ -0,0 +1,9 @@ +--- +'@felte/common': minor +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add `unsetField` and `resetField` helper functions diff --git a/.changeset/tall-taxis-cross.md b/.changeset/tall-taxis-cross.md new file mode 100644 index 00000000..942641f8 --- /dev/null +++ b/.changeset/tall-taxis-cross.md @@ -0,0 +1,5 @@ +--- +'@felte/core': minor +--- + +Add support for custom controls with `createField` diff --git a/.changeset/tame-cycles-lie.md b/.changeset/tame-cycles-lie.md new file mode 100644 index 00000000..e0edca22 --- /dev/null +++ b/.changeset/tame-cycles-lie.md @@ -0,0 +1,5 @@ +--- +'@felte/reporter-tippy': patch +--- + +`onSubmitError` does nothing when `level !== 'error'` diff --git a/.changeset/tasty-swans-teach.md b/.changeset/tasty-swans-teach.md new file mode 100644 index 00000000..e601e388 --- /dev/null +++ b/.changeset/tasty-swans-teach.md @@ -0,0 +1,5 @@ +--- +'@felte/core': patch +--- + +Prevent key assignment to errors and touched stores diff --git a/.changeset/thick-eels-knock.md b/.changeset/thick-eels-knock.md new file mode 100644 index 00000000..074e9cef --- /dev/null +++ b/.changeset/thick-eels-knock.md @@ -0,0 +1,5 @@ +--- +'@felte/common': patch +--- + +Preserve modules in CJS diff --git a/.changeset/tiny-toys-tease.md b/.changeset/tiny-toys-tease.md new file mode 100644 index 00000000..6d0717ee --- /dev/null +++ b/.changeset/tiny-toys-tease.md @@ -0,0 +1,6 @@ +--- +'@felte/common': patch +'@felte/core': patch +--- + +Fix error filtering diff --git a/.changeset/tough-buttons-fetch.md b/.changeset/tough-buttons-fetch.md new file mode 100644 index 00000000..ab982e79 --- /dev/null +++ b/.changeset/tough-buttons-fetch.md @@ -0,0 +1,23 @@ +--- +'@felte/common': major +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: Remove `data-felte-index` attribute support. + +This means that you should replace this: + +```html + +``` + +To this: + +```html + +``` + +This was done in order to allow for future improvements of the type system for TypeScript users, and to also follow the same behaviour the browser would do if JavaScript is disabled diff --git a/.changeset/tough-dogs-press.md b/.changeset/tough-dogs-press.md new file mode 100644 index 00000000..c70c0982 --- /dev/null +++ b/.changeset/tough-dogs-press.md @@ -0,0 +1,16 @@ +--- +'@felte/common': major +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/reporter-dom': major +'@felte/reporter-react': major +'@felte/reporter-solid': major +'@felte/reporter-svelte': major +'@felte/reporter-tippy': major +'@felte/solid': major +'@felte/validator-superstruct': major +'@felte/validator-yup': major +--- + +Make type of helpers and stores looser when using a transform function diff --git a/.changeset/tough-swans-enjoy.md b/.changeset/tough-swans-enjoy.md new file mode 100644 index 00000000..11cb5088 --- /dev/null +++ b/.changeset/tough-swans-enjoy.md @@ -0,0 +1,9 @@ +--- +'@felte/common': minor +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Add unique key to field arrays diff --git a/.changeset/tricky-phones-cough.md b/.changeset/tricky-phones-cough.md new file mode 100644 index 00000000..2ffdb02e --- /dev/null +++ b/.changeset/tricky-phones-cough.md @@ -0,0 +1,20 @@ +--- +'@felte/common': patch +'@felte/core': patch +'@felte/extender-persist': patch +'felte': patch +'@felte/react': patch +'@felte/reporter-cvapi': patch +'@felte/reporter-dom': patch +'@felte/reporter-react': patch +'@felte/reporter-solid': patch +'@felte/reporter-svelte': patch +'@felte/reporter-tippy': patch +'@felte/solid': patch +'@felte/validator-superstruct': patch +'@felte/validator-vest': patch +'@felte/validator-yup': patch +'@felte/validator-zod': patch +--- + +Change cjs output to have an extension of `.cjs` diff --git a/.changeset/twelve-suits-reply.md b/.changeset/twelve-suits-reply.md new file mode 100644 index 00000000..9299451b --- /dev/null +++ b/.changeset/twelve-suits-reply.md @@ -0,0 +1,8 @@ +--- +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: Setting directly to `data` using `data.set` no longer touches the field. The `setFields` helper should be used instead if this behaviour is desired. diff --git a/.changeset/unlucky-baboons-tan.md b/.changeset/unlucky-baboons-tan.md new file mode 100644 index 00000000..42d3f5ec --- /dev/null +++ b/.changeset/unlucky-baboons-tan.md @@ -0,0 +1,9 @@ +--- +'@felte/common': minor +'@felte/core': minor +'felte': minor +'@felte/react': minor +'@felte/solid': minor +--- + +Pass context data to `onError` and `onSuccess` diff --git a/.changeset/weak-grapes-love.md b/.changeset/weak-grapes-love.md new file mode 100644 index 00000000..48e44a1d --- /dev/null +++ b/.changeset/weak-grapes-love.md @@ -0,0 +1,6 @@ +--- +'@felte/common': patch +'@felte/core': patch +--- + +Allow for `onError` and `onSuccess` to be asynchronous diff --git a/.changeset/weak-rats-shake.md b/.changeset/weak-rats-shake.md new file mode 100644 index 00000000..22b40263 --- /dev/null +++ b/.changeset/weak-rats-shake.md @@ -0,0 +1,5 @@ +--- +'@felte/core': minor +--- + +Add debounced validators diff --git a/.changeset/wild-pumas-serve.md b/.changeset/wild-pumas-serve.md new file mode 100644 index 00000000..f76463ac --- /dev/null +++ b/.changeset/wild-pumas-serve.md @@ -0,0 +1,5 @@ +--- +'@felte/common': patch +--- + +Refactor \_update and \_set methods diff --git a/.changeset/wise-papayas-cheer.md b/.changeset/wise-papayas-cheer.md new file mode 100644 index 00000000..aa3747b3 --- /dev/null +++ b/.changeset/wise-papayas-cheer.md @@ -0,0 +1,5 @@ +--- +'@felte/reporter-svelte': patch +--- + +Show message/slot as soon as render happens diff --git a/.changeset/wise-planes-provide.md b/.changeset/wise-planes-provide.md new file mode 100644 index 00000000..901a6c70 --- /dev/null +++ b/.changeset/wise-planes-provide.md @@ -0,0 +1,26 @@ +--- +'@felte/common': major +'@felte/core': major +'felte': major +'@felte/react': major +'@felte/solid': major +--- + +BREAKING: Stop grabbing nested names from fieldset + +This means that this won't work anymore: + +```html +
+ +
+``` + +So it needs to be changed to this: + +```html +
+ +
+``` +This was done to allow for future improvements on type-safety, as well to keep consistency with the browser's behaviour when JavaScript is disabled. diff --git a/.eslintrc.json b/.eslintrc.json index 6ebf5ad2..0c6ade4e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,23 +7,17 @@ "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", - "plugin:testing-library/recommended", - "plugin:jest-dom/recommended", - "prettier" - ], - "plugins": [ - "@typescript-eslint", - "jest", - "testing-library", - "jest-dom", + "plugin:compat/recommended", "prettier" ], + "plugins": ["@typescript-eslint", "testing-library", "prettier"], "parser": "@typescript-eslint/parser", "rules": { - "@typescript-eslint/no-explicit-any": "off" - }, - "env": { - "jest/globals": true, - "node": true + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { "ignoreRestSiblings": true } + ] } } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca2773ee..70729571 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,12 +25,17 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - - run: npm i -g pnpm - - run: pnpm i - - run: pnpm build + - name: Install pnpm + run: npm i -g pnpm + - name: Install dependencies + run: pnpm i + - name: Build packages + run: pnpm build + - name: Lint code + run: pnpm lint - name: Run tests and generate coverage run: pnpm test:ci - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v2 with: directory: ./packages diff --git a/.husky/commit-msg b/.husky/commit-msg index d71a03b9..13f1f643 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -yarn commitlint --edit $1 +pnpx commitlint --edit $1 diff --git a/.nycrc.json b/.nycrc.json new file mode 100644 index 00000000..126607b8 --- /dev/null +++ b/.nycrc.json @@ -0,0 +1,4 @@ +{ + "extends": "@istanbuljs/nyc-config-typescript", + "reporter": ["json", "lcov", "clover", "text"] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 479584f3..a1cca667 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,5 +137,6 @@ Running this command will generate a markdown file in the `.changeset/` director There are a few minor things I haven't gotten around to solve yet. If you have an idea on how to solve them, you may give it a try: -- `@felte/common` is not tree-shakeable yet. This makes some extender packages slightly bigger than I'd like them to be but I haven't quite figured out how to do this yet. -- The current API for extending Felte is not so great. While it's "easy" to use, it's not "simple". It's quite easy to break Felte accidentally. I still haven't figured out how a simple and safe API would look like for this. +- A way to offer input masking would be nice to have. Integration with `imask` probably would be the way to go, since `svelte-imask` does not play well with Felte. +- A better alternative to the `multi-step` package is needed. Preferably something that does not depend on any specific framework. Currently we are recommending handling this manually. +- Some help from someone more experienced with TypeScript could help improve how we internally handle types. diff --git a/README.md b/README.md index 262157ab..f7f7bdbc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Felte](./packages/site/static/felte-logo-thin.png) -# Felte: A form library for Svelte and Solid +# Felte: A form library for Svelte, Solid and React [![Tests](https://github.com/pablo-abc/felte/workflows/Tests/badge.svg)](https://github.com/pablo-abc/felte/actions/workflows/test.yml) [![Bundle size](https://img.shields.io/bundlephobia/min/felte)](https://bundlephobia.com/result?p=felte) @@ -12,12 +12,20 @@ - [Features](#features) - [Simple usage example](#simple-usage-example) + - [Svelte](#svelte) + - [Solid](#solid) + - [React](#react) - [Why](#why) - [Packages](#packages) - - [Core](#core) + - [Svelte](#svelte-1) - [`felte`](./packages/felte/README.md) + - [`@felte/reporter-svelte`](./packages/reporter-svelte/README.md) + - [Solid](#solid-1) - [`@felte/solid`](./packages/solid/README.md) - - [`@felte/common`](./packages/common/README.md) + - [`@felte/reporter-solid`](./packages/reporter-solid/README.md) + - [React](#react-1) + - [`@felte/react`](./packages/react/README.md) + - [`@felte/reporter-react`](./packages/reporter-react/README.md) - [Validators](#validators) - [`@felte/validator-yup`](./packages/validator-yup/README.md) - [`@felte/validator-zod`](./packages/validator-zod/README.md) @@ -27,14 +35,10 @@ - [`@felte/reporter-tippy`](./packages/reporter-tippy/README.md) - [`@felte/reporter-cvapi`](./packages/reporter-cvapi/README.md) - [`@felte/reporter-dom`](./packages/reporter-dom/README.md) - - [`@felte/reporter-svelte`](./packages/reporter-svelte/README.md) - - [`@felte/reporter-solid`](./packages/reporter-solid/README.md) - [Contributing](#contributing) - [Contributors](#contributors-) -Felte is a simple to use form library for Svelte and Solid. No `Field` or `Form` components are needed, just plain stores and actions to build your form however you like. You can see it in action in this [CodeSandbox demo](https://codesandbox.io/s/felte-demo-wce2h?file=/App.svelte)! - -**STATUS:** Getting ready for the release of v1.0.0! You can check the new docs (and their migration guide from v0.x) [here](https://next.felte.dev) if you want to prepare for this. All packages are already available with the `next` tag on npm as well. +Felte is a simple to use form library for Svelte, Solid and React. No `Field` or `Form` components are needed, just plain stores and actions to build your form however you like. You can see it in action in this [CodeSandbox demo](https://codesandbox.io/s/felte-demo-wce2h?file=/App.svelte)! ## Features @@ -46,10 +50,12 @@ Felte is a simple to use form library for Svelte and Solid. No `Field` or `Form` - Official solutions for error reporting using `reporter` packages. - Well tested. Currently at [99% code coverage](https://app.codecov.io/gh/pablo-abc/felte) and constantly working on improving test quality. - Supports validation with [yup](./packages/validator-yup/README.md), [zod](./packages/validator-zod/README.md), [superstruct](./packages/validator-superstruct/README.md) and [vest](./packages/validator-vest/README.md). -- Easily [extend its functionality](https://felte.dev/docs#extending-felte). +- Easily [extend its functionality](https://felte.dev/docs/svelte/extending-felte). ## Simple usage example +### Svelte + ```html + + diff --git a/examples/react/basic/package.json b/examples/react/basic/package.json new file mode 100644 index 00000000..e202a3e2 --- /dev/null +++ b/examples/react/basic/package.json @@ -0,0 +1,23 @@ +{ + "name": "@example/react-basic", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@felte/react": "next", + "@felte/reporter-react": "next", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "devDependencies": { + "@types/react": "^17.0.33", + "@types/react-dom": "^17.0.10", + "@vitejs/plugin-react": "^1.0.7", + "typescript": "^4.4.4", + "vite": "^2.7.2" + } +} diff --git a/examples/react/basic/src/App.tsx b/examples/react/basic/src/App.tsx new file mode 100644 index 00000000..192a4c7e --- /dev/null +++ b/examples/react/basic/src/App.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { useForm } from '@felte/react'; +import { ValidationMessage, reporter } from '@felte/reporter-react'; + +type Data = { + email: string; + password: string; +}; + +function App() { + const [submitted, setSubmitted] = useState(); + const { form } = useForm({ + onSubmit(values) { + setSubmitted(values); + }, + validate(values) { + const errors: { email: string[]; password: string[] } = { + email: [], + password: [], + }; + if (!values.email) errors.email.push('Must not be empty'); + if (!/[a-zA-Z][^@]*@[a-zA-Z][^@.]*\.[a-z]{2,}/.test(values.email)) + errors.email.push('Must be a valid email'); + if (!values.password) errors.password.push('Must not be empty'); + return errors; + }, + extend: [reporter], + }); + return ( +
+

Basic Example - React

+
+
+ Sign In + + + + {(messages) => ( +
    + {messages?.map((message) => ( +
  • * {message}
  • + ))} +
+ )} +
+ + + + {(messages) => ( +
    + {messages?.map((message) => ( +
  • * {message}
  • + ))} +
+ )} +
+
+ + +
+ {submitted && ( +
+

Submitted values

+
{JSON.stringify(submitted, null, 2)}
+
+ )} +
+ ); +} + +export default App; diff --git a/examples/react/basic/src/favicon.svg b/examples/react/basic/src/favicon.svg new file mode 100644 index 00000000..de4aeddc --- /dev/null +++ b/examples/react/basic/src/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/examples/react/basic/src/index.css b/examples/react/basic/src/index.css new file mode 100644 index 00000000..840429e9 --- /dev/null +++ b/examples/react/basic/src/index.css @@ -0,0 +1,144 @@ +:root { + --primary-color: rgb(255, 62, 0); + --on-primary-color: #fffff9; + --primary-color-hover: rgb(255, 113, 51); + --error-color: #ff3a43; + --primary-background: #111111; + --primary-font-color: #fffff0; + --primary-font-color-hover: #bfbfb0; + --header-background: #222222; + --header-background-hover: #323232; + --example-background: var(--header-background); + --link-color: #00a8f4; + --link-color-hover: #3cc2ff; +} +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Nunito Sans", -apple-system, BlinkMacSystemFont, Segoe UI, + Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--primary-font-color); + background: var(--primary-background); + font-size: 1.1rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: "Cabin", sans-serif; + margin: 1rem; + font-weight: 700; + line-height: 1.2; +} + +p { + margin: 1.5rem 0; +} + +h1 { + font-size: 2.1rem; +} + +h2 { + font-size: 1.8rem; +} + +section { + margin: 1rem; + margin-top: 2rem; +} + +a { + color: var(--primary-font-color); + text-decoration: none; + transition: color 100ms; +} + +a:hover { + color: var(--primary-font-color-hover); +} + +code { + font-family: inconsolata, monospace; + font-size: calc(1em - 2px); + color: var(--primary-font-color); + background-color: var(--header-background); + padding: 0.2em 0.4em; + border-radius: 2px; +} + +input[type="text"], +input[type="email"], +input[type="password"], +[contenteditable="true"], +textarea { + font-size: 1em; + border: 1px solid #aaa; + border-radius: 10px; + padding: 0.3rem 1rem; + background: var(--on-primary-color); + height: 3rem; + width: 18rem; + color: black; +} + +input[aria-invalid="true"], +[contenteditable][aria-invalid="true"] { + border: 2px solid var(--error-color); +} + +fieldset { + width: 400px; + border: 2px solid var(--primary-color); + display: block; + font-size: 1.2em; + background: var(--example-background); + padding: 2rem; + border-radius: 10px 30px; +} + +button { + margin-top: 0.7em; + font-size: 0.8em; + font-weight: 700; + padding: 0.7em; + background: var(--primary-color); + border-radius: 10px; + border: none; + color: var(--on-primary-color); + transition: transform 0.1s; +} + +button:not([aria-disabled="true"]):hover { + cursor: pointer; + background: var(--primary-color-hover); +} + +button:not([aria-disabled="true"]):active { + transform: scale(0.9); +} + +button[aria-disabled="true"] { + background: var(--primary-color-hover); + cursor: not-allowed; +} + +form ul { + color: var(--error-color); +} + +button { + margin: 0.5rem; +} + +label { + display: block; +} diff --git a/examples/react/basic/src/main.tsx b/examples/react/basic/src/main.tsx new file mode 100644 index 00000000..a912b515 --- /dev/null +++ b/examples/react/basic/src/main.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import './reset.css'; +import './index.css'; +import App from './App'; + +ReactDOM.render( + + + , + document.getElementById('root') +); diff --git a/examples/react/basic/src/reset.css b/examples/react/basic/src/reset.css new file mode 100644 index 00000000..aa4cf961 --- /dev/null +++ b/examples/react/basic/src/reset.css @@ -0,0 +1,52 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; + font-size: 1.2rem; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +* { + box-sizing: border-box; +} diff --git a/examples/react/basic/src/vite-env.d.ts b/examples/react/basic/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/react/basic/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/react/basic/tsconfig.json b/examples/react/basic/tsconfig.json new file mode 100644 index 00000000..9f836599 --- /dev/null +++ b/examples/react/basic/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["./src"] +} diff --git a/examples/react/basic/vite.config.ts b/examples/react/basic/vite.config.ts new file mode 100644 index 00000000..b1b5f91e --- /dev/null +++ b/examples/react/basic/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()] +}) diff --git a/examples/solid/basic/.gitignore b/examples/solid/basic/.gitignore new file mode 100644 index 00000000..76add878 --- /dev/null +++ b/examples/solid/basic/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/examples/solid/basic/README.md b/examples/solid/basic/README.md new file mode 100644 index 00000000..434f7bb9 --- /dev/null +++ b/examples/solid/basic/README.md @@ -0,0 +1,34 @@ +## Usage + +Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. + +This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template. + +```bash +$ npm install # or pnpm install or yarn install +``` + +### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) + +## Available Scripts + +In the project directory, you can run: + +### `npm dev` or `npm start` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+ +### `npm run build` + +Builds the app for production to the `dist` folder.
+It correctly bundles Solid in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +## Deployment + +You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/examples/solid/basic/index.html b/examples/solid/basic/index.html new file mode 100644 index 00000000..48c59fc1 --- /dev/null +++ b/examples/solid/basic/index.html @@ -0,0 +1,16 @@ + + + + + + + + Solid App + + + +
+ + + + diff --git a/examples/solid/basic/package.json b/examples/solid/basic/package.json new file mode 100644 index 00000000..45007297 --- /dev/null +++ b/examples/solid/basic/package.json @@ -0,0 +1,23 @@ +{ + "name": "@example/solid-basic", + "private": true, + "version": "0.0.0", + "description": "", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview" + }, + "license": "MIT", + "devDependencies": { + "typescript": "^4.5.5", + "vite": "^2.7.13", + "vite-plugin-solid": "^2.2.5" + }, + "dependencies": { + "@felte/reporter-solid": "next", + "@felte/solid": "next", + "solid-js": "^1.3.3" + } +} diff --git a/examples/solid/basic/pnpm-lock.yaml b/examples/solid/basic/pnpm-lock.yaml new file mode 100644 index 00000000..49282833 --- /dev/null +++ b/examples/solid/basic/pnpm-lock.yaml @@ -0,0 +1,805 @@ +lockfileVersion: 5.3 + +specifiers: + solid-js: ^1.3.3 + typescript: ^4.5.5 + vite: ^2.7.13 + vite-plugin-solid: ^2.2.5 + +dependencies: + solid-js: 1.3.3 + +devDependencies: + typescript: 4.5.5 + vite: 2.7.13 + vite-plugin-solid: 2.2.5 + +packages: + + /@babel/code-frame/7.16.7: + resolution: {integrity: sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.16.10 + dev: true + + /@babel/compat-data/7.16.8: + resolution: {integrity: sha512-m7OkX0IdKLKPpBlJtF561YJal5y/jyI5fNfWbPxh2D/nbzzGI4qRyrD8xO2jB24u7l+5I2a43scCG2IrfjC50Q==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core/7.16.12: + resolution: {integrity: sha512-dK5PtG1uiN2ikk++5OzSYsitZKny4wOCD0nrO4TqnW4BVBTQ2NGS3NgilvT/TEyxTST7LNyWV/T4tXDoD3fOgg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.16.7 + '@babel/generator': 7.16.8 + '@babel/helper-compilation-targets': 7.16.7_@babel+core@7.16.12 + '@babel/helper-module-transforms': 7.16.7 + '@babel/helpers': 7.16.7 + '@babel/parser': 7.16.12 + '@babel/template': 7.16.7 + '@babel/traverse': 7.16.10 + '@babel/types': 7.16.8 + convert-source-map: 1.8.0 + debug: 4.3.3 + gensync: 1.0.0-beta.2 + json5: 2.2.0 + semver: 6.3.0 + source-map: 0.5.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/generator/7.16.8: + resolution: {integrity: sha512-1ojZwE9+lOXzcWdWmO6TbUzDfqLD39CmEhN8+2cX9XkDo5yW1OpgfejfliysR2AWLpMamTiOiAp/mtroaymhpw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + jsesc: 2.5.2 + source-map: 0.5.7 + dev: true + + /@babel/helper-annotate-as-pure/7.16.7: + resolution: {integrity: sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-compilation-targets/7.16.7_@babel+core@7.16.12: + resolution: {integrity: sha512-mGojBwIWcwGD6rfqgRXVlVYmPAv7eOpIemUG3dGnDdCY4Pae70ROij3XmfrH6Fa1h1aiDylpglbZyktfzyo/hA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/compat-data': 7.16.8 + '@babel/core': 7.16.12 + '@babel/helper-validator-option': 7.16.7 + browserslist: 4.19.1 + semver: 6.3.0 + dev: true + + /@babel/helper-create-class-features-plugin/7.16.10_@babel+core@7.16.12: + resolution: {integrity: sha512-wDeej0pu3WN/ffTxMNCPW5UCiOav8IcLRxSIyp/9+IF2xJUM9h/OYjg0IJLHaL6F8oU8kqMz9nc1vryXhMsgXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.16.12 + '@babel/helper-annotate-as-pure': 7.16.7 + '@babel/helper-environment-visitor': 7.16.7 + '@babel/helper-function-name': 7.16.7 + '@babel/helper-member-expression-to-functions': 7.16.7 + '@babel/helper-optimise-call-expression': 7.16.7 + '@babel/helper-replace-supers': 7.16.7 + '@babel/helper-split-export-declaration': 7.16.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-environment-visitor/7.16.7: + resolution: {integrity: sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-function-name/7.16.7: + resolution: {integrity: sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-get-function-arity': 7.16.7 + '@babel/template': 7.16.7 + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-get-function-arity/7.16.7: + resolution: {integrity: sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-hoist-variables/7.16.7: + resolution: {integrity: sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-member-expression-to-functions/7.16.7: + resolution: {integrity: sha512-VtJ/65tYiU/6AbMTDwyoXGPKHgTsfRarivm+YbB5uAzKUyuPjgZSgAFeG87FCigc7KNHu2Pegh1XIT3lXjvz3Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-module-imports/7.16.0: + resolution: {integrity: sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-module-imports/7.16.7: + resolution: {integrity: sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-module-transforms/7.16.7: + resolution: {integrity: sha512-gaqtLDxJEFCeQbYp9aLAefjhkKdjKcdh6DB7jniIGU3Pz52WAmP268zK0VgPz9hUNkMSYeH976K2/Y6yPadpng==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.16.7 + '@babel/helper-module-imports': 7.16.7 + '@babel/helper-simple-access': 7.16.7 + '@babel/helper-split-export-declaration': 7.16.7 + '@babel/helper-validator-identifier': 7.16.7 + '@babel/template': 7.16.7 + '@babel/traverse': 7.16.10 + '@babel/types': 7.16.8 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-optimise-call-expression/7.16.7: + resolution: {integrity: sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-plugin-utils/7.16.7: + resolution: {integrity: sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-replace-supers/7.16.7: + resolution: {integrity: sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.16.7 + '@babel/helper-member-expression-to-functions': 7.16.7 + '@babel/helper-optimise-call-expression': 7.16.7 + '@babel/traverse': 7.16.10 + '@babel/types': 7.16.8 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-simple-access/7.16.7: + resolution: {integrity: sha512-ZIzHVyoeLMvXMN/vok/a4LWRy8G2v205mNP0XOuf9XRLyX5/u9CnVulUtDgUTama3lT+bf/UqucuZjqiGuTS1g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-split-export-declaration/7.16.7: + resolution: {integrity: sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.16.8 + dev: true + + /@babel/helper-validator-identifier/7.16.7: + resolution: {integrity: sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-option/7.16.7: + resolution: {integrity: sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helpers/7.16.7: + resolution: {integrity: sha512-9ZDoqtfY7AuEOt3cxchfii6C7GDyyMBffktR5B2jvWv8u2+efwvpnVKXMWzNehqy68tKgAfSwfdw/lWpthS2bw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.16.7 + '@babel/traverse': 7.16.10 + '@babel/types': 7.16.8 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/highlight/7.16.10: + resolution: {integrity: sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.16.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + dev: true + + /@babel/parser/7.16.12: + resolution: {integrity: sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A==} + engines: {node: '>=6.0.0'} + hasBin: true + dev: true + + /@babel/plugin-syntax-jsx/7.16.7_@babel+core@7.16.12: + resolution: {integrity: sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.16.12 + '@babel/helper-plugin-utils': 7.16.7 + dev: true + + /@babel/plugin-syntax-typescript/7.16.7_@babel+core@7.16.12: + resolution: {integrity: sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.16.12 + '@babel/helper-plugin-utils': 7.16.7 + dev: true + + /@babel/plugin-transform-typescript/7.16.8_@babel+core@7.16.12: + resolution: {integrity: sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.16.12 + '@babel/helper-create-class-features-plugin': 7.16.10_@babel+core@7.16.12 + '@babel/helper-plugin-utils': 7.16.7 + '@babel/plugin-syntax-typescript': 7.16.7_@babel+core@7.16.12 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/preset-typescript/7.16.7_@babel+core@7.16.12: + resolution: {integrity: sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.16.12 + '@babel/helper-plugin-utils': 7.16.7 + '@babel/helper-validator-option': 7.16.7 + '@babel/plugin-transform-typescript': 7.16.8_@babel+core@7.16.12 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/template/7.16.7: + resolution: {integrity: sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.16.7 + '@babel/parser': 7.16.12 + '@babel/types': 7.16.8 + dev: true + + /@babel/traverse/7.16.10: + resolution: {integrity: sha512-yzuaYXoRJBGMlBhsMJoUW7G1UmSb/eXr/JHYM/MsOJgavJibLwASijW7oXBdw3NQ6T0bW7Ty5P/VarOs9cHmqw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.16.7 + '@babel/generator': 7.16.8 + '@babel/helper-environment-visitor': 7.16.7 + '@babel/helper-function-name': 7.16.7 + '@babel/helper-hoist-variables': 7.16.7 + '@babel/helper-split-export-declaration': 7.16.7 + '@babel/parser': 7.16.12 + '@babel/types': 7.16.8 + debug: 4.3.3 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/types/7.16.8: + resolution: {integrity: sha512-smN2DQc5s4M7fntyjGtyIPbRJv6wW4rU/94fmYJ7PKQuZkC0qGMHXJbg6sNGt12JmVr4k5YaptI/XtiLJBnmIg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.16.7 + to-fast-properties: 2.0.0 + dev: true + + /ansi-styles/3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + dev: true + + /babel-plugin-jsx-dom-expressions/0.31.9_@babel+core@7.16.12: + resolution: {integrity: sha512-PY86Hesr0D3HxzKSsg0jk/oxRsFyw9jW9ejR+CSsBHpOCTilrOUDOXct3LevnG+dU5cKfm6D1V7fs2F3TyBf8A==} + dependencies: + '@babel/helper-module-imports': 7.16.0 + '@babel/plugin-syntax-jsx': 7.16.7_@babel+core@7.16.12 + '@babel/types': 7.16.8 + html-entities: 2.3.2 + transitivePeerDependencies: + - '@babel/core' + dev: true + + /babel-preset-solid/1.3.2_@babel+core@7.16.12: + resolution: {integrity: sha512-NiWGdkWZ6a/3Sfc6JdkzeNi8HkbVLNhHuKPCBvCkZPMABP6VRBoqq/aYydZlESk6EC1PYRdZAYSItb9Is6tnJQ==} + dependencies: + babel-plugin-jsx-dom-expressions: 0.31.9_@babel+core@7.16.12 + transitivePeerDependencies: + - '@babel/core' + dev: true + + /browserslist/4.19.1: + resolution: {integrity: sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001302 + electron-to-chromium: 1.4.53 + escalade: 3.1.1 + node-releases: 2.0.1 + picocolors: 1.0.0 + dev: true + + /caniuse-lite/1.0.30001302: + resolution: {integrity: sha512-YYTMO+tfwvgUN+1ZnRViE53Ma1S/oETg+J2lISsqi/ZTNThj3ZYBOKP2rHwJc37oCsPqAzJ3w2puZHn0xlLPPw==} + dev: true + + /chalk/2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + dev: true + + /color-convert/1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + dev: true + + /color-name/1.1.3: + resolution: {integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=} + dev: true + + /convert-source-map/1.8.0: + resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==} + dependencies: + safe-buffer: 5.1.2 + dev: true + + /debug/4.3.3: + resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /electron-to-chromium/1.4.53: + resolution: {integrity: sha512-rFveSKQczlcav+H3zkKqykU6ANseFwXwkl855jOIap5/0gnEcuIhv2ecz6aoTrXavF6I/CEBeRnBnkB51k06ew==} + dev: true + + /esbuild-android-arm64/0.13.15: + resolution: {integrity: sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-64/0.13.15: + resolution: {integrity: sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-arm64/0.13.15: + resolution: {integrity: sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-64/0.13.15: + resolution: {integrity: sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-arm64/0.13.15: + resolution: {integrity: sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-32/0.13.15: + resolution: {integrity: sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-64/0.13.15: + resolution: {integrity: sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm/0.13.15: + resolution: {integrity: sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm64/0.13.15: + resolution: {integrity: sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-mips64le/0.13.15: + resolution: {integrity: sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-ppc64le/0.13.15: + resolution: {integrity: sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-netbsd-64/0.13.15: + resolution: {integrity: sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-openbsd-64/0.13.15: + resolution: {integrity: sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-sunos-64/0.13.15: + resolution: {integrity: sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-32/0.13.15: + resolution: {integrity: sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-64/0.13.15: + resolution: {integrity: sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-arm64/0.13.15: + resolution: {integrity: sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild/0.13.15: + resolution: {integrity: sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw==} + hasBin: true + requiresBuild: true + optionalDependencies: + esbuild-android-arm64: 0.13.15 + esbuild-darwin-64: 0.13.15 + esbuild-darwin-arm64: 0.13.15 + esbuild-freebsd-64: 0.13.15 + esbuild-freebsd-arm64: 0.13.15 + esbuild-linux-32: 0.13.15 + esbuild-linux-64: 0.13.15 + esbuild-linux-arm: 0.13.15 + esbuild-linux-arm64: 0.13.15 + esbuild-linux-mips64le: 0.13.15 + esbuild-linux-ppc64le: 0.13.15 + esbuild-netbsd-64: 0.13.15 + esbuild-openbsd-64: 0.13.15 + esbuild-sunos-64: 0.13.15 + esbuild-windows-32: 0.13.15 + esbuild-windows-64: 0.13.15 + esbuild-windows-arm64: 0.13.15 + dev: true + + /escalade/3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + dev: true + + /escape-string-regexp/1.0.5: + resolution: {integrity: sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=} + engines: {node: '>=0.8.0'} + dev: true + + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind/1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /gensync/1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + + /globals/11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + dev: true + + /has-flag/3.0.0: + resolution: {integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0=} + engines: {node: '>=4'} + dev: true + + /has/1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /html-entities/2.3.2: + resolution: {integrity: sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==} + dev: true + + /is-core-module/2.8.1: + resolution: {integrity: sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==} + dependencies: + has: 1.0.3 + dev: true + + /is-what/4.1.7: + resolution: {integrity: sha512-DBVOQNiPKnGMxRMLIYSwERAS5MVY1B7xYiGnpgctsOFvVDz9f9PFXXxMcTOHuoqYp4NK9qFYQaIC1NRRxLMpBQ==} + engines: {node: '>=12.13'} + dev: true + + /js-tokens/4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: true + + /jsesc/2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /json5/2.2.0: + resolution: {integrity: sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==} + engines: {node: '>=6'} + hasBin: true + dependencies: + minimist: 1.2.5 + dev: true + + /merge-anything/5.0.2: + resolution: {integrity: sha512-POPQBWkBC0vxdgzRJ2Mkj4+2NTKbvkHo93ih+jGDhNMLzIw+rYKjO7949hOQM2X7DxMHH1uoUkwWFLIzImw7gA==} + engines: {node: '>=12.13'} + dependencies: + is-what: 4.1.7 + ts-toolbelt: 9.6.0 + dev: true + + /minimist/1.2.5: + resolution: {integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==} + dev: true + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /nanoid/3.2.0: + resolution: {integrity: sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /node-releases/2.0.1: + resolution: {integrity: sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==} + dev: true + + /path-parse/1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /picocolors/1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /postcss/8.4.5: + resolution: {integrity: sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.2.0 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /resolve/1.22.0: + resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} + hasBin: true + dependencies: + is-core-module: 2.8.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /rollup/2.66.0: + resolution: {integrity: sha512-L6mKOkdyP8HK5kKJXaiWG7KZDumPJjuo1P+cfyHOJPNNTK3Moe7zCH5+fy7v8pVmHXtlxorzaBjvkBMB23s98g==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /safe-buffer/5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + dev: true + + /semver/6.3.0: + resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} + hasBin: true + dev: true + + /solid-js/1.3.3: + resolution: {integrity: sha512-0pyHpLZIgQDI1Z+MgxXQRPY10dhXfKJdptb4UCJQ9ArQOLq2gtFA1acEsvSAtPMVdqQ8bqj68FOTXLpz6hm2Mg==} + + /solid-refresh/0.4.0_solid-js@1.3.3: + resolution: {integrity: sha512-5XCUz845n/sHPzKK2i2G2EeV61tAmzv6SqzqhXcPaYhrgzVy7nKTQaBpKK8InKrriq9Z2JFF/mguIU00t/73xw==} + peerDependencies: + solid-js: ^1.3.0 + dependencies: + '@babel/generator': 7.16.8 + '@babel/helper-module-imports': 7.16.7 + '@babel/types': 7.16.8 + solid-js: 1.3.3 + dev: true + + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /source-map/0.5.7: + resolution: {integrity: sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=} + engines: {node: '>=0.10.0'} + dev: true + + /supports-color/5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + dev: true + + /supports-preserve-symlinks-flag/1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /to-fast-properties/2.0.0: + resolution: {integrity: sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=} + engines: {node: '>=4'} + dev: true + + /ts-toolbelt/9.6.0: + resolution: {integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==} + dev: true + + /typescript/4.5.5: + resolution: {integrity: sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /vite-plugin-solid/2.2.5: + resolution: {integrity: sha512-SJkXdVnrPnhAWzs8Vi/+9oViUfx6TiQo8y1FFlDiyUdZR4nxTyGmRzz4xx+CC75GJL3hgDWac/zYA6sYq8SQAg==} + dependencies: + '@babel/core': 7.16.12 + '@babel/preset-typescript': 7.16.7_@babel+core@7.16.12 + babel-preset-solid: 1.3.2_@babel+core@7.16.12 + merge-anything: 5.0.2 + solid-js: 1.3.3 + solid-refresh: 0.4.0_solid-js@1.3.3 + vite: 2.7.13 + transitivePeerDependencies: + - less + - sass + - stylus + - supports-color + dev: true + + /vite/2.7.13: + resolution: {integrity: sha512-Mq8et7f3aK0SgSxjDNfOAimZGW9XryfHRa/uV0jseQSilg+KhYDSoNb9h1rknOy6SuMkvNDLKCYAYYUMCE+IgQ==} + engines: {node: '>=12.2.0'} + hasBin: true + peerDependencies: + less: '*' + sass: '*' + stylus: '*' + peerDependenciesMeta: + less: + optional: true + sass: + optional: true + stylus: + optional: true + dependencies: + esbuild: 0.13.15 + postcss: 8.4.5 + resolve: 1.22.0 + rollup: 2.66.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true diff --git a/examples/solid/basic/src/App.tsx b/examples/solid/basic/src/App.tsx new file mode 100644 index 00000000..6e782396 --- /dev/null +++ b/examples/solid/basic/src/App.tsx @@ -0,0 +1,74 @@ +import type { Component } from 'solid-js'; +import { createSignal } from 'solid-js'; +import { Index, Show } from 'solid-js/web'; +import { createForm } from '@felte/solid'; +import { ValidationMessage, reporter } from '@felte/reporter-solid'; + +type Data = { + email: string; + password: string; +}; + +const App: Component = () => { + const [submitted, setSubmitted] = createSignal(); + + const { form } = createForm({ + onSubmit(values) { + setSubmitted(values); + }, + validate(values) { + const errors: { email: string[]; password: string[] } = { + email: [], + password: [], + }; + if (!values.email) errors.email.push('Must not be empty'); + if (!/[a-zA-Z][^@]*@[a-zA-Z][^@.]*\.[a-z]{2,}/.test(values.email)) + errors.email.push('Must be a valid email'); + if (!values.password) errors.password.push('Must not be empty'); + return errors; + }, + extend: [reporter], + }); + return ( +
+

Basic Example - Solid

+
+
+ Sign In + + + + {(messages) => ( +
    + + {(message) =>
  • * {message}
  • } +
    +
+ )} +
+ + + + {(messages) => ( +
    + + {(message) =>
  • * {message}
  • } +
    +
+ )} +
+
+ + +
+ +
+

Submitted values

+
{JSON.stringify(submitted(), null, 2)}
+
+
+
+ ); +}; + +export default App; diff --git a/examples/solid/basic/src/assets/favicon.ico b/examples/solid/basic/src/assets/favicon.ico new file mode 100644 index 00000000..b836b2bc Binary files /dev/null and b/examples/solid/basic/src/assets/favicon.ico differ diff --git a/examples/solid/basic/src/index.css b/examples/solid/basic/src/index.css new file mode 100644 index 00000000..840429e9 --- /dev/null +++ b/examples/solid/basic/src/index.css @@ -0,0 +1,144 @@ +:root { + --primary-color: rgb(255, 62, 0); + --on-primary-color: #fffff9; + --primary-color-hover: rgb(255, 113, 51); + --error-color: #ff3a43; + --primary-background: #111111; + --primary-font-color: #fffff0; + --primary-font-color-hover: #bfbfb0; + --header-background: #222222; + --header-background-hover: #323232; + --example-background: var(--header-background); + --link-color: #00a8f4; + --link-color-hover: #3cc2ff; +} +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Nunito Sans", -apple-system, BlinkMacSystemFont, Segoe UI, + Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--primary-font-color); + background: var(--primary-background); + font-size: 1.1rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: "Cabin", sans-serif; + margin: 1rem; + font-weight: 700; + line-height: 1.2; +} + +p { + margin: 1.5rem 0; +} + +h1 { + font-size: 2.1rem; +} + +h2 { + font-size: 1.8rem; +} + +section { + margin: 1rem; + margin-top: 2rem; +} + +a { + color: var(--primary-font-color); + text-decoration: none; + transition: color 100ms; +} + +a:hover { + color: var(--primary-font-color-hover); +} + +code { + font-family: inconsolata, monospace; + font-size: calc(1em - 2px); + color: var(--primary-font-color); + background-color: var(--header-background); + padding: 0.2em 0.4em; + border-radius: 2px; +} + +input[type="text"], +input[type="email"], +input[type="password"], +[contenteditable="true"], +textarea { + font-size: 1em; + border: 1px solid #aaa; + border-radius: 10px; + padding: 0.3rem 1rem; + background: var(--on-primary-color); + height: 3rem; + width: 18rem; + color: black; +} + +input[aria-invalid="true"], +[contenteditable][aria-invalid="true"] { + border: 2px solid var(--error-color); +} + +fieldset { + width: 400px; + border: 2px solid var(--primary-color); + display: block; + font-size: 1.2em; + background: var(--example-background); + padding: 2rem; + border-radius: 10px 30px; +} + +button { + margin-top: 0.7em; + font-size: 0.8em; + font-weight: 700; + padding: 0.7em; + background: var(--primary-color); + border-radius: 10px; + border: none; + color: var(--on-primary-color); + transition: transform 0.1s; +} + +button:not([aria-disabled="true"]):hover { + cursor: pointer; + background: var(--primary-color-hover); +} + +button:not([aria-disabled="true"]):active { + transform: scale(0.9); +} + +button[aria-disabled="true"] { + background: var(--primary-color-hover); + cursor: not-allowed; +} + +form ul { + color: var(--error-color); +} + +button { + margin: 0.5rem; +} + +label { + display: block; +} diff --git a/examples/solid/basic/src/index.tsx b/examples/solid/basic/src/index.tsx new file mode 100644 index 00000000..9b92f85e --- /dev/null +++ b/examples/solid/basic/src/index.tsx @@ -0,0 +1,7 @@ +import { render } from 'solid-js/web'; + +import './reset.css'; +import './index.css'; +import App from './App'; + +render(() => , document.getElementById('root') as HTMLElement); diff --git a/examples/solid/basic/src/reset.css b/examples/solid/basic/src/reset.css new file mode 100644 index 00000000..aa4cf961 --- /dev/null +++ b/examples/solid/basic/src/reset.css @@ -0,0 +1,52 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; + font-size: 1.2rem; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +* { + box-sizing: border-box; +} diff --git a/examples/solid/basic/tsconfig.json b/examples/solid/basic/tsconfig.json new file mode 100644 index 00000000..0f2653b6 --- /dev/null +++ b/examples/solid/basic/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite/client"] + } +} diff --git a/examples/solid/basic/vite.config.ts b/examples/solid/basic/vite.config.ts new file mode 100644 index 00000000..d52d794c --- /dev/null +++ b/examples/solid/basic/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [solidPlugin()], + build: { + target: 'esnext', + polyfillDynamicImport: false, + }, +}); diff --git a/examples/svelte/basic/.gitignore b/examples/svelte/basic/.gitignore new file mode 100644 index 00000000..126fe84d --- /dev/null +++ b/examples/svelte/basic/.gitignore @@ -0,0 +1,4 @@ +/node_modules/ +/dist/ +/.vscode/ +.DS_Store diff --git a/examples/svelte/basic/.vscode/extensions.json b/examples/svelte/basic/.vscode/extensions.json new file mode 100644 index 00000000..bdef8201 --- /dev/null +++ b/examples/svelte/basic/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/examples/svelte/basic/README.md b/examples/svelte/basic/README.md new file mode 100644 index 00000000..a9d516a3 --- /dev/null +++ b/examples/svelte/basic/README.md @@ -0,0 +1,48 @@ +# Svelte + TS + Vite + +This template should help get you started developing with Svelte and TypeScript in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). + +## Need an official Svelte framework? + +Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. + +## Technical considerations + +**Why use this over SvelteKit?** + +- It brings its own routing solution which might not be preferable for some users. +- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. + `vite dev` and `vite build` wouldn't work in a SvelteKit environment, for example. + +This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. + +Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. + +**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** + +Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information. + +**Why include `.vscode/extensions.json`?** + +Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. + +**Why enable `allowJs` in the TS template?** + +While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant. + +**Why is HMR not preserving my local component state?** + +HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr). + +If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. + +```ts +// store.ts +// An extremely simple external store +import { writable } from 'svelte/store' +export default writable(0) +``` diff --git a/examples/svelte/basic/index.html b/examples/svelte/basic/index.html new file mode 100644 index 00000000..8b1c36d2 --- /dev/null +++ b/examples/svelte/basic/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Felte Examble - Basic + + +
+ + + diff --git a/examples/svelte/basic/package.json b/examples/svelte/basic/package.json new file mode 100644 index 00000000..153eb6fa --- /dev/null +++ b/examples/svelte/basic/package.json @@ -0,0 +1,26 @@ +{ + "name": "@example/svelte-basic", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^1.0.0-next.30", + "@tsconfig/svelte": "^2.0.1", + "svelte": "^3.44.0", + "svelte-check": "^2.2.7", + "svelte-preprocess": "^4.9.8", + "tslib": "^2.3.1", + "typescript": "^4.4.4", + "vite": "^2.7.2" + }, + "dependencies": { + "@felte/reporter-svelte": "next", + "felte": "next" + } +} diff --git a/examples/svelte/basic/public/favicon.ico b/examples/svelte/basic/public/favicon.ico new file mode 100644 index 00000000..d75d248e Binary files /dev/null and b/examples/svelte/basic/public/favicon.ico differ diff --git a/examples/svelte/basic/public/global.css b/examples/svelte/basic/public/global.css new file mode 100644 index 00000000..ce1d9a1b --- /dev/null +++ b/examples/svelte/basic/public/global.css @@ -0,0 +1,140 @@ +:root { + --primary-color: rgb(255, 62, 0); + --on-primary-color: #fffff9; + --primary-color-hover: rgb(255, 113, 51); + --error-color: #ff3a43; + --primary-background: #111111; + --primary-font-color: #fffff0; + --primary-font-color-hover: #bfbfb0; + --header-background: #222222; + --header-background-hover: #323232; + --example-background: var(--header-background); + --link-color: #00a8f4; + --link-color-hover: #3cc2ff; +} +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Nunito Sans", -apple-system, BlinkMacSystemFont, Segoe UI, + Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--primary-font-color); + background: var(--primary-background); + font-size: 1.1rem; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: "Cabin", sans-serif; + margin: 1rem; + font-weight: 700; + line-height: 1.2; +} + +p { + margin: 1.5rem 0; +} + +h1 { + font-size: 2.1rem; +} + +h2 { + font-size: 1.8rem; +} + +section { + margin: 1rem; + margin-top: 2rem; +} + +a { + color: var(--primary-font-color); + text-decoration: none; + transition: color 100ms; +} + +a:hover { + color: var(--primary-font-color-hover); +} + +code { + font-family: inconsolata, monospace; + font-size: calc(1em - 2px); + color: var(--primary-font-color); + background-color: var(--header-background); + padding: 0.2em 0.4em; + border-radius: 2px; +} + +input[type="text"], +input[type="email"], +input[type="password"], +[contenteditable="true"], +textarea { + font-size: 1em; + border: 1px solid #aaa; + border-radius: 10px; + padding: 0.3rem 1rem; + background: var(--on-primary-color); + height: 3rem; + width: 18rem; + color: black; +} + +input[aria-invalid="true"], +[contenteditable][aria-invalid="true"] { + border: 2px solid var(--error-color); +} + +fieldset { + width: 400px; + border: 2px solid var(--primary-color); + display: block; + font-size: 1.2em; + background: var(--example-background); + padding: 2rem; + border-radius: 10px 30px; +} + +button { + margin-top: 0.7em; + font-size: 0.8em; + font-weight: 700; + padding: 0.7em; + background: var(--primary-color); + border-radius: 10px; + border: none; + color: var(--on-primary-color); + transition: transform 0.1s; +} + +button:not([aria-disabled="true"]):hover { + cursor: pointer; + background: var(--primary-color-hover); +} + +button:not([aria-disabled="true"]):active { + transform: scale(0.9); +} + +button[aria-disabled="true"] { + background: var(--primary-color-hover); + cursor: not-allowed; +} + +form ul { + color: var(--error-color); +} + +label { + display: block; +} diff --git a/examples/svelte/basic/public/reset.css b/examples/svelte/basic/public/reset.css new file mode 100644 index 00000000..aa4cf961 --- /dev/null +++ b/examples/svelte/basic/public/reset.css @@ -0,0 +1,52 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; + font-size: 1.2rem; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +* { + box-sizing: border-box; +} diff --git a/examples/svelte/basic/src/App.svelte b/examples/svelte/basic/src/App.svelte new file mode 100644 index 00000000..448c1482 --- /dev/null +++ b/examples/svelte/basic/src/App.svelte @@ -0,0 +1,60 @@ + + +
+

Basic Example - Svelte

+
+
+ Sign In + + + +
    + {#each messages ?? [] as message} +
  • * {message}
  • + {/each} +
+
+ + + +
    + {#each messages ?? [] as message} +
  • * {message}
  • + {/each} +
+
+
+ + +
+ {#if submitted} +
+

Submitted values

+
{JSON.stringify(submitted, null, 2)}
+
+ {/if} +
diff --git a/examples/svelte/basic/src/main.ts b/examples/svelte/basic/src/main.ts new file mode 100644 index 00000000..d8200ac4 --- /dev/null +++ b/examples/svelte/basic/src/main.ts @@ -0,0 +1,7 @@ +import App from './App.svelte' + +const app = new App({ + target: document.getElementById('app') +}) + +export default app diff --git a/examples/svelte/basic/src/vite-env.d.ts b/examples/svelte/basic/src/vite-env.d.ts new file mode 100644 index 00000000..4078e747 --- /dev/null +++ b/examples/svelte/basic/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/svelte/basic/svelte.config.js b/examples/svelte/basic/svelte.config.js new file mode 100644 index 00000000..3630bb39 --- /dev/null +++ b/examples/svelte/basic/svelte.config.js @@ -0,0 +1,7 @@ +import sveltePreprocess from 'svelte-preprocess' + +export default { + // Consult https://github.com/sveltejs/svelte-preprocess + // for more information about preprocessors + preprocess: sveltePreprocess() +} diff --git a/examples/svelte/basic/tsconfig.json b/examples/svelte/basic/tsconfig.json new file mode 100644 index 00000000..f9039a5a --- /dev/null +++ b/examples/svelte/basic/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "esnext", + "useDefineForClassFields": true, + "module": "esnext", + "resolveJsonModule": true, + "baseUrl": ".", + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true + }, + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/examples/svelte/basic/vite.config.js b/examples/svelte/basic/vite.config.js new file mode 100644 index 00000000..401b4d4b --- /dev/null +++ b/examples/svelte/basic/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [svelte()] +}) diff --git a/package.json b/package.json index b8879cb4..a66be0ab 100644 --- a/package.json +++ b/package.json @@ -8,61 +8,64 @@ "site:dev": "pnpm -r --filter='@felte/site' dev", "test": "pnpm -r test", "test:ci": "pnpm -r test:ci", - "build": "pnpm -r build --filter='!@felte/site'", - "dev": "pnpm -r --parallel dev --filter='!@felte/site'", - "postinstall": "husky install", - "prepublishOnly": "pinst --disable", - "publish": "pnpm i && pnpm -r publish --no-git-checks --publish-branch main", - "postpublish": "pinst --enable" + "lint": "eslint packages --max-warnings=0 --ignore-pattern *packages/site*", + "build": "pnpm -r build --filter='!@felte/site' --filter='!@example/*'", + "dev": "pnpm -r --parallel dev --filter='!@felte/site' --filter='!@example/*'", + "prepare": "husky install", + "publish": "pnpm i && pnpm lint && pnpm -r publish" }, "devDependencies": { "@babel/core": "^7.16.5", "@changesets/changelog-git": "^0.1.6", "@commitlint/cli": "^12.1.1", "@commitlint/config-conventional": "^12.1.1", + "@istanbuljs/nyc-config-typescript": "^1.0.2", "@rollup/plugin-commonjs": "^18.0.0", "@rollup/plugin-node-resolve": "^11.1.0", "@rollup/plugin-replace": "^2.4.2", "@testing-library/dom": "^7.29.4", - "@testing-library/jest-dom": "^5.11.9", "@testing-library/svelte": "^3.0.3", "@testing-library/user-event": "^13.1.3", - "@types/jest": "^26.0.20", - "@typescript-eslint/eslint-plugin": "^4.14.1", - "@typescript-eslint/parser": "^4.14.1", + "@types/chai": "^4.3.0", + "@types/jsdom-global": "^3.0.2", + "@types/sinon": "^10.0.10", + "@typescript-eslint/eslint-plugin": "^5.9.1", + "@typescript-eslint/parser": "^5.9.1", "all-contributors-cli": "^6.20.0", - "babel-jest": "^26.6.3", "cross-env": "^7.0.3", "eslint": "^7.18.0", "eslint-config-prettier": "^8.2.0", - "eslint-plugin-jest": "^24.3.2", - "eslint-plugin-jest-dom": "^3.6.5", + "eslint-plugin-compat": "^4.0.1", "eslint-plugin-prettier": "^3.4.0", "eslint-plugin-svelte3": "^3.2.0", "eslint-plugin-testing-library": "^4.0.1", "front-matter": "^4.0.2", - "highlight.js": "^10.7.1", - "husky": "^6.0.0", - "jest": "^26.6.3", + "global-jsdom": "^8.4.0", + "husky": "^7.0.4", + "jsdom": "^19.0.0", "mdsvex": "^0.9.0", - "pinst": "^2.1.4", + "nyc": "^15.1.0", + "pnpm": "^6.29.1", "prettier": "^2.2.1", "rimraf": "^3.0.2", "rollup": "^2.38.0", "rollup-plugin-bundle-size": "^1.0.3", - "rollup-plugin-terser": "^7.0.2", - "rollup-plugin-ts": "^1.4.0", + "rollup-plugin-rename-node-modules": "^1.3.1", + "rollup-plugin-ts": "^2.0.5", + "sinon": "^13.0.1", "superstruct": "^0.15.0", "svelte": "^3.31.0", "svelte-focus-on": "^0.1.4", - "svelte-jester": "^1.3.0", "svelte-markdown": "^0.1.6", "svelte-portal": "^2.1.2", "tippy.js": "^6.0.0", - "ts-jest": "^26.5.0", - "tslib": "^2.1.0", - "typedoc": "^0.20.20", - "typescript": "^4.1.3", + "tslib": "^2.3.1", + "tsm": "^2.2.1", + "typedoc": "^0.22.10", + "typescript": "^4.5.5", + "uvu": "^0.5.3", + "uvu-expect": "^0.4.4", + "uvu-expect-dom": "^0.2.4", "vest": "^4.0.1", "yup": "^0.32.9", "zod": "^1.11.13" @@ -79,5 +82,11 @@ "babel-plugin-dev-expression": "^0.2.2", "rollup-plugin-svelte": "^7.1.0", "svelte-preprocess": "^4.6.9" - } + }, + "browserslist": [ + "defaults", + "not IE 11", + "not op_mini all", + "not Baidu 7.12" + ] } diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index b5cc25c6..7d2a4207 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -1,5 +1,203 @@ # @felte/common +## 1.0.0-next.23 + +### Patch Changes + +- 7f3d8b8: Refactor \_update and \_set methods + +## 1.0.0-next.22 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` + +## 1.0.0-next.21 + +### Minor Changes + +- fcbdaed: Add `swapFields` and `moveField` helper functions + +## 1.0.0-next.20 + +### Minor Changes + +- 990034e: Add `interacted` store to show which is the last field the user has interacted with + +## 1.0.0-next.19 + +### Minor Changes + +- a174e87: Add isEqual utility to check for strict equality + +## 1.0.0-next.18 + +### Patch Changes + +- 70cfada: Fix deepSome handling arrays + +## 1.0.0-next.17 + +### Patch Changes + +- 2e7aad3: Add type for keyed Data + +## 1.0.0-next.16 + +### Minor Changes + +- c8c1511: Add unique key to field arrays + +## 1.0.0-next.15 + +### Minor Changes + +- 093482a: Add isValidating store + +## 1.0.0-next.14 + +### Patch Changes + +- dd52c94: Fix error filtering + +## 1.0.0-next.13 + +### Major Changes + +- a45d56c: BREAKING: `errors` and `warning` stores will either have `null` or an array of strings as errors + +## 1.0.0-next.12 + +### Major Changes + +- 452fe5a: BREAKING: Remove `data-felte-index` attribute support. + + This means that you should replace this: + + ```html + + ``` + + To this: + + ```html + + ``` + + This was done in order to allow for future improvements of the type system for TypeScript users, and to also follow the same behaviour the browser would do if JavaScript is disabled + +- 15d0ce2: BREAKING: Stop grabbing nested names from fieldset + + This means that this won't work anymore: + + ```html +
+ +
+ ``` + + So it needs to be changed to this: + + ```html +
+ +
+ ``` + + This was done to allow for future improvements on type-safety, as well to keep consistency with the browser's behaviour when JavaScript is disabled. + +## 1.0.0-next.11 + +### Major Changes + +- b7ef442: BREAKING: Remove `addWarnValidator` in favour of options to `addValidator`. + + This gives a smaller and more unified API, as well as opening to add more options in the future. + + If you have an extender using `addWarnValidator`, you must update it by calling `addValidator` instead with the following options: + + ```javascript + addValidator(yourValidationFunction, { level: 'warning' }); + ``` + +### Minor Changes + +- a1dbc28: Improve types +- ec740a0: Update types +- 34e0393: Make string paths for accessors type safe +- e1ad8cd: Export `mergeErrors` util + +## 1.0.0-next.10 + +### Minor Changes + +- dc1f21a: Add helper functions to context passed to `onSuccess`, `onSubmit` and `onError` +- eea3afa: Pass context data to `onError` and `onSuccess` + +## 1.0.0-next.9 + +### Patch Changes + +- 38fbb49: Point "browser" field to esm bundle + +## 1.0.0-next.8 + +### Patch Changes + +- c86a82a: Preserve modules in CJS + +## 1.0.0-next.7 + +### Patch Changes + +- e49c094: Use `preserveModules` for better tree-shaking + +## 1.0.0-next.6 + +### Patch Changes + +- d1b62bf: Allow for `onError` and `onSuccess` to be asynchronous + +## 1.0.0-next.5 + +### Patch Changes + +- e2f4e18: Clone object on update function + +## 1.0.0-next.3 + +### Patch Changes + +- 8c29b4a: Fix unset on Safari + +## 1.0.0-next.2 + +### Minor Changes + +- 6f48123: Add `addField` helper function + +## 1.0.0-next.1 + +### Major Changes + +- 02a77e3: BREAKING: When removing an input from an array of inputs, Felte now splices the array instead of setting the value to `null`/`undefined`. This means that an `index` on an array of inputs is no longer a _unique_ identifier and the value can move around if fields are added/removed. + +## 1.0.0-next.0 + +### Major Changes + +- 9a48a40: Pass a new property `stage` to extenders to distinguish between setup, mount and update stages +- 0d22bc6: BREAKING: Helpers have been completely reworked. + `setField` and `setFields` have been unified in a single `setFields` helper. + Others such as `setError` and `setWarning` have been pluralized to `setErrors` and `setWarnings` since now they can accept the whole object. + `setTouched` now requires to be passed the value to assign. E.g. `setTouched('path')` is now `setTouched('path', true)`. It no longer accepts an index as an argument since that can be assigned in the path itself using `[]`. +- 3d571bb: BREAKING: Remove `getField` helper in favor of `getValue` export. E.g. `getField('email')` now is `getValue($data, 'email')` and accessors. +- 2c0f874: Make type of helpers and stores looser when using a transform function + +### Minor Changes + +- c1f32a0: Add `unsetField` and `resetField` helper functions + ## 0.6.0 ### Minor Changes diff --git a/packages/common/jest.config.js b/packages/common/jest.config.js deleted file mode 100644 index 553799a5..00000000 --- a/packages/common/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], -}; diff --git a/packages/common/package.json b/packages/common/package.json index ce6d406c..bd64c3f2 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,14 +1,18 @@ { "name": "@felte/common", - "version": "0.6.0", + "version": "1.0.0-next.23", "description": "Common utilities for Felte packages", "author": "Pablo Berganza ", "homepage": "https://github.com/pablo-abc/felte/tree/main/packages/common", "license": "MIT", - "main": "dist/index.js", - "browser": "dist/index.js", + "main": "dist/cjs/index.cjs", + "browser": "dist/esm/index.js", "module": "dist/esm/index.js", - "types": "dist/index.d.ts", + "types": "dist/types/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "sideEffects": false, "keywords": [ "svelte", @@ -27,11 +31,11 @@ }, "scripts": { "prebuild": "rimraf ./dist", - "build": "cross-env NODE_ENV=production rollup -c", + "build": "pnpm prebuild && cross-env NODE_ENV=production rollup -c", "dev": "rollup -cw", "prepublishOnly": "pnpm build && pnpm test", - "test": "jest", - "test:ci": "jest --ci --coverage" + "test": "uvu -r tsm -r global-jsdom/register tests -i common", + "test:ci": "nyc -n src pnpm test" }, "bugs": { "url": "https://github.com/pablo-abc/felte/issues" @@ -39,10 +43,9 @@ "exports": { ".": { "import": "./dist/esm/index.js", - "require": "./dist/index.js", + "require": "./dist/cjs/index.cjs", "default": "./dist/esm/index.js" }, - "./dist/utils/*": "./dist/esm/utils/*.js", "./package.json": "./package.json" } } diff --git a/packages/common/rollup.config.js b/packages/common/rollup.config.js index b1687df9..355b1f84 100644 --- a/packages/common/rollup.config.js +++ b/packages/common/rollup.config.js @@ -2,67 +2,36 @@ import typescript from 'rollup-plugin-ts'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; -import bundleSize from 'rollup-plugin-bundle-size'; -import { terser } from 'rollup-plugin-terser'; import pkg from './package.json'; -import * as fs from 'fs'; - -const modules = fs - .readdirSync('./src/utils') - .map((module) => `./src/utils/${module}`); const prod = process.env.NODE_ENV === 'production'; -const name = pkg.name - .replace(/^(@\S+\/)?(svelte-)?(\S+)/, '$3') - .replace(/^\w/, (m) => m.toUpperCase()) - .replace(/-\w/g, (m) => m[1].toUpperCase()); -export default [ - { - input: './src/index.ts', - output: { - file: pkg.browser, +export default { + input: './src/index.ts', + output: [ + { + file: pkg.main, format: 'cjs', sourcemap: prod, - exports: 'named', - name, }, - plugins: [ - replace({ - 'process.env.NODE_ENV': JSON.stringify( - prod ? 'production' : 'development' - ), - preventAssignment: true, - }), - resolve({ browser: true }), - commonjs(), - typescript(), - prod && terser(), - prod && bundleSize(), - ], - }, - { - input: ['./src/index.ts', ...modules], - output: { + { dir: 'dist/esm', format: 'esm', sourcemap: prod, exports: 'named', preserveModules: true, + preserveModulesRoot: 'src', }, - plugins: [ - replace({ - 'process.env.NODE_ENV': JSON.stringify( - prod ? 'production' : 'development' - ), - preventAssignment: true, - }), - resolve({ browser: true }), - commonjs(), - typescript({ - declarationDir: './dist/esm', - }), - prod && terser(), - ], - }, -]; + ], + plugins: [ + replace({ + 'process.env.NODE_ENV': JSON.stringify( + prod ? 'production' : 'development' + ), + preventAssignment: true, + }), + resolve({ browser: true }), + commonjs(), + typescript({ browserslist: false }), + ], +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index bcf2093b..677e1947 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -12,10 +12,13 @@ export { _unset } from './utils/unset'; export { _update } from './utils/update'; export { deepSet } from './utils/deepSet'; export { deepSome } from './utils/deepSome'; -export { getIndex } from './utils/getIndex'; -export * from './utils/typeGuards'; export { getPath } from './utils/getPath'; -export { getPathFromDataset } from './utils/getPathFromDataset'; export { shouldIgnore } from './utils/shouldIgnore'; +export { getValue } from './utils/getValue'; +export { mergeErrors, runValidations } from './utils/executeValidation'; +export { executeTransforms } from './utils/executeTransforms'; +export { createId } from './utils/createId'; +export { isEqual } from './utils/isEqual'; +export * from './utils/typeGuards'; export * from './utils/domUtils'; export * from './types'; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 78a10a69..90a1cbd8 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -1,10 +1,109 @@ import type { Readable, Writable } from 'svelte/store'; +export type RecursivePartial> = { + [P in keyof T]?: T[P] extends Record | Array + ? RecursivePartial + : T[P]; +}; + +export type RecursiveRequired> = { + [P in keyof T]-?: T[P] extends Record | Array + ? RecursiveRequired + : T[P]; +}; + +export type ObjectSetter = (< + P extends Path, + V extends Traverse = Traverse +>( + path: P, + value: V +) => void) & + (

= Traverse>( + path: P, + updater: (value: V) => V + ) => void) & + ((value: Data) => void) & + ((updater: (value: Data) => Data) => void); + +export type UnknownObjectSetter< + Data extends Obj, + Path extends string = string +> = ((path: string, value: unknown) => void) & + (

= Traverse>( + path: P, + updater: (value: V) => unknown + ) => void) & + ((updater: (value: Data) => unknown) => void) & + ((value: unknown) => void); + +export type PartialErrorsSetter< + Data extends Obj, + Path extends string = string +> = (< + P extends Path, + V extends Traverse, P> = Traverse< + AssignableErrors, + P + > +>( + path: P, + value: V +) => void) & + (< + P extends Path, + V extends Traverse, P> = Traverse< + AssignableErrors, + P + > + >( + path: P, + updater: (value: V) => V + ) => void) & + ((value: AssignableErrors) => void) & + ((updater: (value: Errors) => AssignableErrors) => void); + +export type PrimitiveSetter = ((value: Data) => void) & + ((updater: (value: Data) => Data) => void); + +export type FieldsSetter = (< + P extends Path, + V extends Traverse = Traverse +>( + path: P, + value: V, + shouldTouch?: boolean +) => void) & + (

= Traverse>( + path: P, + updater: (value: V) => V, + shouldTouch?: boolean + ) => void) & + ((value: Data) => void) & + ((updater: (value: Data) => Data) => void); + +export type UnknownFieldsSetter< + Data extends Obj, + Path extends string = string +> = (

(path: P, value: unknown, shouldTouch?: boolean) => void) & + (

= Traverse>( + path: P, + updater: (value: V) => unknown, + shouldTouch?: boolean + ) => void) & + ((value: unknown) => void) & + ((updater: (value: Data) => unknown) => void); + +export type Setter = Data extends Record< + string, + any +> + ? ObjectSetter + : PrimitiveSetter; + export type DeepSetResult = { - [key in keyof Data]: Data[key] extends Obj + [key in keyof Data]: Data[key] extends Obj | Obj[] ? DeepSetResult - : Data[key] extends Obj[] - ? DeepSetResult[] : Value; }; @@ -12,46 +111,114 @@ export type CreateSubmitHandlerConfig = { onSubmit?: FormConfig['onSubmit']; validate?: FormConfig['validate']; warn?: FormConfig['warn']; + onSuccess?: FormConfig['onSuccess']; onError?: FormConfig['onError']; }; -export type Helpers = { +export type KnownHelpers = Omit< + Helpers, + 'setData' | 'setFields' +> & { + setData: ObjectSetter; + setFields: FieldsSetter; +}; + +export type UnknownHelpers< + Data extends Obj, + Path extends string = string +> = Omit, 'setData' | 'setFields'> & { + setData: UnknownObjectSetter; + setFields: UnknownFieldsSetter; +}; + +export type Helpers = { /** Function that resets the form to its initial values */ reset(): void; + /** Helper function to set the values in the data store */ + setData: ObjectSetter | UnknownObjectSetter; /** Helper function to touch a specific field. */ - setTouched(path: string): void; + setTouched: ObjectSetter, Path>; /** Helper function to set an error to a specific field. */ - setError(path: string, error: string | string[]): void; + setErrors: PartialErrorsSetter; /** Helper function to set a warning on a specific field. */ - setWarning(path: string, warning: string | string[]): void; - /** Helper function to set the value of a specific field. Set `touch` to `false` if you want to set the value without setting the field to touched. */ - setField(path: string, value?: FieldValue, touch?: boolean): void; - /** Helper function to get the value of a specific field. */ - getField(path: string): FieldValue | FieldValue[]; + setWarnings: PartialErrorsSetter; + /** Helper function to set the value of the isDirty store */ + setIsDirty: PrimitiveSetter; + /** Helper function to set the value of the isSubmitting store */ + setIsSubmitting: PrimitiveSetter; + /** Helper function the set the value of the interacted store */ + setInteracted: PrimitiveSetter; /** Helper function to set all values of the form. Useful for "initializing" values after the form has loaded. */ - setFields(values: Data): void; + setFields: FieldsSetter | UnknownFieldsSetter; + /** Helper function to unset a field (remove it completely from your stores) */ + unsetField

(path: P): void; + /** Helper function to reset a field to its initial value */ + resetField

(path: P): void; + /** Helper function to swap the position of two fields in an array */ + swapFields

(path: P, from: number, to: number): void; + /** Helper function to move a field to a new position */ + moveField

(path: P, from: number, to: number): void; + /** Helper function that adds a field to an array of fields, by default at the end but you can define at which index you want the new item */ + addField: < + P extends Path, + V extends Traverse = Traverse + >( + path: P, + value: V, + index?: number + ) => void; /** Helper function that validates every fields and touches all of them. It updates the `errors` and `warnings` store. */ validate(): Promise | void>; /** Helper function to re-set the initialValues of Felte. No reactivity will be triggered but this will be the data the form will be reset to when caling `reset`. */ setInitialValues(values: Data): void; }; -export type CurrentForm = { - form?: HTMLFormElement; - controls?: FormControl[]; - errors: Writable>; - warnings: Writable>; - data: Writable; +export type ValidatorOptions = { + debounced?: boolean; + level?: 'warning' | 'error'; +}; + +export type AddValidatorFn = ( + validator: ValidationFunction, + options?: ValidatorOptions +) => void; + +export type SetupCurrentForm = { + form?: never; + controls?: never; + stage: 'SETUP'; + errors: PartialWritableErrors; + warnings: PartialWritableErrors; + data: KeyedWritable; + touched: Writable>; + config: FormConfig; + setFields(values: Data): void; + reset(): void; + validate(): Promise | void>; + addValidator: AddValidatorFn; + addTransformer(transformer: TransformFunction): void; +}; + +export type MountedCurrentForm = { + form: HTMLFormElement; + controls: FormControl[]; + stage: 'MOUNT' | 'UPDATE'; + errors: PartialWritableErrors; + warnings: PartialWritableErrors; + data: KeyedWritable; touched: Writable>; config: FormConfig; setFields(values: Data): void; reset(): void; validate(): Promise | void>; - addValidator(validator: ValidationFunction): void; - addWarnValidator(validator: ValidationFunction): void; + addValidator: AddValidatorFn; addTransformer(transformer: TransformFunction): void; }; +export type CurrentForm = + | MountedCurrentForm + | SetupCurrentForm; + export type OnSubmitErrorState = { data: Data; errors: Errors; @@ -63,7 +230,7 @@ export type ExtenderHandler = { }; export type Extender = ( - currentForm: CurrentForm + currentForm: MountedCurrentForm | SetupCurrentForm ) => ExtenderHandler; /** `Record` */ @@ -77,6 +244,7 @@ export type FieldValue = | number | File | File[] + | null | undefined; export type FormControl = @@ -86,99 +254,225 @@ export type FormControl = export type ValidationFunction = ( values: Data -) => Errors | undefined | Promise | undefined>; +) => + | AssignableErrors + | undefined + | Promise | undefined>; -export type TransformFunction = (values: Obj) => Data; +export type TransformFunction = (values: unknown) => Data; export type SubmitContext = { form?: HTMLFormElement; controls?: FormControl[]; config: FormConfig; +} & Omit< + Helpers, + 'validate' | 'setIsSubmitting' | 'setIsDirty' | 'setInteracted' +>; + +type DebouncedConfig = { + /** Optional function to validate the data. */ + validate?: ValidationFunction | ValidationFunction[]; + /** Optional function to set warnings based on the current state of your data. */ + warn?: ValidationFunction | ValidationFunction[]; + /** Optional number to set timeout in milliseconds */ + timeout?: number; + /** Overrides timeout for validate */ + validateTimeout?: number; + /** Overrides timeout for warn */ + warnTimeout?: number; }; /** * Configuration object when `initialValues` is not set. Used when using the `form` action. */ -export interface FormConfigWithoutInitialValues { +export type FormConfigWithoutTransformFn = { + transform?: never; + /** Optional object with the initial values of the form **/ + initialValues?: RecursivePartial; /** Optional function to validate the data. */ validate?: ValidationFunction | ValidationFunction[]; /** Optional function to set warnings based on the current state of your data. */ warn?: ValidationFunction | ValidationFunction[]; - /** Optional function to transform data before it gets set in the store. */ - transform?: TransformFunction | TransformFunction[]; - /** Required function to handle the form data on submit. */ - onSubmit: ( + /** Optional object containing properties to be debounced */ + debounced?: DebouncedConfig; + /** Optional function to handle the form data on submit. */ + onSubmit?: ( values: Data, context: SubmitContext - ) => Promise | void; + ) => Promise | unknown; + /** Optional function to react to a submission success. It will receive whatever you return from `onSubmit`. If not using `onSubmit` it will receive the `Response` from the default submit handler */ + onSuccess?: ( + response: unknown, + context: SubmitContext + ) => void | Promise; /** Optional function that accepts any thrown exceptions from the onSubmit function. You can return an object with the same shape [[`Errors`]] for a reporter to use it. */ - onError?: (errors: unknown) => void | Errors; + onError?: ( + error: unknown, + context: SubmitContext + ) => Promise> | void | Errors; /** Optional function/s to extend Felte's functionality. */ extend?: Extender | Extender[]; - /** Optional array that sets which events should trigger a field to be touched. */ - touchTriggerEvents?: { - change?: boolean; - input?: boolean; - blur?: boolean; - }; [key: string]: unknown; -} +}; /** * Configuration object when `initialValues` is set. Used when using the `data` store to bind to form inputs. */ -export interface FormConfigWithInitialValues - extends FormConfigWithoutInitialValues { - /** Initial values for the form. To be used when not using the `form` action. */ - initialValues: Data; +export type FormConfigWithTransformFn = { + transform: TransformFunction | TransformFunction[]; + /** Optional object with the initial values of the form **/ + initialValues?: unknown; + /** Optional function to validate the data. */ + validate?: ValidationFunction | ValidationFunction[]; + /** Optional function to set warnings based on the current state of your data. */ + warn?: ValidationFunction | ValidationFunction[]; + /** Optional object containing properties to be debounced */ + debounced?: DebouncedConfig; + /** Required function to handle the form data on submit. */ + onSubmit?: ( + values: Data, + context: SubmitContext + ) => Promise | unknown; + /** Optional function to react to a submission success. It will receive whatever you return from `onSubmit`. If not using `onSubmit` it will receive the `Response` from the default submit handler */ + onSuccess?: ( + response: unknown, + context: SubmitContext + ) => void | Promise; + /** Optional function that accepts any thrown exceptions from the onSubmit function. You can return an object with the same shape [[`Errors`]] for a reporter to use it. */ + onError?: ( + error: unknown, + context: SubmitContext + ) => Promise> | void | Errors; + /** Optional function/s to extend Felte's functionality. */ + extend?: Extender | Extender[]; [key: string]: unknown; -} +}; /** * Configuration object type. `initialValues` is optional. */ -export interface FormConfig - extends FormConfigWithoutInitialValues { - initialValues?: Data; - [key: string]: unknown; -} +export type FormConfig = + | FormConfigWithTransformFn + | FormConfigWithoutTransformFn; + +type AnyObj = Record; +type AnyArr = Array; /** The errors object may contain either a string or array or string per key. */ -export type Errors = { - [key in keyof Data]?: Data[key] extends Obj - ? Errors - : Data[key] extends Obj[] - ? Errors[] - : string | string[] | null; -}; +export type Errors = Data extends AnyArr + ? Data[number] extends AnyObj + ? Errors[] + : string[] | null + : Data extends Date | File | File[] + ? string[] | null + : Data extends AnyObj + ? { + [key in keyof Data]: Data[key] extends AnyObj | AnyArr + ? Errors + : string[] | null; + } + : any; + +export type Keyed = Data extends AnyArr + ? Data[number] extends AnyObj + ? (Keyed & { key: string })[] + : Data[number][] + : Data extends Date | File | File[] + ? Data + : Data extends AnyObj + ? { + [key in keyof Data]: Data[key] extends AnyObj | AnyArr + ? Keyed + : Data[key]; + } + : any; + +/** The errors object may contain either a string or array or string per key. */ +export type AssignableErrors = Data extends AnyArr + ? Data[number] extends AnyObj + ? AssignableErrors[] + : string | string[] | null | undefined + : Data extends Date | File | File[] + ? string | string[] | null | undefined + : Data extends AnyObj + ? { + [key in keyof Data]?: Data[key] extends AnyObj | AnyArr + ? AssignableErrors | undefined + : string | string[] | null | undefined; + } + : any; /** The touched object may only contain booleans per key. */ -export type Touched = { - [key in keyof Data]: Data[key] extends Obj - ? Touched - : Data[key] extends Obj[] - ? Touched[] - : boolean | boolean[]; -}; +export type Touched = Data extends AnyArr + ? Data[number] extends AnyObj + ? Touched[] + : boolean + : Data extends Date | File | File[] + ? boolean + : Data extends AnyObj + ? { + [key in keyof Data]: Data[key] extends AnyObj | AnyArr + ? Touched + : boolean; + } + : any; export type FormAction = (node: HTMLFormElement) => { destroy: () => void }; +export type TransWritable = { + subscribe(subscriber: (values: Keyed) => any): () => void; + set(value: unknown): void; + update(updater: (value: Data) => unknown): void; +}; + +export type UnknownStores< + Data extends Obj, + StoreExt = Record +> = Omit, 'data'> & { + data: TransWritable & StoreExt; +}; + +export type KnownStores< + Data extends Obj, + StoreExt = Record +> = Omit, 'data'> & { + data: KeyedWritable & StoreExt; +}; + +export type PartialWritableErrors = { + subscribe: Writable>['subscribe']; + set: Writable>['set']; + update: (updater: (value: Errors) => AssignableErrors) => void; +}; + +export type KeyedWritable = Omit< + Writable, + 'subscribe' +> & { + subscribe(subscriber: (values: Keyed) => any): () => void; +}; + /** The stores that `createForm` creates. */ -export type Stores = { +export type Stores> = { /** Writable store that contains the form's data. */ - data: Writable; + data: (KeyedWritable | TransWritable) & StoreExt; /** Writable store that contains the form's validation errors. */ - errors: Writable>; + errors: PartialWritableErrors & StoreExt; /** Writable store that contains warnings for the form. These won't prevent a submit from happening. */ - warnings: Writable>; + warnings: PartialWritableErrors & StoreExt; /** Writable store that denotes if any field has been touched. */ - touched: Writable>; + touched: Writable> & StoreExt; /** Writable store containing only a boolean that represents if the form is submitting. */ - isSubmitting: Writable; + isSubmitting: Writable & StoreExt; /** Readable store containing only a boolean that represents if the form is valid. */ - isValid: Readable; + isValid: Readable & StoreExt; + /** Readable store containing only a boolean that represents if the form is currently validating. */ + isValidating: Readable & StoreExt; /** Readable store containing only a boolean that represents if the form is dirty. */ - isDirty: Writable; + isDirty: Writable & StoreExt; + /** Writable store containing either `null` or the name of the last field the user interacted with. */ + interacted: Writable & StoreExt; }; /** The return type for the `createForm` function. */ @@ -191,7 +485,72 @@ export type Form = { createSubmitHandler: ( altConfig?: CreateSubmitHandlerConfig ) => (e?: Event) => void; -} & Stores & - Helpers; +}; + +export type StoreFactory> = ( + initialValue: Value +) => Writable & Ext; + +type Prev = [ + never, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + ...0[] +]; + +type Join = K extends string | number + ? P extends string | number + ? `${K}${'' extends P ? '' : '.'}${P}` + : never + : never; + +export type Paths = [D] extends [never] + ? never + : T extends object + ? { + [K in keyof T]-?: K extends string | number + ? `${K}` | Join> + : never; + }[keyof T] + : ''; + +type Split = Path extends `${infer T}.${infer R}` + ? [T, ...Split] + : [Path]; + +type TraverseImpl = Path extends [ + infer K, + ...infer R +] + ? K extends keyof T + ? TraverseImpl + : K extends `${number}` + ? T extends Array + ? TraverseImpl | undefined + : any + : any + : T; -export type StoreFactory = (initialValue: Value) => Writable; +export type Traverse< + T extends Record | Array, + Path extends string +> = TraverseImpl>; diff --git a/packages/reporter-solid/src/utils.ts b/packages/common/src/utils/createId.ts similarity index 67% rename from packages/reporter-solid/src/utils.ts rename to packages/common/src/utils/createId.ts index f46917f4..33c09d70 100644 --- a/packages/reporter-solid/src/utils.ts +++ b/packages/common/src/utils/createId.ts @@ -1,5 +1,6 @@ export function createId(length = 8) { - let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let str = ''; for (let i = 0; i < length; i++) { str += chars.charAt(Math.floor(Math.random() * chars.length)); diff --git a/packages/common/src/utils/deepSet.ts b/packages/common/src/utils/deepSet.ts index 0f2cfa79..49e180ff 100644 --- a/packages/common/src/utils/deepSet.ts +++ b/packages/common/src/utils/deepSet.ts @@ -4,7 +4,10 @@ import { _isPlainObject } from './isPlainObject'; function handleArray(value: Value) { return function (propVal: Obj) { - if (_isPlainObject(propVal)) return deepSet(propVal as Obj, value); + if (_isPlainObject(propVal)) { + const { key, ...field } = deepSet(propVal as Obj, value); + return field; + } return value; }; } diff --git a/packages/common/src/utils/deepSome.ts b/packages/common/src/utils/deepSome.ts index 99f738a6..b8e3c2b5 100644 --- a/packages/common/src/utils/deepSome.ts +++ b/packages/common/src/utils/deepSome.ts @@ -7,6 +7,14 @@ import { _isPlainObject } from './isPlainObject'; */ export function deepSome(obj: Obj, pred: (value: unknown) => boolean): boolean { return _some(obj, (value) => - _isPlainObject(value) ? deepSome(value as Obj, pred) : pred(value) + _isPlainObject(value) + ? deepSome(value as Obj, pred) + : Array.isArray(value) + ? value.length === 0 || value.every((v) => typeof v === 'string') + ? pred(value) + : value.some((v) => + _isPlainObject(v) ? deepSome(v as Obj, pred) : pred(v) + ) + : pred(value) ); } diff --git a/packages/common/src/utils/domUtils.ts b/packages/common/src/utils/domUtils.ts index deb561f9..453e7a45 100644 --- a/packages/common/src/utils/domUtils.ts +++ b/packages/common/src/utils/domUtils.ts @@ -1,19 +1,9 @@ -import type { - FormControl, - Obj, - FieldValue, - ValidationFunction, - Errors, - TransformFunction, -} from '../types'; +import type { FormControl, Obj, FieldValue, Touched } from '../types'; import { isFormControl, isFieldSetElement, isInputElement } from './typeGuards'; -import { _mergeWith } from './mergeWith'; -import { _isPlainObject } from './isPlainObject'; import { _get } from './get'; import { _set } from './set'; import { _update } from './update'; import { getPath } from './getPath'; -import { getIndex } from './getIndex'; /** * @ignore @@ -41,21 +31,11 @@ export function getFormControls(el: Element): FormControl[] { export function addAttrsFromFieldset(fieldSet: HTMLFieldSetElement): void { for (const element of fieldSet.elements) { if (!isFormControl(element) && !isFieldSetElement(element)) continue; - if (fieldSet.name && element.name) { - const index = getIndex(fieldSet); - const fieldsetName = - typeof index === 'undefined' - ? fieldSet.name - : `${fieldSet.name}[${index}]`; - element.dataset.felteFieldset = fieldSet.dataset.felteFieldset - ? `${fieldSet.dataset.felteFieldset}.${fieldsetName}` - : fieldsetName; - } if ( - fieldSet.dataset.felteUnsetOnRemove === 'true' && - !element.hasAttribute('data-felte-unset-on-remove') + fieldSet.hasAttribute('data-felte-keep-on-remove') && + !element.hasAttribute('data-felte-keep-on-remove') ) { - element.dataset.felteUnsetOnRemove = 'true'; + element.dataset.felteKeepOnRemove = fieldSet.dataset.felteKeepOnRemove; } } } @@ -76,13 +56,13 @@ export function getInputTextOrNumber( */ export function getFormDefaultValues( node: HTMLFormElement -): { defaultData: Data } { +): { defaultData: Data; defaultTouched: Touched } { let defaultData = {} as Data; + let defaultTouched = {} as Touched; for (const el of node.elements) { if (isFieldSetElement(el)) addAttrsFromFieldset(el); if (!isFormControl(el) || !el.name) continue; const elName = getPath(el); - const index = getIndex(el); if (isInputElement(el)) { if (el.type === 'checkbox') { if (typeof _get(defaultData, elName) === 'undefined') { @@ -90,27 +70,22 @@ export function getFormDefaultValues( node.querySelectorAll(`[name="${el.name}"]`) ).filter((checkbox) => { if (!isFormControl(checkbox)) return false; - if (typeof index !== 'undefined') { - const felteIndex = Number( - (checkbox as HTMLInputElement).dataset.felteIndex - ); - return felteIndex === index; - } return elName === getPath(checkbox); }); if (checkboxes.length === 1) { defaultData = _set(defaultData, elName, el.checked); + defaultTouched = _set(defaultTouched, elName, false); continue; } defaultData = _set(defaultData, elName, el.checked ? [el.value] : []); + defaultTouched = _set(defaultTouched, elName, false); continue; } if (Array.isArray(_get(defaultData, elName)) && el.checked) { - _update(defaultData, elName, (value) => { - if (typeof index !== 'undefined' && !Array.isArray(value)) - value = []; - return [...value, el.value]; - }); + defaultData = _update(defaultData, elName, (value) => [ + ...value, + el.value, + ]); } continue; } @@ -121,6 +96,7 @@ export function getFormDefaultValues( elName, el.checked ? el.value : undefined ); + defaultTouched = _set(defaultTouched, elName, false); continue; } if (el.type === 'file') { @@ -129,13 +105,15 @@ export function getFormDefaultValues( elName, el.multiple ? Array.from(el.files || []) : el.files?.[0] ); + defaultTouched = _set(defaultTouched, elName, false); continue; } } const inputValue = getInputTextOrNumber(el); defaultData = _set(defaultData, elName, inputValue); + defaultTouched = _set(defaultTouched, elName, false); } - return { defaultData }; + return { defaultData, defaultTouched }; } export function setControlValue( @@ -192,34 +170,3 @@ export function setForm( setControlValue(el, _get(data, elName)); } } - -type ErrorField = string | Obj | string[]; - -function executeCustomizer(objValue?: ErrorField, srcValue?: ErrorField) { - if (_isPlainObject(objValue) || _isPlainObject(srcValue)) return; - if (objValue === null) return srcValue; - if (srcValue === null) return objValue; - if (!objValue || !srcValue) return; - if (!Array.isArray(objValue)) objValue = [objValue as string]; - if (!Array.isArray(srcValue)) srcValue = [srcValue as string]; - return [...objValue, ...srcValue]; -} - -export async function executeValidation( - values: Data, - validations?: ValidationFunction[] | ValidationFunction -): Promise>> { - if (!validations) return; - if (!Array.isArray(validations)) return validations(values); - const errorArray = await Promise.all(validations.map((v) => v(values))); - return _mergeWith>(...errorArray, executeCustomizer); -} - -export function executeTransforms( - values: Obj, - transforms?: TransformFunction[] | TransformFunction -): ReturnType> { - if (!transforms) return values as Data; - if (!Array.isArray(transforms)) return transforms(values); - return transforms.reduce((res, t) => t(res), values) as Data; -} diff --git a/packages/common/src/utils/executeTransforms.ts b/packages/common/src/utils/executeTransforms.ts new file mode 100644 index 00000000..1b7bd0ee --- /dev/null +++ b/packages/common/src/utils/executeTransforms.ts @@ -0,0 +1,10 @@ +import type { Obj, TransformFunction } from '../types'; + +export function executeTransforms( + values: Obj, + transforms?: TransformFunction[] | TransformFunction +): ReturnType> { + if (!transforms) return values as Data; + if (!Array.isArray(transforms)) return transforms(values); + return transforms.reduce((res, t) => t(res), values) as Data; +} diff --git a/packages/common/src/utils/executeValidation.ts b/packages/common/src/utils/executeValidation.ts new file mode 100644 index 00000000..7e89a17d --- /dev/null +++ b/packages/common/src/utils/executeValidation.ts @@ -0,0 +1,55 @@ +import type { Obj, ValidationFunction, RecursivePartial } from '../types'; +import { _mergeWith } from './mergeWith'; +import { _isPlainObject } from './isPlainObject'; + +type ErrorField = Obj | string[] | Obj[] | string; + +function executeCustomizer(objValue?: ErrorField, srcValue?: ErrorField) { + if (_isPlainObject(objValue) || _isPlainObject(srcValue)) return; + if (objValue === null || objValue === '') return srcValue; + if (srcValue === null || srcValue === '') return objValue; + if (!srcValue) return objValue; + if (!objValue || !srcValue) return; + if (Array.isArray(objValue)) { + if (!Array.isArray(srcValue)) return [...objValue, srcValue]; + const newErrors: any[] = []; + const errLength = Math.max(srcValue.length, objValue.length); + for (let i = 0; i < errLength; i++) { + let obj: any = objValue[i]; + let src: any = srcValue[i]; + if (!_isPlainObject(obj) && !_isPlainObject(src)) { + if (!Array.isArray(obj)) obj = [obj]; + if (!Array.isArray(src)) src = [src]; + newErrors.push(...obj, ...src); + } else { + newErrors.push(mergeErrors([obj ?? {}, src ?? {}] as any)); + } + } + return newErrors.filter(Boolean); + } + if (!Array.isArray(srcValue)) srcValue = [srcValue]; + return [objValue, ...srcValue] + .reduce((acc, value) => acc.concat(value as string), [] as string[]) + .filter(Boolean); +} + +export function mergeErrors( + errors: (RecursivePartial | undefined)[] +) { + const merged = _mergeWith(...errors, executeCustomizer); + return merged; +} + +export function runValidations( + values: Data, + validationOrValidations?: + | ValidationFunction[] + | ValidationFunction +): ReturnType>[] { + if (!validationOrValidations) return []; + const validations = Array.isArray(validationOrValidations) + ? validationOrValidations + : [validationOrValidations]; + + return validations.map((v) => v(values)); +} diff --git a/packages/common/src/utils/get.ts b/packages/common/src/utils/get.ts index 89a2398d..4b93a7fd 100644 --- a/packages/common/src/utils/get.ts +++ b/packages/common/src/utils/get.ts @@ -7,7 +7,7 @@ export function _get( obj: Data, path: string, defaultValue?: Default -): Default | FieldValue | FieldValue[] | undefined { +): Default | FieldValue | FieldValue[] { const travel = (regexp: RegExp) => String.prototype.split .call(path, regexp) diff --git a/packages/common/src/utils/getIndex.ts b/packages/common/src/utils/getIndex.ts deleted file mode 100644 index b155adbe..00000000 --- a/packages/common/src/utils/getIndex.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @ignore - */ -export function getIndex(el: HTMLElement) { - return el.hasAttribute('data-felte-index') - ? Number(el.dataset.felteIndex) - : undefined; -} diff --git a/packages/common/src/utils/getPath.ts b/packages/common/src/utils/getPath.ts index d1a8dc5c..1a8553a2 100644 --- a/packages/common/src/utils/getPath.ts +++ b/packages/common/src/utils/getPath.ts @@ -1,6 +1,5 @@ import type { FormControl } from '../types'; -import { isFieldSetElement, isFormControl } from './typeGuards'; -import { getIndex } from './getIndex'; +import { isFormControl } from './typeGuards'; /** * @category Helper @@ -9,24 +8,5 @@ export function getPath( el: HTMLElement | FormControl, name?: string | undefined ): string { - const index = getIndex(el); - let path = ''; - if (name) { - path = name; - } else if (isFormControl(el)) { - path = el.name; - } - path = typeof index === 'undefined' ? path : `${path}[${index}]`; - let parent = el.parentNode; - if (!parent) return path; - while (parent && parent.nodeName !== 'FORM') { - if (isFieldSetElement(parent) && parent.name) { - const index = getIndex(parent); - const fieldsetName = - typeof index === 'undefined' ? parent.name : `${parent.name}[${index}]`; - path = `${fieldsetName}.${path}`; - } - parent = parent.parentNode; - } - return path; + return name ?? (isFormControl(el) ? el.name : ''); } diff --git a/packages/common/src/utils/getPathFromDataset.ts b/packages/common/src/utils/getPathFromDataset.ts deleted file mode 100644 index 326d9a64..00000000 --- a/packages/common/src/utils/getPathFromDataset.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { FormControl } from '../types'; -import { getIndex } from './getIndex'; - -export function getPathFromDataset(el: FormControl) { - const fieldSetName = el.dataset.felteFieldset; - const index = getIndex(el); - const fieldName = - typeof index === 'undefined' ? el.name : `${el.name}[${index}]`; - return fieldSetName ? `${fieldSetName}.${fieldName}` : fieldName; -} diff --git a/packages/common/src/utils/getValue.ts b/packages/common/src/utils/getValue.ts new file mode 100644 index 00000000..230719b2 --- /dev/null +++ b/packages/common/src/utils/getValue.ts @@ -0,0 +1,27 @@ +import type { Paths, Traverse, Obj, FieldValue } from '../types'; +import { _isPlainObject } from './isPlainObject'; +import { _get } from './get'; + +export function getValue(storeValue: T): T; +export function getValue( + storeValue: T, + selector: (value: T) => R +): R; +export function getValue = Paths>( + storeValue: T, + path: P +): Traverse; +export function getValue( + storeValue: T, + selectorOrPath: string | ((value: any) => any) +): T; +export function getValue( + storeValue: T, + selectorOrPath?: ((value: T) => R) | string +): T | R | FieldValue | FieldValue[] | undefined { + if (!_isPlainObject(storeValue) || !selectorOrPath) return storeValue; + if (typeof selectorOrPath === 'string') { + return _get(storeValue, selectorOrPath); + } + return selectorOrPath(storeValue); +} diff --git a/packages/common/src/utils/isEqual.ts b/packages/common/src/utils/isEqual.ts new file mode 100644 index 00000000..a431b3c2 --- /dev/null +++ b/packages/common/src/utils/isEqual.ts @@ -0,0 +1,16 @@ +import { _isPlainObject } from './isPlainObject'; + +export function isEqual(val1: unknown, val2: unknown): boolean { + if (val1 === val2) return true; + if (Array.isArray(val1) && Array.isArray(val2)) { + if (val1.length !== val2.length) return false; + return val1.every((v, i) => isEqual(v, val2[i])); + } + if (_isPlainObject(val1) && _isPlainObject(val2)) { + const keys1 = Object.keys(val1); + const keys2 = Object.keys(val2); + if (keys1.length !== keys2.length) return false; + return keys1.every((k) => isEqual(val1[k], val2[k])); + } + return false; +} diff --git a/packages/common/src/utils/isPlainObject.ts b/packages/common/src/utils/isPlainObject.ts index 66986a04..655f7686 100644 --- a/packages/common/src/utils/isPlainObject.ts +++ b/packages/common/src/utils/isPlainObject.ts @@ -1,4 +1,6 @@ /** @ignore */ -export function _isPlainObject(value: unknown): boolean { +export function _isPlainObject( + value: unknown +): value is Record { return Object.prototype.toString.call(value) === '[object Object]'; } diff --git a/packages/common/src/utils/mergeWith.ts b/packages/common/src/utils/mergeWith.ts index 675694e3..21ca878c 100644 --- a/packages/common/src/utils/mergeWith.ts +++ b/packages/common/src/utils/mergeWith.ts @@ -10,9 +10,13 @@ export function _mergeWith(...args: any[]): T { if (args.length === 0) return obj; for (const source of args) { if (!source) continue; - const keys = Object.keys(source); + let rsValue = customizer(obj, source); + if (typeof rsValue !== 'undefined') return rsValue; + const keys = Array.from( + new Set(Object.keys(obj).concat(Object.keys(source))) + ); for (const key of keys) { - const rsValue = customizer(obj[key], source[key]); + rsValue = customizer(obj[key], source[key]); if (typeof rsValue !== 'undefined') { obj[key] = rsValue; } else if (_isPlainObject(source[key]) && _isPlainObject(obj[key])) { diff --git a/packages/common/src/utils/set.ts b/packages/common/src/utils/set.ts index 3d776a46..e1265bc2 100644 --- a/packages/common/src/utils/set.ts +++ b/packages/common/src/utils/set.ts @@ -1,36 +1,11 @@ -import type { Obj, FieldValue } from '../types'; -import { _cloneDeep } from './cloneDeep'; - -/* From: https://stackoverflow.com/a/54733755 */ +import type { Obj } from '../types'; +import { _update } from './update'; /** @ignore */ export function _set( obj: Data | undefined, path: string | string[], - value: FieldValue | FieldValue[] + value: any ) { - if (Object(obj) !== obj) obj = {} as Data; - // When obj is not an object - else if (typeof obj !== 'undefined') obj = _cloneDeep(obj); - // If not yet an array, get the keys from the string-path - let newPath = !Array.isArray(path) - ? path.toString().match(/[^.[\]]+/g) || [] - : path; - newPath.slice(0, -1).reduce( - ( - a: any, - c: any, - i: any // Iterate all of them except the last one - ) => - Object(a[c]) === a[c] // Does the key exist and is its value an object? - ? // Yes: then follow that path - a[c] - : // No: create the key. Is the next key a potential array-index? - (a[c] = - Math.abs(Number(newPath[i + 1])) >> 0 === +newPath[i + 1] - ? [] // Yes: assign a new array object - : {}), // No: assign a new plain object - obj - )[newPath[newPath.length - 1]] = value; // Finally assign the value to the last key - return obj as Data; // Return the top-level object to allow chaining + return _update(obj, path, () => value); } diff --git a/packages/common/src/utils/unset.ts b/packages/common/src/utils/unset.ts index 9c099c95..e904636a 100644 --- a/packages/common/src/utils/unset.ts +++ b/packages/common/src/utils/unset.ts @@ -1,5 +1,6 @@ import type { Obj } from '../types'; import { _cloneDeep } from './cloneDeep'; +import { _get } from './get'; /** @ignore */ export function _unset(obj: undefined, path: string | string[]): undefined; @@ -11,20 +12,19 @@ export function _unset( obj: Data | undefined, path: string | string[] ): Data | undefined { - if (Object(obj) !== obj) return; + if (!obj || Object(obj) !== obj) return; // When obj is not an object else if (typeof obj !== 'undefined') obj = _cloneDeep(obj); // If not yet an array, get the keys from the string-path - let newPath = !Array.isArray(path) + const newPath = !Array.isArray(path) ? path.toString().match(/[^.[\]]+/g) || [] : path; - delete newPath.slice(0, -1).reduce( - (a: any, c: any) => - Object(a[c]) === a[c] // Does the key exist and is its value an object? - ? // Yes: then follow that path - a[c] - : undefined, - obj - )?.[newPath[newPath.length - 1]]; - return obj as Data; // Return the top-level object to allow chaining + const foundProp: any = + newPath.length === 1 ? obj : _get(obj, newPath.slice(0, -1).join('.')); + if (Array.isArray(foundProp)) { + foundProp.splice(Number(newPath[newPath.length - 1]), 1); + } else { + delete foundProp?.[newPath[newPath.length - 1]]; + } + return obj as Data; } diff --git a/packages/common/src/utils/update.ts b/packages/common/src/utils/update.ts index 95281104..e29831f7 100644 --- a/packages/common/src/utils/update.ts +++ b/packages/common/src/utils/update.ts @@ -1,30 +1,34 @@ -import type { Obj, FieldValue } from '../types'; -import { _get } from './get'; +import type { Obj } from '../types'; +import { _cloneDeep } from './cloneDeep'; +import { _isPlainObject } from './isPlainObject'; /** @ignore */ -export function _update( +export function _update( obj: Data | undefined, - path: string, - updater: (value: Value) => Value + path: string | string[], + updater: (value: any) => any ) { - if (Object(obj) !== obj) obj = {} as Data; // When obj is not an object - // If not yet an array, get the keys from the string-path - let newPath = path.toString().match(/[^.[\]]+/g) || []; - newPath.slice(0, -1).reduce( - ( - a: any, - c: any, - i: any // Iterate all of them except the last one - ) => - Object(a[c]) === a[c] // Does the key exist and is its value an object? - ? // Yes: then follow that path - a[c] - : // No: create the key. Is the next key a potential array-index? - (a[c] = - Math.abs(Number(newPath[i + 1])) >> 0 === +newPath[i + 1] - ? [] // Yes: assign a new array object - : {}), // No: assign a new plain object - obj - )[newPath[newPath.length - 1]] = updater(_get(obj as Data, path) as Value); // Finally assign the value to the last key - return obj as Data; // Return the top-level object to allow chaining + if (obj) obj = _cloneDeep(obj); + if (!_isPlainObject(obj)) obj = {} as Data; + const splitPath = !Array.isArray(path) ? path.match(/[^.[\]]+/g) || [] : path; + const lastSection = splitPath[splitPath.length - 1]; + if (!lastSection) return obj; + let property: any = obj; + for (let i = 0; i < splitPath.length - 1; i++) { + const section = splitPath[i]; + if ( + !property[section] || + (!_isPlainObject(property[section]) && !Array.isArray(property[section])) + ) { + const nextSection = splitPath[i + 1]; + if (isNaN(Number(nextSection))) { + property[section] = {}; + } else { + property[section] = []; + } + } + property = property[section]; + } + property[lastSection] = updater(property[lastSection]); + return obj; } diff --git a/packages/common/tests/common.ts b/packages/common/tests/common.ts index ce8f1b10..75fdbeed 100644 --- a/packages/common/tests/common.ts +++ b/packages/common/tests/common.ts @@ -15,6 +15,7 @@ export type InputAttributes = { value?: string; checked?: boolean; id?: string; + index?: number; }; export function createInputElement(attrs: InputAttributes): HTMLInputElement { @@ -24,6 +25,8 @@ export function createInputElement(attrs: InputAttributes): HTMLInputElement { if (attrs.value) inputElement.value = attrs.value; if (attrs.checked) inputElement.checked = attrs.checked; if (attrs.id) inputElement.id = attrs.id; + if (typeof attrs.index !== 'undefined') + inputElement.name = `${attrs.name}.${attrs.index}`; inputElement.required = !!attrs.required; return inputElement; } diff --git a/packages/common/tests/utils.spec.ts b/packages/common/tests/utils.spec.ts new file mode 100644 index 00000000..3f8434c8 --- /dev/null +++ b/packages/common/tests/utils.spec.ts @@ -0,0 +1,1020 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import 'uvu-expect-dom/extend'; +import { screen } from '@testing-library/dom'; +import type { AssignableErrors } from '../src'; +import { + createInputElement, + createDOM, + cleanupDOM, + InputAttributes, +} from './common'; +import { + _some, + _mapValues, + _get, + _set, + _unset, + _update, + _isPlainObject, + _cloneDeep, + _mergeWith, + _merge, + _defaultsDeep, + deepSet, + deepSome, + isFieldSetElement, + isFieldValue, + isFormControl, + isElement, + getPath, + getFormControls, + addAttrsFromFieldset, + getFormDefaultValues, + setForm, + runValidations, + getInputTextOrNumber, + shouldIgnore, + executeTransforms, + getValue, + mergeErrors, + createId, + isEqual, +} from '../src'; + +function createLoginForm() { + const formElement = screen.getByRole('form') as HTMLFormElement; + const emailInput = createInputElement({ + name: 'account.email', + type: 'email', + }); + const passwordInput = createInputElement({ + name: 'account.password', + type: 'password', + }); + const submitInput = createInputElement({ type: 'submit' }); + const accountFieldset = document.createElement('fieldset'); + accountFieldset.append(emailInput, passwordInput); + formElement.append(accountFieldset, submitInput); + return { formElement, emailInput, passwordInput, submitInput }; +} + +function createMultipleInputElements(attr: InputAttributes, amount = 3) { + const inputs = []; + for (let i = 0; i < amount; i++) { + const input = createInputElement({ ...attr, index: i }); + inputs.push(input); + } + return inputs; +} + +function createSignupForm() { + const formElement = screen.getByRole('form') as HTMLFormElement; + const emailInput = createInputElement({ + name: 'account.email', + type: 'email', + }); + const passwordInput = createInputElement({ + name: 'account.password', + type: 'password', + }); + const showPasswordInput = createInputElement({ + name: 'account.showPassword', + type: 'checkbox', + }); + const confirmPasswordInput = createInputElement({ + name: 'account.confirmPassword', + type: 'password', + }); + const publicEmailYesRadio = createInputElement({ + name: 'account.publicEmail', + value: 'yes', + type: 'radio', + }); + const publicEmailNoRadio = createInputElement({ + name: 'account.publicEmail', + value: 'no', + type: 'radio', + }); + const accountFieldset = document.createElement('fieldset'); + accountFieldset.append( + emailInput, + passwordInput, + showPasswordInput, + publicEmailYesRadio, + publicEmailNoRadio, + confirmPasswordInput + ); + formElement.appendChild(accountFieldset); + const profileFieldset = document.createElement('fieldset'); + const firstNameInput = createInputElement({ name: 'profile.firstName' }); + const lastNameInput = createInputElement({ name: 'profile.lastName' }); + const bioInput = createInputElement({ name: 'profile.bio' }); + const ageInput = createInputElement({ name: 'profile.age', type: 'number' }); + profileFieldset.append(firstNameInput, lastNameInput, bioInput, ageInput); + formElement.appendChild(profileFieldset); + const pictureInput = createInputElement({ + name: 'profile.picture', + type: 'file', + }); + formElement.appendChild(pictureInput); + const extraPicsInput = createInputElement({ + name: 'extra.pictures', + type: 'file', + }); + extraPicsInput.multiple = true; + formElement.appendChild(extraPicsInput); + const submitInput = createInputElement({ type: 'submit' }); + const techCheckbox = createInputElement({ + type: 'checkbox', + name: 'preferences', + value: 'technology', + }); + const filmsCheckbox = createInputElement({ + type: 'checkbox', + name: 'preferences', + value: 'films', + }); + formElement.append(techCheckbox, filmsCheckbox, submitInput); + const multipleFieldsetElement = document.createElement('fieldset'); + const extraTextInputs = createMultipleInputElements({ + type: 'text', + name: 'multiple.extraText', + }); + const extraNumberInputs = createMultipleInputElements({ + type: 'number', + name: 'multiple.extraNumber', + }); + const extraFileInputs = createMultipleInputElements({ + type: 'file', + name: 'multiple.extraFiles', + }); + const extraCheckboxes = createMultipleInputElements({ + type: 'checkbox', + name: 'multiple.extraCheckbox', + }); + const extraPreferences1 = createMultipleInputElements({ + type: 'checkbox', + name: 'multiple.extraPreference', + value: 'preference1', + }); + const extraPreferences2 = createMultipleInputElements({ + type: 'checkbox', + name: 'multiple.extraPreference', + value: 'preference2', + }); + multipleFieldsetElement.append( + ...extraTextInputs, + ...extraNumberInputs, + ...extraFileInputs, + ...extraCheckboxes, + ...extraPreferences1, + ...extraPreferences2 + ); + const fieldsets = [0, 1, 2].map((index) => { + const input = createInputElement({ name: `fieldsets.${index}.otherText` }); + const fieldset = document.createElement('fieldset'); + fieldset.appendChild(input); + return fieldset; + }); + formElement.appendChild(multipleFieldsetElement); + formElement.append(...fieldsets); + + return { + formElement, + emailInput, + passwordInput, + confirmPasswordInput, + showPasswordInput, + publicEmailYesRadio, + publicEmailNoRadio, + firstNameInput, + lastNameInput, + bioInput, + ageInput, + pictureInput, + extraPicsInput, + techCheckbox, + filmsCheckbox, + submitInput, + extraTextInputs, + extraNumberInputs, + extraFileInputs, + extraCheckboxes, + extraPreferences1, + extraPreferences2, + }; +} + +const Utils = suite('Utils'); + +Utils.before.each(createDOM); +Utils.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +Utils('_some', () => { + const testObj = { + username: 'test', + password: '', + }; + const truthyResult = _some( + testObj, + (value) => typeof value === 'string' && value === 'test' + ); + expect(truthyResult).to.be.true; + + const falsyResult = _some( + testObj, + (value) => typeof value === 'string' && value === 'not in object' + ); + expect(falsyResult).to.be.false; +}); + +Utils('_mapValues', () => { + const testObj = { + username: 'test', + password: '', + }; + const mapped = _mapValues(testObj, (value) => !!value); + expect(mapped).to.deep.equal({ + username: true, + password: false, + }); +}); + +Utils('_get', () => { + const testObj = { + account: { + username: 'test', + password: '', + }, + }; + expect(_get(testObj, 'account.username')).to.equal('test'); + expect(_get(testObj, 'account.nonExistent')).to.equal(undefined); + expect(_get(testObj, 'account.nonExistent', 'default')).to.equal('default'); + expect(_get(testObj, 'account.deep.nonExistent', 'default')).to.equal( + 'default' + ); + expect(_get(testObj, 'account')).to.deep.equal({ + username: 'test', + password: '', + }); +}); + +Utils('_set', () => { + const testObj: any = { + account: { + username: 'test', + password: '', + }, + }; + + expect( + _set(testObj, 'account.password', 'password').account.password + ).to.equal('password'); + expect(_set(testObj, 'account.toExist', 'value').account.toExist).to.equal( + 'value' + ); + expect( + _set(testObj, ['account', 'toExist'], 'otherValue').account.toExist + ).to.equal('otherValue'); + expect( + _set(undefined as any, 'account[0].toExist', 'value').account[0].toExist + ).to.equal('value'); +}); + +Utils('_unset', () => { + const testObj: any = { + account: { + username: 'test', + password: '', + confirm: '', + preferences: ['tech', 'sports', 'fashion'], + }, + }; + + expect(_unset(testObj, 'account.password').account.password).to.equal( + undefined + ); + expect(_unset(testObj, 'account.noExist').account.noExist).to.equal( + undefined + ); + expect(_unset(testObj, ['account', 'confirm']).account.confirm).to.equal( + undefined + ); + expect( + _unset(testObj, 'account.preferences[1]').account.preferences + ).to.deep.equal(['tech', 'fashion']); + expect(_unset(undefined, 'account.noExist')).to.equal(undefined); + expect(_unset({}, 'account.noExist')).to.deep.equal({}); + expect(_unset(testObj, '')).to.deep.equal(testObj); +}); + +Utils('_update', () => { + const testObj: any = { + account: { + username: 'test', + password: '', + }, + }; + expect( + _update(testObj, 'account.password', () => 'password').account.password + ).to.equal('password'); + expect( + _update(testObj, ['account', 'toExist'], () => 'value').account.toExist + ).to.equal('value'); + expect( + _update(undefined as any, 'account[0].toExist', () => 'value').account[0] + .toExist + ).to.equal('value'); + expect(_update({}, 'account[0][0]', () => 'value')).to.deep.include({ + account: [['value']], + }); + expect(_update({}, '', () => 'value')).to.deep.equal({}); +}); + +Utils('_isPlainObject', () => { + expect(_isPlainObject({})).to.be.true; + expect(_isPlainObject('')).to.be.false; + expect(_isPlainObject(() => undefined)).to.be.false; + expect(_isPlainObject(1)).to.be.false; + expect(_isPlainObject(true)).to.be.false; + expect(_isPlainObject(new File([], 'test'))).to.be.false; + expect(_isPlainObject([])).to.be.false; +}); + +Utils('deepSet', () => { + const testObj = { + account: { + username: 'test', + password: '', + preferences: ['tech', 'film'], + friends: [ + { + name: 'name1', + key: 'key1', + }, + { + name: 'name2', + }, + ], + }, + }; + + expect(deepSet(testObj, true)).to.deep.equal({ + account: { + username: true, + password: true, + preferences: [true, true], + friends: [ + { + name: true, + }, + { + name: true, + }, + ], + }, + }); +}); + +Utils('deepSome', () => { + const testObj = { + account: { + username: 'test', + password: '', + strings: ['string1'], + friends: [ + { + name: 'name1', + }, + ], + }, + }; + const truthyResult = deepSome( + testObj, + (value) => typeof value === 'string' && value === 'test' + ); + expect(truthyResult).to.be.true; + + const falsyResult = deepSome( + testObj, + (value) => typeof value === 'string' && value === 'not in object' + ); + expect(falsyResult).to.be.false; + + expect(deepSome(testObj, (value) => value === 'name1')).to.be.true; + expect( + deepSome(testObj, (value) => Array.isArray(value) && value.length === 1) + ).to.be.true; + expect(deepSome({ test: { value: [true] } }, (v) => !!v)).to.be.true; +}); + +Utils('isFieldSetElement', () => { + expect(isFieldSetElement(document.createElement('fieldset'))).to.be.true; + expect(isFieldSetElement(document.createElement('input'))).to.be.false; + expect(isFieldSetElement(undefined as any)).to.be.false; +}); + +Utils('isFormControl', () => { + expect(isFormControl(document.createElement('fieldset'))).to.be.false; + expect(isFormControl(document.createElement('input'))).to.be.true; + expect(isFormControl(document.createElement('textarea'))).to.be.true; + expect(isFormControl(document.createElement('select'))).to.be.true; + expect(isFormControl(undefined as any)).to.be.false; +}); + +Utils('isElement', () => { + expect(isElement(document.createTextNode(''))).to.be.false; + expect(isElement(document.createElement('input'))).to.be.true; + expect(isElement(document.createElement('textarea'))).to.be.true; + expect(isElement(document.createElement('select'))).to.be.true; +}); + +Utils('getPath', () => { + const inputElement = document.createElement('input'); + inputElement.name = 'container.test'; + expect(getPath(inputElement)).to.equal('container.test'); + expect(getPath(inputElement, 'container.overriden')).to.equal( + 'container.overriden' + ); + expect(getPath(document.createElement('div'))).to.equal(''); +}); + +Utils('getFormControls', () => { + const { formElement } = createLoginForm(); + expect(formElement).to.not.be.focused; + expect(getFormControls(formElement)).to.have.lengthOf(3); +}); + +Utils('addAttrsFromFieldset', () => { + const fieldset = document.createElement('fieldset'); + fieldset.name = 'container'; + const fieldsetUnset = document.createElement('fieldset'); + fieldsetUnset.name = 'containerUnset'; + fieldsetUnset.setAttribute('data-felte-keep-on-remove', 'false'); + const inputElement = createInputElement({ name: 'test' }); + fieldset.appendChild(inputElement); + const inputUnsetElement = createInputElement({ name: 'test' }); + fieldsetUnset.appendChild(inputUnsetElement); + + addAttrsFromFieldset(fieldset); + + addAttrsFromFieldset(fieldsetUnset); + expect(inputUnsetElement) + .to.have.attribute('data-felte-keep-on-remove') + .that.equals('false'); +}); + +Utils('getFormDefaultValues', () => { + const { formElement } = createSignupForm(); + + const { defaultData } = getFormDefaultValues(formElement); + expect(defaultData).to.deep.include({ + account: { + email: '', + password: '', + confirmPassword: '', + showPassword: false, + publicEmail: undefined, + }, + profile: { + firstName: '', + lastName: '', + bio: '', + picture: undefined, + age: undefined, + }, + extra: { + pictures: [], + }, + preferences: [], + multiple: { + extraText: ['', '', ''], + extraNumber: [undefined, undefined, undefined], + extraFiles: [undefined, undefined, undefined], + extraCheckbox: [false, false, false], + extraPreference: [[], [], []], + }, + fieldsets: [{ otherText: '' }, { otherText: '' }, { otherText: '' }], + }); +}); + +Utils('setForm', () => { + const formData = { + account: { + email: 'jacek@soplica.com', + password: 'password', + confirmPassword: 'password', + showPassword: true, + publicEmail: 'yes', + }, + profile: { + firstName: 'Jacek', + lastName: 'Soplica', + bio: 'bio', + picture: undefined, + age: 0, + }, + extra: { + pictures: [], + }, + preferences: ['technology', 'films'], + multiple: { + extraText: ['text1', 'text2', 'text3'], + extraNumber: [1, 2, 3], + extraFiles: [undefined, undefined, undefined], + extraCheckbox: [true, false, true], + extraPreference: [['preference1'], ['preference1', 'preference2'], []], + }, + fieldsets: [ + { otherText: 'text' }, + { otherText: 'other' }, + { otherText: 'more' }, + ], + }; + const { formElement } = createSignupForm(); + + setForm(formElement, formData); + const { defaultData } = getFormDefaultValues(formElement); + expect(defaultData).to.deep.equal(formData); +}); + +Utils('runValidations', async () => { + const mockValues = { + account: { + email: '', + password: '', + confirmPassword: '', + }, + }; + type Error = AssignableErrors; + const mockErrors: Error = { + account: { + email: 'required', + password: null, + confirmPassword: undefined, + }, + }; + const validate = sinon + .stub() + .onSecondCall() + .returns(mockErrors) + .onThirdCall() + .returns(mockErrors); + + validate.onFirstCall().returns({ + account: { + email: 'not an email', + password: 'required', + confirmPassword: 'required', + }, + }); + + const errors = await Promise.all( + runValidations(mockValues, [validate, validate]) + ); + + expect(mergeErrors([deepSet(mockValues, []), ...errors])).to.deep.equal({ + account: { + email: ['not an email', 'required'], + password: ['required'], + confirmPassword: ['required'], + }, + }); + expect(runValidations(mockValues, validate)[0]).to.deep.equal(mockErrors); +}); + +Utils('executeTransforms', () => { + const mockValues: any = { + progress: { + percentage: 0.42, + }, + }; + const transformToBase100 = (values: any) => ({ + progress: { + percentage: values.progress.percentage * 100, + }, + }); + const transformToString = (values: any) => ({ + progress: { + percentage: String(values.progress.percentage.toFixed(0)) + '%', + }, + }); + expect(executeTransforms(mockValues)).to.equal(mockValues); + expect(executeTransforms(mockValues, transformToBase100)).to.deep.equal({ + progress: { percentage: 42 }, + }); + expect( + executeTransforms(mockValues, [ + transformToBase100, + transformToString, + ] as any[]) + ).to.deep.equal({ + progress: { + percentage: '42%', + }, + }); +}); + +Utils('getInputTextOrNumber', () => { + const textElement = createInputElement({ + name: 'text', + value: '', + }); + const numberElement = createInputElement({ + name: 'number', + type: 'number', + }); + + expect(getInputTextOrNumber(textElement)).to.equal(''); + expect(getInputTextOrNumber(numberElement)).to.equal(undefined); + + textElement.value = 'test'; + numberElement.value = 'test'; + expect(getInputTextOrNumber(textElement)).to.equal('test'); + expect(getInputTextOrNumber(numberElement)).to.equal(undefined); + + numberElement.value = '42'; + expect(getInputTextOrNumber(numberElement)).to.equal(42); +}); + +Utils('isFieldValue', () => { + expect(isFieldValue([])).to.equal(true); + expect(isFieldValue(['test'])).to.equal(true); + expect(isFieldValue([new File([], 'test')])).to.equal(true); + expect(isFieldValue('test')).to.equal(true); + expect(isFieldValue(2)).to.equal(true); + expect(isFieldValue(false)).to.equal(true); + expect(isFieldValue(new File([], 'test'))).to.equal(true); +}); + +Utils('_cloneDeep', () => { + const obj = { + account: { + email: 'test', + password: 'password', + }, + }; + expect(_cloneDeep(obj)).to.deep.equal({ + account: { + email: 'test', + password: 'password', + }, + }); + expect(_cloneDeep(obj)).not.to.equal(obj); + expect(_cloneDeep(obj).account).not.to.equal(obj.account); +}); + +Utils('_merge', () => { + const obj = { + account: { + email: 'test', + password: 'password', + leftAlone: 'original', + }, + leftAlone: 'original', + objectArray: [{ value: 'test' }, { value: 'test' }], + stringArray: ['test', 'test2'], + }; + const source1 = { + account: { + email: 'overriden', + password: { + type: 'overriden', + value: 'password', + }, + }, + added: 'value', + objectArray: [{ otherValue: 'test' }, { otherValue: 'test' }], + stringArray: ['test3', 'test4'], + }; + expect(_merge({}, source1)).to.deep.equal(source1); + expect(_merge(obj, source1)).to.deep.equal({ + account: { + email: 'overriden', + password: { + type: 'overriden', + value: 'password', + }, + leftAlone: 'original', + }, + added: 'value', + leftAlone: 'original', + objectArray: [ + { otherValue: 'test', value: 'test' }, + { otherValue: 'test', value: 'test' }, + ], + stringArray: ['test3', 'test4'], + }); + expect(_merge({}, obj, source1)).to.deep.equal({ + account: { + email: 'overriden', + password: { + type: 'overriden', + value: 'password', + }, + leftAlone: 'original', + }, + added: 'value', + leftAlone: 'original', + objectArray: [ + { otherValue: 'test', value: 'test' }, + { otherValue: 'test', value: 'test' }, + ], + stringArray: ['test3', 'test4'], + }); + expect(obj).to.deep.equal({ + account: { + email: 'test', + password: 'password', + leftAlone: 'original', + }, + leftAlone: 'original', + objectArray: [{ value: 'test' }, { value: 'test' }], + stringArray: ['test', 'test2'], + }); + expect(source1).to.deep.equal({ + account: { + email: 'overriden', + password: { + type: 'overriden', + value: 'password', + }, + }, + added: 'value', + objectArray: [{ otherValue: 'test' }, { otherValue: 'test' }], + stringArray: ['test3', 'test4'], + }); +}); + +Utils('_mergeWith', () => { + const touched = { + account: { + email: true, + password: false, + }, + email: true, + password: false, + }; + const errors = { + account: { + email: 'Not valid', + password: 'Not valid', + }, + email: 'Not valid', + password: 'Not valid', + }; + function errorFilterer(errValue?: string, touchValue?: boolean) { + if (_isPlainObject(touchValue)) return; + return (touchValue && errValue) || null; + } + expect(_mergeWith(errors, touched, errorFilterer)).to.deep.equal({ + account: { + email: 'Not valid', + password: null, + }, + email: 'Not valid', + password: null, + }); + expect(_mergeWith({}, touched, errorFilterer)).to.deep.equal({ + account: { + email: null, + password: null, + }, + email: null, + password: null, + }); + expect(_mergeWith(null, null, errorFilterer)).to.deep.equal({}); + expect(_mergeWith('test')).to.deep.equal({}); + expect(_mergeWith({}, {}, () => 'test')).to.equal('test'); +}); + +Utils('_defaultsDeep', () => { + const obj = { + account: { + email: 'test', + password: 'password', + leftAlone: 'original', + }, + leftAlone: 'original', + preferences: [null, 'leftAlone'], + friends: [], + }; + const source1 = { + account: { + email: 'overriden', + password: { + type: 'overriden', + value: 'password', + }, + added: 'value', + addedObj: { + prop: 'test', + }, + }, + added: 'value', + preferences: ['added', 'ignored', 'added'], + friends: [{ name: 'name' }], + other: [], + }; + expect(_defaultsDeep(obj, source1)).to.deep.equal({ + account: { + email: 'test', + password: 'password', + leftAlone: 'original', + added: 'value', + addedObj: { + prop: 'test', + }, + }, + added: 'value', + leftAlone: 'original', + preferences: ['added', 'leftAlone', 'added'], + friends: [{ name: 'name' }], + other: [], + }); +}); + +Utils('shouldIgnore', () => { + const ignoredInput = createInputElement({ type: 'text', name: 'test' }); + ignoredInput.setAttribute('data-felte-ignore', ''); + expect(shouldIgnore(ignoredInput)).to.equal(true); + const input = createInputElement({ type: 'text', name: 'test' }); + const container = document.createElement('div'); + container.appendChild(input); + expect(shouldIgnore(input)).to.equal(false); + container.setAttribute('data-felte-ignore', ''); + expect(shouldIgnore(input)).to.equal(true); +}); + +Utils('getValue', () => { + const data = { + account: { + email: 'zaphod@beeblebrox.com', + }, + }; + expect(getValue(data, 'account.email')).to.equal('zaphod@beeblebrox.com'); + expect(getValue(data, ($data) => $data.account.email)).to.equal( + 'zaphod@beeblebrox.com' + ); + expect(getValue('test')).to.equal('test'); +}); + +Utils('mergeErrors', () => { + const empty = { + account: { + array: [ + { + value: [], + }, + { + value: [], + }, + { + value: [], + }, + ], + strings: [], + }, + }; + const data: any = { + account: { + array: [ + undefined, + { + value: 'test', + }, + { + value: ['test in array'], + }, + ], + strings: [undefined, 'test', 'test in array'], + }, + }; + const other: any = { + account: { + array: [ + undefined, + { + value: 'another', + }, + null, + ], + strings: 'another', + }, + }; + expect(mergeErrors([empty, data, other])).to.deep.equal({ + account: { + array: [ + { + value: [], + }, + { + value: ['test', 'another'], + }, + { + value: ['test in array'], + }, + ], + strings: ['test', 'test in array', 'another'], + }, + }); + expect( + mergeErrors([ + { + test: 'error', + }, + { + test: 'another', + }, + ]) + ).to.deep.equal({ + test: ['error', 'another'], + }); +}); + +Utils('createId', () => { + expect(createId()).to.have.lengthOf(8); + expect(createId(21)).to.have.lengthOf(21); +}); + +Utils('isEqual', () => { + expect(isEqual(1, 1)).to.equal(true); + expect(isEqual(1, 2)).to.equal(false); + expect(isEqual('1', '1')).to.equal(true); + expect(isEqual([1, 2, 3], [1, 2, 3])).to.equal(true); + expect(isEqual([1, 2, 3], [1, 3, 2])).to.equal(false); + expect(isEqual([1, 2, 3], [1, 2, 3, 4])).to.equal(false); + expect(isEqual([1, 2, 3], { name: 'test' })).to.equal(false); + expect( + isEqual( + { + account: { + name: 'test', + likes: 1, + friends: [ + { + name: 'friend1', + }, + { + name: 'friend2', + }, + ], + }, + }, + { + account: { + friends: [ + { + name: 'friend1', + }, + { + name: 'friend2', + }, + ], + likes: 1, + name: 'test', + }, + } + ) + ).to.equal(true); + expect( + isEqual( + { + account: { + name: 'test', + likes: 1, + friends: [ + { + name: 'friend1', + }, + { + name: 'friend2', + }, + ], + }, + }, + { + account: { + friends: [ + { + name: 'friend1', + }, + { + name: 'friend2', + }, + ], + preferences: [], + likes: 1, + name: 'test', + }, + } + ) + ).to.equal(false); +}); + +Utils.run(); diff --git a/packages/common/tests/utils.test.ts b/packages/common/tests/utils.test.ts deleted file mode 100644 index 2f9458bb..00000000 --- a/packages/common/tests/utils.test.ts +++ /dev/null @@ -1,823 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen } from '@testing-library/dom'; -import { - createInputElement, - createDOM, - cleanupDOM, - InputAttributes, -} from './common'; -import { - _some, - _mapValues, - _get, - _set, - _unset, - _update, - _isPlainObject, - _cloneDeep, - _mergeWith, - _merge, - _defaultsDeep, - deepSet, - deepSome, - isFieldSetElement, - isFieldValue, - isFormControl, - isElement, - getPath, - getFormControls, - addAttrsFromFieldset, - getFormDefaultValues, - setForm, - executeValidation, - getInputTextOrNumber, - getIndex, - getPathFromDataset, - shouldIgnore, - executeTransforms, -} from '../src'; - -function createLoginForm() { - const formElement = screen.getByRole('form') as HTMLFormElement; - const emailInput = createInputElement({ name: 'email', type: 'email' }); - const passwordInput = createInputElement({ - name: 'password', - type: 'password', - }); - const submitInput = createInputElement({ type: 'submit' }); - const accountFieldset = document.createElement('fieldset'); - accountFieldset.name = 'account'; - accountFieldset.append(emailInput, passwordInput); - formElement.append(accountFieldset, submitInput); - return { formElement, emailInput, passwordInput, submitInput }; -} - -function createMultipleInputElements(attr: InputAttributes, amount = 3) { - const inputs = []; - for (let i = 0; i < amount; i++) { - const input = createInputElement(attr); - input.dataset.felteIndex = String(i); - inputs.push(input); - } - return inputs; -} - -function createSignupForm() { - const formElement = screen.getByRole('form') as HTMLFormElement; - const emailInput = createInputElement({ name: 'email', type: 'email' }); - const passwordInput = createInputElement({ - name: 'password', - type: 'password', - }); - const showPasswordInput = createInputElement({ - name: 'showPassword', - type: 'checkbox', - }); - const confirmPasswordInput = createInputElement({ - name: 'confirmPassword', - type: 'password', - }); - const publicEmailYesRadio = createInputElement({ - name: 'publicEmail', - value: 'yes', - type: 'radio', - }); - const publicEmailNoRadio = createInputElement({ - name: 'publicEmail', - value: 'no', - type: 'radio', - }); - const accountFieldset = document.createElement('fieldset'); - accountFieldset.name = 'account'; - accountFieldset.append( - emailInput, - passwordInput, - showPasswordInput, - publicEmailYesRadio, - publicEmailNoRadio, - confirmPasswordInput - ); - formElement.appendChild(accountFieldset); - const profileFieldset = document.createElement('fieldset'); - profileFieldset.name = 'profile'; - const firstNameInput = createInputElement({ name: 'firstName' }); - const lastNameInput = createInputElement({ name: 'lastName' }); - const bioInput = createInputElement({ name: 'bio' }); - const ageInput = createInputElement({ name: 'age', type: 'number' }); - profileFieldset.append(firstNameInput, lastNameInput, bioInput, ageInput); - formElement.appendChild(profileFieldset); - const pictureInput = createInputElement({ - name: 'profile.picture', - type: 'file', - }); - formElement.appendChild(pictureInput); - const extraPicsInput = createInputElement({ - name: 'extra.pictures', - type: 'file', - }); - extraPicsInput.multiple = true; - formElement.appendChild(extraPicsInput); - const submitInput = createInputElement({ type: 'submit' }); - const techCheckbox = createInputElement({ - type: 'checkbox', - name: 'preferences', - value: 'technology', - }); - const filmsCheckbox = createInputElement({ - type: 'checkbox', - name: 'preferences', - value: 'films', - }); - formElement.append(techCheckbox, filmsCheckbox, submitInput); - const multipleFieldsetElement = document.createElement('fieldset'); - multipleFieldsetElement.name = 'multiple'; - const extraTextInputs = createMultipleInputElements({ - type: 'text', - name: 'extraText', - }); - const extraNumberInputs = createMultipleInputElements({ - type: 'number', - name: 'extraNumber', - }); - const extraFileInputs = createMultipleInputElements({ - type: 'file', - name: 'extraFiles', - }); - const extraCheckboxes = createMultipleInputElements({ - type: 'checkbox', - name: 'extraCheckbox', - }); - const extraPreferences1 = createMultipleInputElements({ - type: 'checkbox', - name: 'extraPreference', - value: 'preference1', - }); - const extraPreferences2 = createMultipleInputElements({ - type: 'checkbox', - name: 'extraPreference', - value: 'preference2', - }); - multipleFieldsetElement.append( - ...extraTextInputs, - ...extraNumberInputs, - ...extraFileInputs, - ...extraCheckboxes, - ...extraPreferences1, - ...extraPreferences2 - ); - const fieldsets = [0, 1, 2].map((index) => { - const input = createInputElement({ name: 'otherText' }); - const fieldset = document.createElement('fieldset'); - fieldset.name = 'fieldsets'; - fieldset.dataset.felteIndex = String(index); - fieldset.appendChild(input); - return fieldset; - }); - formElement.appendChild(multipleFieldsetElement); - formElement.append(...fieldsets); - - return { - formElement, - emailInput, - passwordInput, - confirmPasswordInput, - showPasswordInput, - publicEmailYesRadio, - publicEmailNoRadio, - firstNameInput, - lastNameInput, - bioInput, - ageInput, - pictureInput, - extraPicsInput, - techCheckbox, - filmsCheckbox, - submitInput, - extraTextInputs, - extraNumberInputs, - extraFileInputs, - extraCheckboxes, - extraPreferences1, - extraPreferences2, - }; -} - -describe('Utils', () => { - test('_some', () => { - const testObj = { - username: 'test', - password: '', - }; - const truthyResult = _some( - testObj, - (value) => typeof value === 'string' && value === 'test' - ); - expect(truthyResult).toBeTruthy(); - - const falsyResult = _some( - testObj, - (value) => typeof value === 'string' && value === 'not in object' - ); - expect(falsyResult).toBeFalsy(); - }); - - test('_mapValues', () => { - const testObj = { - username: 'test', - password: '', - }; - const mapped = _mapValues(testObj, (value) => !!value); - expect(mapped).toEqual({ - username: true, - password: false, - }); - }); - - test('_get', () => { - const testObj = { - account: { - username: 'test', - password: '', - }, - }; - - expect(_get(testObj, 'account.username')).toBe('test'); - expect(_get(testObj, 'account.nonExistent')).toBe(undefined); - expect(_get(testObj, 'account.nonExistent', 'default')).toBe('default'); - expect(_get(testObj, 'account.deep.nonExistent', 'default')).toBe( - 'default' - ); - expect(_get(testObj, 'account')).toEqual({ - username: 'test', - password: '', - }); - }); - - test('_set', () => { - const testObj: any = { - account: { - username: 'test', - password: '', - }, - }; - - expect(_set(testObj, 'account.password', 'password').account.password).toBe( - 'password' - ); - expect(_set(testObj, 'account.toExist', 'value').account.toExist).toBe( - 'value' - ); - expect( - _set(testObj, ['account', 'toExist'], 'otherValue').account.toExist - ).toBe('otherValue'); - expect( - _set(undefined as any, 'account[0].toExist', 'value').account[0].toExist - ).toBe('value'); - }); - - test('_unset', () => { - const testObj: any = { - account: { - username: 'test', - password: '', - confirm: '', - preferences: ['tech'], - }, - }; - - expect(_unset(testObj, 'account.password').account.password).toBe( - undefined - ); - expect(_unset(testObj, 'account.noExist').account.noExist).toBe(undefined); - expect(_unset(testObj, ['account', 'confirm']).account.confirm).toBe( - undefined - ); - expect(_unset(undefined, 'account.noExist')).toBe(undefined); - expect(_unset({}, 'account.noExist')).toEqual({}); - }); - - test('_update', () => { - const testObj: any = { - account: { - username: 'test', - password: '', - }, - }; - expect( - _update(testObj, 'account.password', () => 'password').account.password - ).toBe('password'); - expect( - _update(testObj, 'account.toExist', () => 'value').account.toExist - ).toBe('value'); - expect( - _update(undefined as any, 'account[0].toExist', () => 'value').account[0] - .toExist - ).toBe('value'); - }); - - test('_isPlainObject', () => { - expect(_isPlainObject({})).toBeTruthy(); - expect(_isPlainObject('')).toBeFalsy(); - expect(_isPlainObject(() => undefined)).toBeFalsy(); - expect(_isPlainObject(1)).toBeFalsy(); - expect(_isPlainObject(true)).toBeFalsy(); - expect(_isPlainObject(new File([], 'test'))).toBeFalsy(); - expect(_isPlainObject([])).toBeFalsy(); - }); - - test('deepSet', () => { - const testObj = { - account: { - username: 'test', - password: '', - preferences: ['tech', 'film'], - }, - }; - - expect(deepSet(testObj, true)).toEqual({ - account: { - username: true, - password: true, - preferences: [true, true], - }, - }); - }); - - test('deepSome', () => { - const testObj = { - account: { - username: 'test', - password: '', - }, - }; - const truthyResult = deepSome( - testObj, - (value) => typeof value === 'string' && value === 'test' - ); - expect(truthyResult).toBeTruthy(); - - const falsyResult = deepSome( - testObj, - (value) => typeof value === 'string' && value === 'not in object' - ); - expect(falsyResult).toBeFalsy(); - }); - - test('isFieldSetElement', () => { - expect(isFieldSetElement(document.createElement('fieldset'))).toBeTruthy(); - expect(isFieldSetElement(document.createElement('input'))).toBeFalsy(); - }); - - test('isFormControl', () => { - expect(isFormControl(document.createElement('fieldset'))).toBeFalsy(); - expect(isFormControl(document.createElement('input'))).toBeTruthy(); - expect(isFormControl(document.createElement('textarea'))).toBeTruthy(); - expect(isFormControl(document.createElement('select'))).toBeTruthy(); - }); - - test('isElement', () => { - expect(isElement(document.createTextNode(''))).toBeFalsy(); - expect(isElement(document.createElement('input'))).toBeTruthy(); - expect(isElement(document.createElement('textarea'))).toBeTruthy(); - expect(isElement(document.createElement('select'))).toBeTruthy(); - }); - - test('getPath', () => { - const inputElement = document.createElement('input'); - inputElement.name = 'test'; - expect(getPath(inputElement)).toBe('test'); - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'container'; - fieldsetElement.appendChild(inputElement); - expect(getPath(inputElement)).toBe('container.test'); - inputElement.setAttribute('data-felte-index', '1'); - expect(getPath(inputElement)).toBe('container.test[1]'); - expect(getPath(inputElement, 'overriden')).toBe('container.overriden[1]'); - }); - - test('getPathFromDataset', () => { - const inputElement = document.createElement('input'); - inputElement.name = 'test'; - expect(getPathFromDataset(inputElement)).toBe('test'); - inputElement.dataset.felteFieldset = 'container'; - expect(getPathFromDataset(inputElement)).toBe('container.test'); - inputElement.setAttribute('data-felte-index', '1'); - expect(getPathFromDataset(inputElement)).toBe('container.test[1]'); - }); - - test('getFormControls', () => { - createDOM(); - const { formElement } = createLoginForm(); - expect(getFormControls(formElement)).toHaveLength(3); - cleanupDOM(); - }); - - test('addAttrsFromFieldset', () => { - const fieldset = document.createElement('fieldset'); - fieldset.name = 'container'; - const fieldsetUnset = document.createElement('fieldset'); - fieldsetUnset.name = 'containerUnset'; - fieldsetUnset.setAttribute('data-felte-unset-on-remove', 'true'); - const inputElement = createInputElement({ name: 'test' }); - fieldset.appendChild(inputElement); - const inputUnsetElement = createInputElement({ name: 'test' }); - fieldsetUnset.appendChild(inputUnsetElement); - - addAttrsFromFieldset(fieldset); - expect(inputElement).toHaveAttribute('data-felte-fieldset', 'container'); - - addAttrsFromFieldset(fieldsetUnset); - expect(inputUnsetElement).toHaveAttribute( - 'data-felte-fieldset', - 'containerUnset' - ); - expect(inputUnsetElement).toHaveAttribute( - 'data-felte-unset-on-remove', - 'true' - ); - }); - - test('getFormDefaultValues', () => { - createDOM(); - const { formElement } = createSignupForm(); - - const { defaultData } = getFormDefaultValues(formElement); - expect(defaultData).toEqual( - expect.objectContaining({ - account: { - email: '', - password: '', - confirmPassword: '', - showPassword: false, - publicEmail: undefined, - }, - profile: { - firstName: '', - lastName: '', - bio: '', - picture: undefined, - }, - extra: { - pictures: expect.arrayContaining([]), - }, - preferences: expect.arrayContaining([]), - multiple: { - extraText: expect.arrayContaining(['', '', '']), - extraNumber: expect.arrayContaining([ - undefined, - undefined, - undefined, - ]), - extraFiles: expect.arrayContaining([undefined, undefined, undefined]), - extraCheckbox: expect.arrayContaining([false, false, false]), - extraPreference: expect.arrayContaining([[], [], []]), - }, - fieldsets: expect.arrayContaining([ - { otherText: '' }, - { otherText: '' }, - { otherText: '' }, - ]), - }) - ); - cleanupDOM(); - }); - - test('setForm', () => { - createDOM(); - const formData = { - account: { - email: 'jacek@soplica.com', - password: 'password', - confirmPassword: 'password', - showPassword: true, - publicEmail: 'yes', - }, - profile: { - firstName: 'Jacek', - lastName: 'Soplica', - bio: 'bio', - picture: undefined, - age: 0, - }, - extra: { - pictures: [], - }, - preferences: ['technology', 'films'], - multiple: { - extraText: ['text1', 'text2', 'text3'], - extraNumber: [1, 2, 3], - extraFiles: [undefined, undefined, undefined], - extraCheckbox: [true, false, true], - extraPreference: [['preference1'], ['preference1', 'preference2'], []], - }, - fieldsets: [ - { otherText: 'text' }, - { otherText: 'other' }, - { otherText: 'more' }, - ], - }; - const { formElement } = createSignupForm(); - - setForm(formElement, formData); - const { defaultData } = getFormDefaultValues(formElement); - expect(defaultData).toEqual(formData); - cleanupDOM(); - }); - - test('executeValidation', async () => { - const mockValues = { - account: { - email: '', - }, - }; - const validate = jest.fn( - () => - ({ - account: { - email: 'required', - password: null, - confirmPassword: undefined, - }, - } as any) - ); - - validate.mockReturnValueOnce({ - account: { - email: 'not an email', - password: 'required', - confirmPassword: 'required', - }, - }); - - const errors = await executeValidation(mockValues, [validate, validate]); - - expect(errors).toEqual({ - account: { - email: ['not an email', 'required'], - password: 'required', - confirmPassword: 'required', - }, - }); - }); - - test('executeTransforms', () => { - const mockValues: any = { - progress: { - percentage: 0.42, - }, - }; - const transformToBase100 = (values: any) => ({ - progress: { - percentage: values.progress.percentage * 100, - }, - }); - const transformToString = (values: any) => ({ - progress: { - percentage: String(values.progress.percentage.toFixed(0)) + '%', - }, - }); - expect( - executeTransforms(mockValues, [ - transformToBase100, - transformToString, - ] as any[]) - ).toEqual({ - progress: { - percentage: '42%', - }, - }); - }); - - test('getInputTextOrNumber', () => { - const textElement = createInputElement({ - name: 'text', - value: '', - }); - const numberElement = createInputElement({ - name: 'number', - type: 'number', - }); - - expect(getInputTextOrNumber(textElement)).toBe(''); - expect(getInputTextOrNumber(numberElement)).toBe(undefined); - - textElement.value = 'test'; - numberElement.value = 'test'; - expect(getInputTextOrNumber(textElement)).toBe('test'); - expect(getInputTextOrNumber(numberElement)).toBe(undefined); - - numberElement.value = '42'; - expect(getInputTextOrNumber(numberElement)).toBe(42); - }); - - test('isFieldValue', () => { - expect(isFieldValue([])).toBe(true); - expect(isFieldValue(['test'])).toBe(true); - expect(isFieldValue([new File([], 'test')])).toBe(true); - expect(isFieldValue('test')).toBe(true); - expect(isFieldValue(2)).toBe(true); - expect(isFieldValue(false)).toBe(true); - expect(isFieldValue(new File([], 'test'))).toBe(true); - }); - - test('_cloneDeep', () => { - const obj = { - account: { - email: 'test', - password: 'password', - }, - }; - expect(_cloneDeep(obj)).toEqual({ - account: { - email: 'test', - password: 'password', - }, - }); - expect(_cloneDeep(obj)).not.toBe(obj); - expect(_cloneDeep(obj).account).not.toBe(obj.account); - }); - - test('_merge', () => { - const obj = { - account: { - email: 'test', - password: 'password', - leftAlone: 'original', - }, - leftAlone: 'original', - objectArray: [{ value: 'test' }, { value: 'test' }], - stringArray: ['test', 'test2'], - }; - const source1 = { - account: { - email: 'overriden', - password: { - type: 'overriden', - value: 'password', - }, - }, - added: 'value', - objectArray: [{ otherValue: 'test' }, { otherValue: 'test' }], - stringArray: ['test3', 'test4'], - }; - expect(_merge({}, source1)).toEqual(source1); - expect(_merge(obj, source1)).toEqual({ - account: { - email: 'overriden', - password: { - type: 'overriden', - value: 'password', - }, - leftAlone: 'original', - }, - added: 'value', - leftAlone: 'original', - objectArray: [ - { otherValue: 'test', value: 'test' }, - { otherValue: 'test', value: 'test' }, - ], - stringArray: ['test3', 'test4'], - }); - expect(_merge({}, obj, source1)).toEqual({ - account: { - email: 'overriden', - password: { - type: 'overriden', - value: 'password', - }, - leftAlone: 'original', - }, - added: 'value', - leftAlone: 'original', - objectArray: [ - { otherValue: 'test', value: 'test' }, - { otherValue: 'test', value: 'test' }, - ], - stringArray: ['test3', 'test4'], - }); - expect(obj).toEqual({ - account: { - email: 'test', - password: 'password', - leftAlone: 'original', - }, - leftAlone: 'original', - objectArray: [{ value: 'test' }, { value: 'test' }], - stringArray: ['test', 'test2'], - }); - expect(source1).toEqual({ - account: { - email: 'overriden', - password: { - type: 'overriden', - value: 'password', - }, - }, - added: 'value', - objectArray: [{ otherValue: 'test' }, { otherValue: 'test' }], - stringArray: ['test3', 'test4'], - }); - }); - - test('_mergeWith', () => { - const touched = { - account: { - email: true, - password: false, - }, - email: true, - password: false, - }; - const errors = { - account: { - email: 'Not valid', - password: 'Not valid', - }, - email: 'Not valid', - password: 'Not valid', - }; - function errorFilterer(errValue?: string, touchValue?: boolean) { - if (_isPlainObject(touchValue)) return; - return (touchValue && errValue) || null; - } - expect(_mergeWith(errors, touched, errorFilterer)).toEqual({ - account: { - email: 'Not valid', - password: null, - }, - email: 'Not valid', - password: null, - }); - expect(_mergeWith({}, touched, errorFilterer)).toEqual({ - account: { - email: null, - password: null, - }, - email: null, - password: null, - }); - }); - - test('_defaultsDeep', () => { - const obj = { - account: { - email: 'test', - password: 'password', - leftAlone: 'original', - }, - leftAlone: 'original', - preferences: [null, 'leftAlone'], - }; - const source1 = { - account: { - email: 'overriden', - password: { - type: 'overriden', - value: 'password', - }, - added: 'value', - addedObj: { - prop: 'test', - }, - }, - added: 'value', - preferences: ['added', 'ignored', 'added'], - }; - expect(_defaultsDeep(obj, source1)).toEqual({ - account: { - email: 'test', - password: 'password', - leftAlone: 'original', - added: 'value', - addedObj: { - prop: 'test', - }, - }, - added: 'value', - leftAlone: 'original', - preferences: ['added', 'leftAlone', 'added'], - }); - }); - - test('getIndex', () => { - const input = createInputElement({ type: 'text', name: 'test' }); - expect(getIndex(input)).toBe(undefined); - const multipleInput = createMultipleInputElements( - { type: 'text', name: 'test' }, - 1 - ); - expect(getIndex(multipleInput[0])).toBe(0); - }); - - test('shouldIgnore', () => { - const ignoredInput = createInputElement({ type: 'text', name: 'test' }); - ignoredInput.setAttribute('data-felte-ignore', ''); - expect(shouldIgnore(ignoredInput)).toBe(true); - const input = createInputElement({ type: 'text', name: 'test' }); - const container = document.createElement('div'); - container.appendChild(input); - expect(shouldIgnore(input)).toBe(false); - container.setAttribute('data-felte-ignore', ''); - expect(shouldIgnore(input)).toBe(true); - }); -}); diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 71f82fd9..36ca0b66 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -10,7 +10,7 @@ "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, - "declarationDir": "./dist" + "declarationDir": "./dist/types" }, "include": ["src"] } diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 62986ddc..03ad9267 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,337 @@ # @felte/core +## 1.0.0-next.27 + +### Patch Changes + +- Updated dependencies [7f3d8b8] + - @felte/common@1.0.0-next.23 + +## 1.0.0-next.26 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/common@1.0.0-next.22 + +## 1.0.0-next.25 + +### Minor Changes + +- fcbdaed: Add `swapFields` and `moveField` helper functions + +### Patch Changes + +- Updated dependencies [fcbdaed] + - @felte/common@1.0.0-next.21 + +## 1.0.0-next.24 + +### Minor Changes + +- 990034e: Add `interacted` store to show which is the last field the user has interacted with +- 0faaa8f: Add isValidating store + +### Patch Changes + +- 5c71750: Calls `reset` helper when a `reset` event is dispatched by the form (e.g. when using a `button[type="reset"]` +- Updated dependencies [990034e] + - @felte/common@1.0.0-next.20 + +## 1.0.0-next.23 + +### Patch Changes + +- 8282a70: Wait for DOM element to be mounted on createField/useField + +## 1.0.0-next.22 + +### Minor Changes + +- b9ea48d: Add support for custom controls with `createField` + +### Patch Changes + +- Updated dependencies [a174e87] + - @felte/common@1.0.0-next.19 + +## 1.0.0-next.21 + +### Patch Changes + +- 0b38b98: Prevent key assignment to errors and touched stores +- Updated dependencies [70cfada] + - @felte/common@1.0.0-next.18 + +## 1.0.0-next.20 + +### Patch Changes + +- 2e7aad3: Fix some values disappearing from DOM when removing a field from an array +- 2e7aad3: Add type for keyed Data +- Updated dependencies [2e7aad3] + - @felte/common@1.0.0-next.17 + +## 1.0.0-next.19 + +### Minor Changes + +- c8c1511: Add unique key to field arrays + +### Patch Changes + +- Updated dependencies [c8c1511] + - @felte/common@1.0.0-next.16 + +## 1.0.0-next.18 + +### Major Changes + +- 093482a: BREAKING: Setting directly to `data` using `data.set` no longer touches the field. The `setFields` helper should be used instead if this behaviour is desired. + +### Minor Changes + +- 093482a: Add isValidating store + +### Patch Changes + +- Updated dependencies [093482a] + - @felte/common@1.0.0-next.15 + +## 1.0.0-next.17 + +### Patch Changes + +- dd52c94: Fix error filtering +- Updated dependencies [dd52c94] + - @felte/common@1.0.0-next.14 + +## 1.0.0-next.16 + +### Major Changes + +- a45d56c: BREAKING: `errors` and `warning` stores will either have `null` or an array of strings as errors + +### Patch Changes + +- Updated dependencies [a45d56c] + - @felte/common@1.0.0-next.13 + +## 1.0.0-next.15 + +### Major Changes + +- 452fe5a: BREAKING: Remove `data-felte-index` attribute support. + + This means that you should replace this: + + ```html + + ``` + + To this: + + ```html + + ``` + + This was done in order to allow for future improvements of the type system for TypeScript users, and to also follow the same behaviour the browser would do if JavaScript is disabled + +- 15d0ce2: BREAKING: Stop grabbing nested names from fieldset + + This means that this won't work anymore: + + ```html +

+ +
+ ``` + + So it needs to be changed to this: + + ```html +
+ +
+ ``` + + This was done to allow for future improvements on type-safety, as well to keep consistency with the browser's behaviour when JavaScript is disabled. + +### Patch Changes + +- Updated dependencies [452fe5a] +- Updated dependencies [15d0ce2] + - @felte/common@1.0.0-next.12 + +## 1.0.0-next.14 + +### Major Changes + +- b7ef442: BREAKING: Remove `addWarnValidator` in favour of options to `addValidator`. + + This gives a smaller and more unified API, as well as opening to add more options in the future. + + If you have an extender using `addWarnValidator`, you must update it by calling `addValidator` instead with the following options: + + ```javascript + addValidator(yourValidationFunction, { level: 'warning' }); + ``` + +### Minor Changes + +- a1dbc28: Improve types +- ec740a0: Update types +- 34e0393: Make string paths for accessors type safe +- 477bb45: Add debounced validators + +### Patch Changes + +- Updated dependencies [a1dbc28] +- Updated dependencies [ec740a0] +- Updated dependencies [34e0393] +- Updated dependencies [b7ef442] +- Updated dependencies [e1ad8cd] + - @felte/common@1.0.0-next.11 + +## 1.0.0-next.13 + +### Patch Changes + +- f315439: Export events as types + +## 1.0.0-next.12 + +### Minor Changes + +- dc1f21a: Add helper functions to context passed to `onSuccess`, `onSubmit` and `onError` +- eea3afa: Pass context data to `onError` and `onSuccess` + +### Patch Changes + +- Updated dependencies [dc1f21a] +- Updated dependencies [eea3afa] + - @felte/common@1.0.0-next.10 + +## 1.0.0-next.11 + +### Patch Changes + +- 38fbb49: Point "browser" field to esm bundle +- Updated dependencies [38fbb49] + - @felte/common@1.0.0-next.9 + +## 1.0.0-next.10 + +### Patch Changes + +- Updated dependencies [c86a82a] + - @felte/common@1.0.0-next.8 + +## 1.0.0-next.9 + +### Patch Changes + +- 46b05e3: Fix when publishing as modules + +## 1.0.0-next.8 + +### Patch Changes + +- e49c094: Use `preserveModules` for better tree-shaking +- Updated dependencies [e49c094] + - @felte/common@1.0.0-next.7 + +## 1.0.0-next.7 + +### Patch Changes + +- 62ceb3f: Fix hot module reloading + +## 1.0.0-next.6 + +### Minor Changes + +- f9b9125: Add `feltesuccess` and `felteerror` events +- 96c3c18: Add default submit handler + +### Patch Changes + +- d1b62bf: Allow for `onError` and `onSuccess` to be asynchronous +- Updated dependencies [d1b62bf] + - @felte/common@1.0.0-next.6 + +## 1.0.0-next.5 + +### Patch Changes + +- Updated dependencies [e2f4e18] + - @felte/common@1.0.0-next.5 + +## 1.0.0-next.4 + +### Patch Changes + +- 8c29b4a: Fix unset on Safari +- Updated dependencies [8c29b4a] + - @felte/common@1.0.0-next.3 + +## 1.0.0-next.3 + +### Minor Changes + +- 6f48123: Add `addField` helper function + +### Patch Changes + +- Updated dependencies [6f48123] + - @felte/common@1.0.0-next.2 + +## 1.0.0-next.2 + +### Major Changes + +- 77de471: BREAKING: Stop proxying inputs. This was causing all sorts of race conditions which were a headache to solve. Instead we're going to keep a single recommendation: If you wish to programatically set the value of an input, use the `setFields` helper. +- 02a77e3: BREAKING: When removing an input from an array of inputs, Felte now splices the array instead of setting the value to `null`/`undefined`. This means that an `index` on an array of inputs is no longer a _unique_ identifier and the value can move around if fields are added/removed. + +### Patch Changes + +- Updated dependencies [02a77e3] + - @felte/common@1.0.0-next.1 + +## 1.0.0-next.0 + +### Major Changes + +- a2ea0b2: BREAKING: `setFields` no longer touches a field by default. It needs to be explicit and it's only possible when passing a string path. E.g. `setField(‘email’ , 'zaphod@beeblebrox.com')` now is `setFields('email', 'zaphod@beeblebrox.com', true)`. +- 1dd68e7: BREAKING: Remove `data-felte-unset-on-remove` in favour of `data-felte-keep-on-remove`. Felte will now remove fields by default if removed from the DOM. + + To keep the same behaviour as before, add `data-felte-keep-on-remove` to any dynamic inputs you had that didn't have `data-felte-unset-on-remove` previously. And remove `data-felte-unset-on-remove` from the inputs that had it, or replace it for `data-felte-keep-on-remove="false"` if it was used to override a parent's attribute. + +- 6109533: BREAKING: apply transforms to initialValues +- 9a48a40: Pass a new property `stage` to extenders to distinguish between setup, mount and update stages +- 0d22bc6: BREAKING: Helpers have been completely reworked. + `setField` and `setFields` have been unified in a single `setFields` helper. + Others such as `setError` and `setWarning` have been pluralized to `setErrors` and `setWarnings` since now they can accept the whole object. + `setTouched` now requires to be passed the value to assign. E.g. `setTouched('path')` is now `setTouched('path', true)`. It no longer accepts an index as an argument since that can be assigned in the path itself using `[]`. +- 3d571bb: BREAKING: Remove `getField` helper in favor of `getValue` export. E.g. `getField('email')` now is `getValue($data, 'email')` and accessors. +- 2c0f874: Make type of helpers and stores looser when using a transform function + +### Minor Changes + +- 1bc036e: Change responsibility for adding `aria-invalid` to fields to `@felte/core` +- c1f32a0: Add `unsetField` and `resetField` helper functions + +### Patch Changes + +- 6431ee4: Unset also `touched`, `warnings` and `errors` stores when fields are marked for removal +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/common@1.0.0-next.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/core/README.md b/packages/core/README.md index 7eab5d51..2efdc061 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,3 +4,13 @@ [![NPM Version](https://img.shields.io/npm/v/@felte/core)](https://www.npmjs.com/package/@felte/core) The core package containing the main functionality of Felte. This allows to make Felte compatible with multiple frameworks or vanilla javascript. More documenttion on this pending. + +Since this package is _bundled_ with other packages, breaking changes might occur in between minor versions, specially if they're required to _prevent_ breaking changes on the other packages. + +If you're looking to use Felte for any of your apps, you're most likely looking for: + +- [felte](../felte/README.md) if you're working with Svelte. + +- [@felte/solid](../solid/README.md) if you're working with Solid. + +- [@felte/react](../react/README.md) if your're working with React. diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js deleted file mode 100644 index 553799a5..00000000 --- a/packages/core/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], -}; diff --git a/packages/core/package.json b/packages/core/package.json index 1dd57358..e5de4e0f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,11 +1,15 @@ { "name": "@felte/core", - "version": "0.3.0", - "description": "Core package for FelteJS", - "main": "dist/index.js", - "browser": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", + "version": "1.0.0-next.27", + "description": "Core utility for Felte's integration with front-end frameworks", + "main": "dist/cjs/index.cjs", + "browser": "dist/esm/index.js", + "module": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "sideEffects": false, "author": "Pablo Berganza ", "license": "MIT", @@ -18,12 +22,12 @@ ], "scripts": { "prebuild": "rimraf ./dist", - "build": "cross-env NODE_ENV=production rollup -c", + "build": "pnpm prebuild && cross-env NODE_ENV=production rollup -c", "docs:build": "typedoc --out ../../docs", "dev": "rollup -cw", "prepublishOnly": "pnpm build && pnpm test", - "test": "jest", - "test:ci": "jest --coverage" + "test": "uvu -r tsm -r global-jsdom/register tests -i common", + "test:ci": "nyc -n src pnpm test" }, "files": [ "dist" @@ -34,14 +38,11 @@ "publishConfig": { "access": "public" }, - "devDependencies": { - "ts-jest": "^26.5.0" - }, "exports": { ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.js", - "default": "./dist/index.mjs" + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs", + "default": "./dist/esm/index.js" }, "./package.json": "./package.json" } diff --git a/packages/core/rollup.config.js b/packages/core/rollup.config.js index 15f1bd4a..321f7e92 100644 --- a/packages/core/rollup.config.js +++ b/packages/core/rollup.config.js @@ -2,21 +2,26 @@ import typescript from 'rollup-plugin-ts'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; -import { terser } from 'rollup-plugin-terser'; -import bundleSize from 'rollup-plugin-bundle-size'; +import renameNodeModules from 'rollup-plugin-rename-node-modules'; import pkg from './package.json'; const prod = process.env.NODE_ENV === 'production'; -const name = pkg.name - .replace(/^(@\S+\/)?(svelte-)?(\S+)/, '$3') - .replace(/^\w/, (m) => m.toUpperCase()) - .replace(/-\w/g, (m) => m[1].toUpperCase()); export default { input: './src/index.ts', output: [ - { file: pkg.browser, format: 'cjs', sourcemap: prod, name }, - { file: pkg.module, format: 'esm', sourcemap: prod }, + { + file: pkg.main, + format: 'cjs', + sourcemap: prod, + }, + { + dir: 'dist/esm', + format: 'esm', + sourcemap: prod, + preserveModules: true, + preserveModulesRoot: 'src', + }, ], plugins: [ replace({ @@ -27,8 +32,7 @@ export default { }), resolve({ browser: true }), commonjs(), - typescript(), - prod && terser(), - prod && bundleSize(), + typescript({ browserslist: false }), + renameNodeModules('external', prod), ], }; diff --git a/packages/core/src/create-field.ts b/packages/core/src/create-field.ts new file mode 100644 index 00000000..0c654ff2 --- /dev/null +++ b/packages/core/src/create-field.ts @@ -0,0 +1,134 @@ +import type { FieldValue, FormControl } from '@felte/common'; +import { isFormControl, setControlValue, isElement } from '@felte/common'; + +export type FieldConfig = { + name: string; + touchOnChange?: boolean; + defaultValue?: FieldValue; +}; + +export type Field = { + field(node: HTMLElement): { destroy?(): void }; + onInput(value: FieldValue): void; + onChange(value: FieldValue): void; + onBlur(): void; +}; + +type EventType = 'input' | 'change' | 'focusout'; + +const observerConfig = { + attributes: true, + attributeFilter: ['data-felte-validation-message', 'aria-invalid'], +}; + +export function createField( + name: string, + config?: Omit +): Field; +export function createField(config: FieldConfig): Field; +export function createField( + nameOrConfig: FieldConfig | string, + config?: Omit +): Field; +export function createField( + nameOrConfig: FieldConfig | string, + config?: Omit +): Field { + let name: string; + let defaultValue: FieldValue; + let touchOnChange: boolean; + let fieldNode: HTMLElement; + let control: FormControl; + + if (typeof nameOrConfig === 'string') { + name = nameOrConfig; + defaultValue = config?.defaultValue; + touchOnChange = config?.touchOnChange ?? false; + } else { + name = nameOrConfig.name; + defaultValue = nameOrConfig.defaultValue; + touchOnChange = nameOrConfig.touchOnChange ?? false; + } + + function dispatchEvent(eventType: 'focusout'): void; + function dispatchEvent( + eventType: 'input' | 'change', + value: FieldValue + ): void; + function dispatchEvent(eventType: EventType, value?: FieldValue): void { + if (!control) return; + setControlValue(control, value); + const customEvent = new Event(eventType, { + bubbles: true, + cancelable: true, + }); + control.dispatchEvent(customEvent); + } + + function mutationCallback(mutationList: MutationRecord[]) { + mutationList.forEach(() => { + const invalid = control.getAttribute('aria-invalid'); + if (!invalid) fieldNode.removeAttribute('aria-invalid'); + else fieldNode.setAttribute('aria-invalid', invalid); + const validationMessage = control.getAttribute( + 'data-felte-validation-message' + ); + if (!validationMessage) + fieldNode.removeAttribute('data-felte-validation-message'); + else + fieldNode.setAttribute( + 'data-felte-validation-message', + validationMessage + ); + }); + } + + function field(node: HTMLElement) { + fieldNode = node; + let observer: MutationObserver; + if (isFormControl(node)) { + control = node; + control.name = name; + return {}; + } else { + // This setTimeout is necessary to guarantee the node has been mounted + setTimeout(() => { + const parent = fieldNode.parentNode; + if (!parent || !isElement(parent)) return; + const foundControl = parent.querySelector(`[name="${name}"]`); + if (!foundControl || !isFormControl(foundControl)) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + parent.insertBefore(input, node.nextSibling); + control = input; + } else { + control = foundControl; + } + setControlValue(control, defaultValue); + + observer = new MutationObserver(mutationCallback); + observer.observe(control, observerConfig); + }); + return { + destroy() { + observer?.disconnect(); + }, + }; + } + } + + function onInput(value: FieldValue) { + dispatchEvent(touchOnChange ? 'change' : 'input', value); + } + + function onBlur() { + dispatchEvent('focusout'); + } + return { + field, + onInput, + onChange: onInput, + onBlur, + }; +} diff --git a/packages/core/src/create-form-action.ts b/packages/core/src/create-form-action.ts index 587c3915..a7587798 100644 --- a/packages/core/src/create-form-action.ts +++ b/packages/core/src/create-form-action.ts @@ -3,20 +3,22 @@ import type { Obj, Stores, FormConfig, - ValidationFunction, TransformFunction, ExtenderHandler, FormControl, CreateSubmitHandlerConfig, - Touched, Errors, + AddValidatorFn, + Helpers, + ValidationFunction, + Keyed, } from '@felte/common'; import { isFormControl, shouldIgnore, isInputElement, isSelectElement, - isTextAreaElement, + isElement, getInputTextOrNumber, _get, _set, @@ -25,30 +27,82 @@ import { _cloneDeep, _defaultsDeep, getPath, - getIndex, deepSet, deepSome, - getPathFromDataset, getFormDefaultValues, - isElement, getFormControls, - executeValidation, + _isPlainObject, } from '@felte/common'; +import type { FelteSuccessDetail, FelteErrorDetail } from './events'; +import type { SuccessResponse, FetchResponse } from './error'; import { get } from './get'; +import { FelteSubmitError } from './error'; +import { deepSetTouched } from './deep-set-touched'; +import { deepRemoveKey } from './deep-set-key'; + +function createDefaultSubmitHandler(form?: HTMLFormElement) { + if (!form) return; + return async function onSubmit(): Promise { + let body: FormData | URLSearchParams = new FormData(form); + const action = new URL(form.action); + const method = + form.method.toLowerCase() === 'get' + ? 'get' + : action.searchParams.get('_method') || form.method; + let enctype = form.enctype; + + if (form.querySelector('input[type="file"]')) { + enctype = 'multipart/form-data'; + } + if (method === 'get' || enctype === 'application/x-www-form-urlencoded') { + body = new URLSearchParams(body as any); + } + + let fetchOptions: RequestInit; + + if (method === 'get') { + (body as URLSearchParams).forEach((value, key) => { + action.searchParams.append(key, value); + }); + fetchOptions = { method }; + } else { + fetchOptions = { + method, + body, + headers: { + 'Content-Type': enctype, + }, + }; + } + + const response: FetchResponse = await window.fetch( + action.toString(), + fetchOptions + ); -type Configuration = { + if (response.ok) return response; + throw new FelteSubmitError( + 'An error occurred while submitting the form', + response + ); + }; +} + +export type FormActionConfig = { stores: Stores; config: FormConfig; extender: Extender[]; - helpers: { - reset(): void; - validate(): Promise | void>; - addValidator(validator: ValidationFunction): void; - addWarnValidator(validator: ValidationFunction): void; + validateErrors( + data: Data | Keyed, + altValidate?: ValidationFunction | ValidationFunction[] + ): Promise | undefined>; + validateWarnings( + data: Data | Keyed, + altWarn?: ValidationFunction | ValidationFunction[] + ): Promise | undefined>; + helpers: Helpers & { + addValidator: AddValidatorFn; addTransformer(transformer: TransformFunction): void; - setFields(values: Data): void; - setTouched(fieldName: string, index?: number): void; - setInitialValues(values: Data): void; }; _setFormNode(node: HTMLFormElement): void; _getFormNode(): HTMLFormElement | undefined; @@ -62,47 +116,66 @@ export function createFormAction({ stores, config, extender, + validateErrors, + validateWarnings, _setFormNode, _getFormNode, _getInitialValues, _setCurrentExtenders, _getCurrentExtenders, -}: Configuration) { +}: FormActionConfig) { + const { setFields, setTouched, reset, setInitialValues } = helpers; const { - setFields, - setTouched, - reset, - validate, addValidator, - addWarnValidator, addTransformer, - setInitialValues, + validate, + setIsDirty, + setIsSubmitting, + ...contextHelpers } = helpers; - const { data, errors, warnings, touched, isSubmitting, isDirty } = stores; + const { + data, + errors, + warnings, + touched, + isSubmitting, + isDirty, + interacted, + } = stores; function createSubmitHandler(altConfig?: CreateSubmitHandlerConfig) { - const onSubmit = altConfig?.onSubmit ?? config.onSubmit; - const validate = altConfig?.validate ?? config.validate; - const warn = altConfig?.warn ?? config.warn; const onError = altConfig?.onError ?? config.onError; + const onSuccess = altConfig?.onSuccess ?? config.onSuccess; return async function handleSubmit(event?: Event) { const formNode = _getFormNode(); + const onSubmit = + altConfig?.onSubmit ?? + config.onSubmit ?? + createDefaultSubmitHandler(formNode); + if (!onSubmit) return; event?.preventDefault(); isSubmitting.set(true); - const currentData = get(data); - const currentErrors = await executeValidation(currentData, validate); - const currentWarnings = await executeValidation(currentData, warn); + interacted.set(null); + const currentData = deepRemoveKey(get(data)); + const currentErrors = await validateErrors( + currentData, + altConfig?.validate + ); + const currentWarnings = await validateWarnings( + currentData, + altConfig?.warn + ); if (currentWarnings) - warnings.set(_merge(deepSet(currentData, null), currentWarnings)); - touched.update((t) => { - return deepSet, boolean>(t, true) as Touched; - }); + warnings.set(_merge(deepSet(currentData, []), currentWarnings)); + touched.set(deepSetTouched(currentData, true)); if (currentErrors) { - errors.set(currentErrors); - const hasErrors = deepSome(currentErrors, (error) => !!error); + const hasErrors = deepSome(currentErrors, (error) => + Array.isArray(error) ? error.length >= 1 : !!error + ); if (hasErrors) { + await new Promise((r) => setTimeout(r)); _getCurrentExtenders().forEach((extender) => - extender?.onSubmitError?.({ + extender.onSubmitError?.({ data: currentData, errors: currentErrors, }) @@ -111,20 +184,40 @@ export function createFormAction({ return; } } + const context = { + ...contextHelpers, + form: formNode, + controls: + formNode && Array.from(formNode.elements).filter(isFormControl), + config: { ...config, ...altConfig }, + }; try { - await onSubmit(currentData, { - form: formNode, - controls: - formNode && Array.from(formNode.elements).filter(isFormControl), - config: { ...config, ...altConfig }, - }); + const response = await onSubmit(currentData, context); + formNode?.dispatchEvent( + new CustomEvent>('feltesuccess', { + detail: { + response, + ...context, + }, + }) + ); + await onSuccess?.(response, context); } catch (e) { - if (!onError) throw e; - const serverErrors = onError(e); + formNode?.dispatchEvent( + new CustomEvent>('felteerror', { + detail: { + error: e, + ...context, + }, + }) + ); + if (!onError) return; + const serverErrors = await onError(e, context); if (serverErrors) { errors.set(serverErrors); + await new Promise((r) => setTimeout(r)); _getCurrentExtenders().forEach((extender) => - extender?.onSubmitError?.({ + extender.onSubmitError?.({ data: currentData, errors: serverErrors, }) @@ -139,90 +232,42 @@ export function createFormAction({ const handleSubmit = createSubmitHandler(); function form(node: HTMLFormElement) { - if (!node.requestSubmit) node.requestSubmit = handleSubmit; - function callExtender(extender: Extender) { - return extender({ - form: node, - controls: Array.from(node.elements).filter(isFormControl), - data, - errors, - warnings, - touched, - config, - addValidator, - addWarnValidator, - addTransformer, - setFields, - validate, - reset, - }); - } - - function proxyInputs() { - for (const control of Array.from(node.elements).filter(isFormControl)) { - if (shouldIgnore(control) || !control.name) continue; - let propName = 'value'; - if ( - isInputElement(control) && - ['checkbox', 'radio'].includes(control.type) - ) { - propName = 'checked'; - } - if (isInputElement(control) && control.type === 'file') { - propName = 'files'; - } - const prop = Object.getOwnPropertyDescriptor( - isSelectElement(control) - ? HTMLSelectElement.prototype - : isTextAreaElement(control) - ? HTMLTextAreaElement.prototype - : HTMLInputElement.prototype, - propName - ); - Object.defineProperty(control, propName, { - configurable: true, - set(newValue) { - prop?.set?.call(control, newValue); - - if (isInputElement(control)) { - if (control.type === 'checkbox') - return setCheckboxValues(control); - if (control.type === 'radio') return setRadioValues(control); - if (control.type === 'file') return setFileValue(control); - } - const inputValue = isSelectElement(control) - ? control.value - : getInputTextOrNumber(control); - data.update(($data) => { - return _set($data, getPath(control), inputValue); - }); - }, - get: prop?.get, + if (!node.requestSubmit) + node.requestSubmit = handleSubmit as typeof node.requestSubmit; + function callExtender(stage: 'MOUNT' | 'UPDATE') { + return function (extender: Extender) { + return extender({ + form: node, + stage, + controls: Array.from(node.elements).filter(isFormControl), + data, + errors, + warnings, + touched, + config, + addValidator, + addTransformer, + setFields, + validate, + reset, }); - } + }; } - _setCurrentExtenders(extender.map(callExtender)); + _setCurrentExtenders(extender.map(callExtender('MOUNT'))); node.noValidate = !!config.validate; - const { defaultData } = getFormDefaultValues(node); + const { defaultData, defaultTouched } = getFormDefaultValues(node); _setFormNode(node); setInitialValues(_merge(_cloneDeep(defaultData), _getInitialValues())); setFields(_getInitialValues()); - touched.set(deepSet(_getInitialValues(), false)); + touched.set(defaultTouched); function setCheckboxValues(target: HTMLInputElement) { - const index = getIndex(target); const elPath = getPath(target); const checkboxes = Array.from( node.querySelectorAll(`[name="${target.name}"]`) ).filter((checkbox) => { if (!isFormControl(checkbox)) return false; - if (typeof index !== 'undefined') { - const felteIndex = Number( - (checkbox as HTMLInputElement).dataset.felteIndex - ); - return felteIndex === index; - } return elPath === getPath(checkbox); }); if (checkboxes.length === 0) return; @@ -252,13 +297,9 @@ export function createFormAction({ } function setFileValue(target: HTMLInputElement) { - const files = target.files; + const files = Array.from(target.files ?? []); data.update(($data) => { - return _set( - $data, - getPath(target), - target.multiple ? Array.from(files ?? []) : files?.[0] - ); + return _set($data, getPath(target), target.multiple ? files : files[0]); }); } @@ -273,9 +314,9 @@ export function createFormAction({ return; if (['checkbox', 'radio', 'file'].includes(target.type)) return; if (!target.name) return; - if (config.touchTriggerEvents?.input) setTouched(getPath(target)); isDirty.set(true); const inputValue = getInputTextOrNumber(target); + interacted.set(target.name); data.update(($data) => { return _set($data, getPath(target), inputValue); }); @@ -285,14 +326,15 @@ export function createFormAction({ const target = e.target; if (!target || !isFormControl(target) || shouldIgnore(target)) return; if (!target.name) return; - if (config.touchTriggerEvents?.change) setTouched(getPath(target)); + setTouched(getPath(target), true); + interacted.set(target.name); if ( isSelectElement(target) || - ['checkbox', 'radio', 'file'].includes(target.type) + ['checkbox', 'radio', 'file', 'hidden'].includes(target.type) ) { isDirty.set(true); } - if (isSelectElement(target)) { + if (isSelectElement(target) || target.type === 'hidden') { data.update(($data) => { return _set($data, getPath(target), target.value); }); @@ -307,16 +349,49 @@ export function createFormAction({ const target = e.target; if (!target || !isFormControl(target) || shouldIgnore(target)) return; if (!target.name) return; - if (config.touchTriggerEvents?.blur) setTouched(getPath(target)); + setTouched(getPath(target), true); + interacted.set(target.name); + } + + function handleReset(e: Event) { + e.preventDefault(); + reset(); } const mutationOptions = { childList: true, subtree: true }; function unsetTaggedForRemove(formControls: FormControl[]) { - for (const control of formControls) { - if (control.dataset.felteUnsetOnRemove !== 'true') continue; + for (const control of formControls.reverse()) { + if ( + control.hasAttribute('data-felte-keep-on-remove') && + control.dataset.felteKeepOnRemove !== 'false' + ) + continue; + const fieldArrayReg = /.*(\[[0-9]+\]|\.[0-9]+)\.[^.]+$/; + let fieldName = getPath(control); + const shape = get(touched); + const isFieldArray = fieldArrayReg.test(fieldName); + if (isFieldArray) { + const arrayPath = fieldName.split('.').slice(0, -1).join('.'); + const valueToRemove = _get(shape, arrayPath); + if ( + _isPlainObject(valueToRemove) && + Object.keys(valueToRemove).length <= 1 + ) { + fieldName = arrayPath; + } + } data.update(($data) => { - return _unset($data, getPathFromDataset(control)); + return _unset($data, fieldName); + }); + touched.update(($touched) => { + return _unset($touched, fieldName); + }); + errors.update(($errors) => { + return _unset($errors, fieldName); + }); + warnings.update(($warnings) => { + return _unset($warnings, fieldName); }); } } @@ -325,7 +400,6 @@ export function createFormAction({ for (const mutation of mutationList) { if (mutation.type !== 'childList') continue; if (mutation.addedNodes.length > 0) { - proxyInputs(); const shouldUpdate = Array.from(mutation.addedNodes).some((node) => { if (!isElement(node)) return false; if (isFormControl(node)) return true; @@ -333,12 +407,12 @@ export function createFormAction({ return formControls.length > 0; }); if (!shouldUpdate) continue; - _getCurrentExtenders().forEach((extender) => extender?.destroy?.()); - _setCurrentExtenders(extender.map(callExtender)); - const { defaultData: newDefaultData } = getFormDefaultValues( - node - ); - const newDefaultTouched = deepSet(newDefaultData, false); + _getCurrentExtenders().forEach((extender) => extender.destroy?.()); + _setCurrentExtenders(extender.map(callExtender('UPDATE'))); + const { + defaultData: newDefaultData, + defaultTouched: newDefaultTouched, + } = getFormDefaultValues(node); data.update(($data) => _defaultsDeep($data, newDefaultData)); touched.update(($touched) => { return _defaultsDeep($touched, newDefaultTouched); @@ -349,8 +423,8 @@ export function createFormAction({ if (!isElement(removedNode)) continue; const formControls = getFormControls(removedNode); if (formControls.length === 0) continue; - _getCurrentExtenders().forEach((extender) => extender?.destroy?.()); - _setCurrentExtenders(extender.map(callExtender)); + _getCurrentExtenders().forEach((extender) => extender.destroy?.()); + _setCurrentExtenders(extender.map(callExtender('UPDATE'))); unsetTaggedForRemove(formControls); } } @@ -360,11 +434,11 @@ export function createFormAction({ const observer = new MutationObserver(mutationCallback); observer.observe(node, mutationOptions); - proxyInputs(); node.addEventListener('input', handleInput); node.addEventListener('change', handleChange); node.addEventListener('focusout', handleBlur); node.addEventListener('submit', handleSubmit); + node.addEventListener('reset', handleReset); const unsubscribeErrors = errors.subscribe(($errors) => { for (const el of node.elements) { if (!isFormControl(el) || !el.name) continue; @@ -375,8 +449,13 @@ export function createFormAction({ ? fieldErrors : undefined; if (message === el.dataset.felteValidationMessage) continue; - if (message) el.dataset.felteValidationMessage = message; - else delete el.dataset.felteValidationMessage; + if (message) { + el.dataset.felteValidationMessage = message; + el.setAttribute('aria-invalid', 'true'); + } else { + delete el.dataset.felteValidationMessage; + el.removeAttribute('aria-invalid'); + } } }); @@ -387,8 +466,9 @@ export function createFormAction({ node.removeEventListener('change', handleChange); node.removeEventListener('focusout', handleBlur); node.removeEventListener('submit', handleSubmit); + node.removeEventListener('reset', handleReset); unsubscribeErrors(); - _getCurrentExtenders().forEach((extender) => extender?.destroy?.()); + _getCurrentExtenders().forEach((extender) => extender.destroy?.()); }, }; } diff --git a/packages/core/src/create-form.ts b/packages/core/src/create-form.ts index 9a9a8b55..8feffd5b 100644 --- a/packages/core/src/create-form.ts +++ b/packages/core/src/create-form.ts @@ -1,96 +1,107 @@ import type { Form, FormConfig, - FormConfigWithInitialValues, - FormConfigWithoutInitialValues, + FormConfigWithTransformFn, + FormConfigWithoutTransformFn, ExtenderHandler, - Touched, StoreFactory, Obj, ValidationFunction, TransformFunction, + UnknownStores, + Stores, + KnownStores, + Helpers, + UnknownHelpers, + KnownHelpers, + ValidatorOptions, } from '@felte/common'; -import { - _unset, - _set, - _isPlainObject, - _get, - _cloneDeep, - _mergeWith, - _merge, - _defaultsDeep, - executeTransforms, -} from '@felte/common'; +import { executeTransforms } from '@felte/common'; import { createHelpers } from './helpers'; import { createFormAction } from './create-form-action'; +import type { FormActionConfig } from './create-form-action'; import { createStores } from './stores'; +import { deepSetKey } from './deep-set-key'; -export type Adapters = { - storeFactory: StoreFactory; +export type Adapters> = { + storeFactory: StoreFactory; }; -type CoreForm = Form & { +export type CoreForm = Form & { cleanup(): void; + startStores(): () => void; }; -/** - * Creates the stores and `form` action to make the form reactive. - * In order to use auto-subscriptions with the stores, call this function at the top-level scope of the component. - * - * @param config - Configuration for the form itself. Since `initialValues` is set, `Data` will not be undefined - * - * @category Main - */ -export function createForm( - config: FormConfigWithInitialValues & Ext, - adapters: Adapters -): CoreForm; -/** - * Creates the stores and `form` action to make the form reactive. - * In order to use auto-subscriptions with the stores, call this function at the top-level scope of the component. - * - * @param config - Configuration for the form itself. Since `initialValues` is not set (when only using the `form` action), `Data` will be undefined until the `form` element loads. - */ -export function createForm( - config: FormConfigWithoutInitialValues & Ext, - adapters: Adapters -): CoreForm; -export function createForm( +export function createForm< + Data extends Obj = Obj, + Ext extends Obj = Obj, + StoreExt = Record +>( + config: FormConfigWithTransformFn & Ext, + adapters: Adapters +): CoreForm & UnknownHelpers & UnknownStores; +export function createForm< + Data extends Obj = Obj, + Ext extends Obj = Obj, + StoreExt = Record +>( + config: FormConfigWithoutTransformFn & Ext, + adapters: Adapters +): CoreForm & KnownHelpers & KnownStores; +export function createForm< + Data extends Obj = Obj, + Ext extends Obj = Obj, + StoreExt = Record +>( config: FormConfig & Ext, - adapters: Adapters -): CoreForm { + adapters: Adapters +): CoreForm & Helpers & Stores; +export function createForm< + Data extends Obj = Obj, + Ext extends Obj = Obj, + StoreExt = Record +>( + config: FormConfig & { preventStoreStart?: boolean } & Ext, + adapters: Adapters +): CoreForm & Helpers & Stores { config.extend ??= []; - config.touchTriggerEvents ??= { change: true, blur: true }; + config.debounced ??= {}; if (config.validate && !Array.isArray(config.validate)) config.validate = [config.validate]; + if (config.debounced.validate && !Array.isArray(config.debounced.validate)) + config.debounced.validate = [config.debounced.validate]; + if (config.transform && !Array.isArray(config.transform)) config.transform = [config.transform]; if (config.warn && !Array.isArray(config.warn)) config.warn = [config.warn]; - - function addValidator(validator: ValidationFunction) { - if (!config.validate) { - config.validate = [validator]; + if (config.debounced.warn && !Array.isArray(config.debounced.warn)) + config.debounced.warn = [config.debounced.warn]; + + function addValidator( + validator: ValidationFunction, + { debounced, level }: ValidatorOptions = { + debounced: false, + level: 'error', + } + ) { + const prop = level === 'error' ? 'validate' : 'warn'; + config.debounced ??= {}; + const validateConfig = debounced ? config.debounced : config; + if (!validateConfig[prop]) { + validateConfig[prop] = [validator]; } else { - config.validate = [ - ...(config.validate as ValidationFunction[]), + validateConfig[prop] = [ + ...(validateConfig[prop] as ValidationFunction[]), validator, ]; } } - function addWarnValidator(validator: ValidationFunction) { - if (!config.warn) { - config.warn = [validator]; - } else { - config.warn = [...(config.warn as ValidationFunction[]), validator]; - } - } - function addTransformer(transformer: TransformFunction) { if (!config.transform) { - config.transform = [transformer]; + (config as FormConfig).transform = [transformer]; } else { config.transform = [ ...(config.transform as TransformFunction[]), @@ -106,6 +117,7 @@ export function createForm( let currentExtenders: ExtenderHandler[] = []; const { isSubmitting, + isValidating, data, errors, warnings, @@ -113,42 +125,53 @@ export function createForm( isValid, isDirty, cleanup, + start, + validateErrors, + validateWarnings, + interacted, } = createStores(adapters.storeFactory, config); const originalUpdate = data.update; const originalSet = data.set; - data.update = (updater) => + const transUpdate: typeof data.update = (updater) => originalUpdate((values) => - executeTransforms(updater(values), config.transform) + deepSetKey(executeTransforms(updater(values), config.transform)) ); - data.set = (values) => - originalSet(executeTransforms(values, config.transform)); + const transSet: typeof data.set = (values) => + originalSet(deepSetKey(executeTransforms(values, config.transform))); + + data.update = transUpdate; + data.set = transSet; const helpers = createHelpers({ extender, config, addValidator, addTransformer, + validateErrors, + validateWarnings, stores: { data, errors, warnings, touched, isValid, + isValidating, isSubmitting, isDirty, + interacted, }, }); currentExtenders = extender.map((extender) => extender({ + stage: 'SETUP', errors, warnings, touched, data, config, addValidator, - addWarnValidator, addTransformer, setFields: helpers.public.setFields, reset: helpers.public.reset, @@ -160,60 +183,52 @@ export function createForm( const _setCurrentExtenders = (extenders: ExtenderHandler[]) => { currentExtenders = extenders; }; - function dataSetCustomizer(dataValue: unknown, initialValue: unknown) { - if (_isPlainObject(dataValue)) return; - return dataValue !== initialValue; - } - - function dataSetTouchedCustomizer(dataValue: unknown, touchedValue: boolean) { - if (_isPlainObject(dataValue)) return; - return touchedValue || dataValue; - } - - function newDataSet(values: Data) { - touched.update((current) => { - const changed = _mergeWith>( - _cloneDeep(values), - config.initialValues, - dataSetCustomizer - ); - return _mergeWith>( - changed, - current, - dataSetTouchedCustomizer - ); - }); - isDirty.set(true); - return data.set(values); - } - const { form, createSubmitHandler, handleSubmit } = createFormAction({ + const formActionConfig: FormActionConfig = { config, - stores: { data, touched, errors, warnings, isSubmitting, isValid, isDirty }, + stores: { + data, + touched, + errors, + warnings, + isSubmitting, + isValidating, + isValid, + isDirty, + interacted, + }, helpers: { ...helpers.public, addTransformer, addValidator, - addWarnValidator, }, extender, + validateErrors, + validateWarnings, _getCurrentExtenders, _setCurrentExtenders, ...helpers.private, - }); + }; + + const { form, createSubmitHandler, handleSubmit } = createFormAction( + formActionConfig + ); return { - data: { ...data, set: newDataSet }, + data, errors, warnings, touched, isValid, isSubmitting, + isValidating, isDirty, + interacted, form, handleSubmit, createSubmitHandler, cleanup, + startStores: start, ...helpers.public, }; } diff --git a/packages/core/src/deep-set-key.ts b/packages/core/src/deep-set-key.ts new file mode 100644 index 00000000..7d78cb51 --- /dev/null +++ b/packages/core/src/deep-set-key.ts @@ -0,0 +1,39 @@ +import type { Obj, Keyed } from '@felte/common'; +import { _mapValues, _isPlainObject, createId } from '@felte/common'; + +export function deepSetKey(obj?: Data): Data { + if (!obj) return {} as Data; + return _mapValues(obj, (prop) => { + if (_isPlainObject(prop)) return deepSetKey(prop as Obj); + if (Array.isArray(prop)) { + if (prop.length === 0 || prop.every((p) => typeof p === 'string')) + return prop; + return prop.map((p) => { + if (!_isPlainObject(p)) return p; + const field = deepSetKey(p as Obj); + if (!field.key) field.key = createId(); + return field; + }); + } + return prop; + }) as Data; +} + +export function deepRemoveKey( + obj?: Keyed | Data +): Data { + if (!obj) return {} as Data; + return _mapValues(obj, (prop) => { + if (_isPlainObject(prop)) return deepSetKey(prop as Obj); + if (Array.isArray(prop)) { + if (prop.length === 0 || prop.every((p) => typeof p === 'string')) + return prop; + return prop.map((p) => { + if (!_isPlainObject(p)) return p; + const { key, ...field } = deepSetKey(p as Obj); + return field; + }); + } + return prop; + }) as Data; +} diff --git a/packages/core/src/deep-set-touched.ts b/packages/core/src/deep-set-touched.ts new file mode 100644 index 00000000..6086da9b --- /dev/null +++ b/packages/core/src/deep-set-touched.ts @@ -0,0 +1,20 @@ +import type { Obj, Touched, Keyed } from '@felte/common'; +import { _mapValues, _isPlainObject } from '@felte/common'; + +export function deepSetTouched( + obj: Data | Keyed, + value: boolean +): Touched { + return _mapValues(obj, (prop) => { + if (_isPlainObject(prop)) return deepSetTouched(prop as Obj, value); + if (Array.isArray(prop)) { + if (prop.length === 0 || prop.every((p) => typeof p === 'string')) + return value; + return prop.map((p) => { + const { key, ...field } = deepSetTouched(p as Obj, value); + return field; + }); + } + return value; + }) as Touched; +} diff --git a/packages/core/src/error.ts b/packages/core/src/error.ts new file mode 100644 index 00000000..7a5eddab --- /dev/null +++ b/packages/core/src/error.ts @@ -0,0 +1,18 @@ +export type FailResponse = Omit & { + ok: false; +}; + +export type SuccessResponse = Omit & { + ok: true; +}; + +export type FetchResponse = SuccessResponse | FailResponse; + +export class FelteSubmitError extends Error { + constructor(message: string, response: FailResponse) { + super(message); + this.name = 'FelteSubmitError'; + this.response = response; + } + response: FailResponse; +} diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts new file mode 100644 index 00000000..eb035e35 --- /dev/null +++ b/packages/core/src/events.ts @@ -0,0 +1,17 @@ +import type { SubmitContext, Obj } from '@felte/common'; + +export type FelteSuccessDetail = SubmitContext & { + response: unknown; +}; + +export type FelteErrorDetail = SubmitContext & { + error: unknown; +}; + +export type FelteSuccessEvent = CustomEvent< + FelteSuccessDetail +>; + +export type FelteErrorEvent = CustomEvent< + FelteErrorDetail +>; diff --git a/packages/core/src/helpers.ts b/packages/core/src/helpers.ts index e0a91992..7b948d45 100644 --- a/packages/core/src/helpers.ts +++ b/packages/core/src/helpers.ts @@ -8,117 +8,300 @@ import type { Touched, ValidationFunction, TransformFunction, + Setter, + ObjectSetter, + PartialErrorsSetter, + AssignableErrors, + PrimitiveSetter, + FieldsSetter, + Helpers, + Keyed, } from '@felte/common'; import { deepSet, - executeValidation, - getPath, - isFormControl, - setControlValue, setForm, _cloneDeep, - _defaultsDeep, _get, - _merge, _set, _unset, + _update, + _isPlainObject, + createId, } from '@felte/common'; import { get } from './get'; +import { deepSetTouched } from './deep-set-touched'; +import { deepSetKey } from './deep-set-key'; type CreateHelpersOptions = { config: FormConfig; stores: Stores; + validateErrors(data: Data | Keyed): Promise | undefined>; + validateWarnings(data: Data | Keyed): Promise | undefined>; extender: Extender[]; addValidator(validator: ValidationFunction): void; addTransformer(transformer: TransformFunction): void; }; +function addAtIndex( + storeValue: Data, + path: string, + value: any, + index?: number +) { + return _update(storeValue, path, (oldValue) => { + if (typeof oldValue !== 'undefined' && !Array.isArray(oldValue)) + return oldValue; + if (!oldValue) oldValue = []; + if (typeof index === 'undefined') { + oldValue.push(value); + } else { + oldValue.splice(index, 0, value); + } + return oldValue; + }); +} + +function swapInArray( + storeValue: Data, + path: string, + from: number, + to: number +) { + return _update(storeValue, path, (oldValue) => { + if (!oldValue || !Array.isArray(oldValue)) return oldValue; + [oldValue[from], oldValue[to]] = [oldValue[to], oldValue[from]]; + return oldValue; + }); +} + +function moveInArray( + storeValue: Data, + path: string, + from: number, + to: number +) { + return _update(storeValue, path, (oldValue) => { + if (!oldValue || !Array.isArray(oldValue)) return oldValue; + oldValue.splice(to, 0, oldValue.splice(from, 1)[0] as any); + return oldValue; + }); +} + +function isUpdater(value: unknown): value is (value: T) => T { + return typeof value === 'function'; +} + +function createSetHelper( + storeSetter: ( + updater: (value: Errors) => AssignableErrors + ) => void +): PartialErrorsSetter; +function createSetHelper( + storeSetter: (updater: (value: Data) => Data) => void +): ObjectSetter; +function createSetHelper( + storeSetter: (updater: (value: Data) => Data) => void +): PrimitiveSetter; +function createSetHelper( + storeSetter: (updater: (value: Data) => Data) => void +): Setter { + const setHelper = ( + pathOrValue: string | Data | ((value: Data) => Data), + valueOrUpdater?: unknown | ((value: unknown) => unknown) + ) => { + if (typeof pathOrValue === 'string') { + const path = pathOrValue; + storeSetter((oldValue) => { + const newValue = isUpdater(valueOrUpdater) + ? (valueOrUpdater(_get(oldValue as Obj, path)) as Data) + : valueOrUpdater; + return _set( + oldValue as Obj, + path, + newValue as FieldValue | FieldValue[] + ) as Data; + }); + } else { + storeSetter((oldValue) => + isUpdater(pathOrValue) ? pathOrValue(oldValue) : pathOrValue + ); + } + }; + + return setHelper as Setter; +} + export function createHelpers({ stores, config, + validateErrors, + validateWarnings, }: CreateHelpersOptions) { - const { data, touched, errors, warnings, isDirty } = stores; + let formNode: HTMLFormElement | undefined; + let initialValues = deepSetKey((config.initialValues ?? {}) as Data); + + const { + data, + touched, + errors, + warnings, + isDirty, + isSubmitting, + interacted, + } = stores; + + const setData = createSetHelper(data.update); + + const setTouched = createSetHelper, string>(touched.update); + + const setErrors = createSetHelper(errors.update); - function setTouched(fieldName: string, index?: number): void { - const path = - typeof index === 'undefined' ? fieldName : `${fieldName}[${index}]`; - touched.update(($touched) => _set($touched, path, true)); + const setWarnings = createSetHelper(warnings.update); + + function updateFields(updater: (values: Data) => Data) { + setData((oldData) => { + const newData = updater(oldData); + if (formNode) setForm(formNode, newData); + return newData; + }); } - function setError(path: string, error: string | string[]): void { - errors.update(($errors) => _set($errors, path, error)); + const setFields: FieldsSetter = ( + pathOrValue: string | Data | ((value: Data) => Data), + valueOrUpdater?: unknown | ((value: unknown) => unknown), + shouldTouch?: boolean + ) => { + const fieldsSetter = createSetHelper(updateFields); + fieldsSetter(pathOrValue as any, valueOrUpdater as any); + if (typeof pathOrValue === 'string' && shouldTouch) { + setTouched(pathOrValue, true); + } + }; + + function addField(path: string, value: unknown, index?: number) { + const touchedValue = _isPlainObject(value) + ? deepSetTouched(value, false) + : false; + const errValue = _isPlainObject(touchedValue) + ? deepSet(touchedValue, []) + : []; + value = _isPlainObject(value) ? { ...value, key: createId() } : value; + errors.update(($errors) => { + return addAtIndex($errors, path, errValue, index); + }); + warnings.update(($warnings) => { + return addAtIndex($warnings, path, errValue, index); + }); + touched.update(($touched) => { + return addAtIndex($touched, path, touchedValue, index); + }); + data.update(($data) => { + const newData = addAtIndex($data, path, value, index); + setTimeout(() => formNode && setForm(formNode, newData)); + return newData; + }); } - function setWarning(path: string, warning: string | string[]): void { - warnings.update(($warnings) => _set($warnings, path, warning)); + function updateAll(updater: (storeValue: Data) => Data) { + errors.update(updater); + warnings.update(updater); + touched.update(updater); + data.update(($data) => { + const newData = updater($data); + setTimeout(() => formNode && setForm(formNode, newData)); + return newData; + }); } - function setField(path: string, value?: FieldValue, touch = true): void { - data.update(($data) => _set($data, path, value)); - if (touch) { - setTouched(path); - isDirty.set(true); - } - if (!formNode) return; - for (const control of formNode.elements) { - if (!isFormControl(control) || !control.name) continue; - const elName = getPath(control); - if (path !== elName) continue; - setControlValue(control, value); - return; - } + function unsetField(path: string) { + updateAll((storeValue) => _unset(storeValue, path)); } - function getField(path: string) { - return _get(get(data), path); + function swapFields(path: string, from: number, to: number) { + updateAll((storeValue) => swapInArray(storeValue, path, from, to)); } - function setFields(values: Data): void { - data.set(_cloneDeep(values)); - if (formNode) setForm(formNode, values); + function moveField(path: string, from: number, to: number) { + updateAll((storeValue) => moveInArray(storeValue, path, from, to)); + } + + function resetField(path: string) { + const initialValue = _get(initialValues, path); + const touchedValue: any = _isPlainObject(initialValue) + ? deepSetTouched(initialValue as Obj, false) + : false; + const errValue: any = _isPlainObject(touchedValue) + ? deepSet(touchedValue, []) + : []; + data.update(($data) => { + const newData = _set($data, path, initialValue); + if (formNode) setForm(formNode, newData); + return newData; + }); + touched.update(($touched) => { + return _set($touched, path, touchedValue); + }); + errors.update(($errors) => { + return _set($errors, path, errValue); + }); + warnings.update(($warnings) => { + return _set($warnings, path, errValue); + }); } + const setIsSubmitting = createSetHelper(isSubmitting.update); + + const setIsDirty = createSetHelper(isDirty.update); + + const setInteracted = createSetHelper(interacted.update); + async function validate(): Promise | void> { const currentData = get(data); - touched.update((t) => { - return deepSet, boolean>(t, true) as Touched; - }); - const currentErrors = await executeValidation(currentData, config.validate); - const currentWarnings = await executeValidation(currentData, config.warn); - warnings.set(_merge(deepSet(currentData, null), currentWarnings || {})); - errors.set(currentErrors || {}); + touched.set(deepSetTouched(currentData, true)); + interacted.set(null); + const currentErrors = await validateErrors(currentData); + await validateWarnings(currentData); return currentErrors; } - let formNode: HTMLFormElement | undefined; - let initialValues = config.initialValues ?? ({} as Data); - function reset(): void { setFields(_cloneDeep(initialValues)); - touched.update(($touched) => deepSet($touched, false) as Touched); + setTouched(($touched) => deepSet($touched, false) as Touched); + interacted.set(null); isDirty.set(false); } - return { - public: { - reset, - setTouched, - setError, - setField, - setWarning, - getField, - setFields, - validate, - setInitialValues: (values: Data) => { - initialValues = values; - }, + const publicHelpers: Helpers = { + setData, + setFields, + setTouched, + setErrors, + setWarnings, + setIsSubmitting, + setIsDirty, + setInteracted, + validate, + reset, + unsetField, + resetField, + addField, + swapFields, + moveField, + setInitialValues: (values: Data) => { + initialValues = deepSetKey(values); }, - private: { - _setFormNode: (node: HTMLFormElement) => { - formNode = node; - }, - _getFormNode: () => formNode, - _getInitialValues: () => initialValues, + }; + + const privateHelpers = { + _setFormNode(node: HTMLFormElement) { + formNode = node; }, + _getFormNode: () => formNode, + _getInitialValues: () => initialValues, + }; + + return { + public: publicHelpers, + private: privateHelpers, }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ef373b52..353fb8f6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,7 @@ +export { get as getValueFromStore } from './get'; +export { FelteSubmitError } from './error'; +export type { Field, FieldConfig } from './create-field'; +export { createField } from './create-field'; export * from './create-form'; export * from '@felte/common'; +export * from './events'; diff --git a/packages/core/src/stores.ts b/packages/core/src/stores.ts index b2c1f3dd..96274946 100644 --- a/packages/core/src/stores.ts +++ b/packages/core/src/stores.ts @@ -1,128 +1,483 @@ import type { StoreFactory, Obj, + Keyed, FormConfig, Errors, Touched, + ValidationFunction, + PartialWritableErrors, + AssignableErrors, + KeyedWritable, } from '@felte/common'; +import type { Writable, Readable, Unsubscriber } from 'svelte/store'; import { _cloneDeep, deepSet, _isPlainObject, _mergeWith, - _merge, - executeValidation, + runValidations, + mergeErrors, + executeTransforms, deepSome, } from '@felte/common'; +import { deepSetTouched } from './deep-set-touched'; +import { deepRemoveKey, deepSetKey } from './deep-set-key'; -function errorFilterer( - errValue?: string | string[], - touchValue?: boolean | boolean[] -) { - if (_isPlainObject(touchValue)) return; +type ValidationController = { + signal: { + readonly aborted: boolean; + readonly priority: boolean; + }; + abort(): void; +}; + +function createValidationController(priority: boolean): ValidationController { + const signal = { aborted: false, priority }; + return { + signal, + abort() { + signal.aborted = true; + }, + }; +} + +export function errorFilterer(touchValue?: unknown, errValue?: unknown) { + if (_isPlainObject(touchValue)) { + if ( + !errValue || + (_isPlainObject(errValue) && Object.keys(errValue).length === 0) + ) { + return deepSet(touchValue, null); + } + return; + } if (Array.isArray(touchValue)) { if (touchValue.some(_isPlainObject)) return; const errArray = Array.isArray(errValue) ? errValue : []; - return touchValue.map((value, index) => (value && errArray[index]) || null); + return touchValue.map((value, index) => { + const err = errArray[index]; + if (Array.isArray(err) && err.length === 0) return null; + return (value && err) || null; + }); } - return (touchValue && errValue) || null; + if (Array.isArray(errValue) && errValue.length === 0) return null; + if (Array.isArray(errValue)) return touchValue ? errValue : null; + return touchValue && errValue ? [errValue] : null; +} + +export function warningFilterer(touchValue?: unknown, errValue?: unknown) { + if (_isPlainObject(touchValue)) { + if ( + !errValue || + (_isPlainObject(errValue) && Object.keys(errValue).length === 0) + ) { + return deepSet(touchValue, null); + } + return; + } + if (Array.isArray(touchValue)) { + if (touchValue.some(_isPlainObject)) return; + const errArray = Array.isArray(errValue) ? errValue : []; + return touchValue.map((_, index) => { + const err = errArray[index]; + if (Array.isArray(err) && err.length === 0) return null; + return err || null; + }); + } + if (Array.isArray(errValue) && errValue.length === 0) return null; + if (Array.isArray(errValue)) return errValue; + return errValue ? [errValue] : null; +} + +function filterErrors([errors, touched]: [ + Errors, + Touched +]) { + return _mergeWith>(touched, errors, errorFilterer); } -export function createStores( - storeFactory: StoreFactory, - config: FormConfig +function filterWarnings([errors, touched]: [ + Errors, + Touched +]) { + return _mergeWith>(touched, errors, warningFilterer); +} + +function debounce( + this: any, + func: (...v: T) => any, + timeout = 300 ) { - const initialValues = config.initialValues - ? _cloneDeep(config.initialValues) - : ({} as Data); - const data = storeFactory(initialValues); + let timer: NodeJS.Timeout; + return (...args: T) => { + clearTimeout(timer); + timer = setTimeout(() => { + func.apply(this, args); + }, timeout); + }; +} - const initialErrors: Errors = deepSet(initialValues, null); - const errors = storeFactory(initialErrors); +type Readables = + | Readable + | [Readable, ...Array>] + | Array>; - const filteredErrors = storeFactory(_cloneDeep(initialErrors)); +type ReadableValues = T extends Readable + ? [U] + : { [K in keyof T]: T[K] extends Readable ? U : never }; - const initialWarnings: Errors = deepSet(initialValues, null); - const warnings = storeFactory(initialWarnings); +type PossibleWritable = Readable & { + update?: (updater: (v: T) => T) => void; + set?: (v: T) => void; +}; - const initialTouched: Touched = deepSet(initialValues, false); +// A `derived` store factory that can defer subscription and be constructed +// with any store factory. +export function createDerivedFactory>( + storeFactory: StoreFactory +) { + return function derived( + storeOrStores: T, + deriver: (values: ReadableValues) => R, + initialValue: R + ): [PossibleWritable & StoreExt, () => void, () => void] { + const stores: Readable[] = Array.isArray(storeOrStores) + ? storeOrStores + : [storeOrStores]; + const values: any[] = new Array(stores.length); + const derivedStore: PossibleWritable & StoreExt = storeFactory( + initialValue + ); + + const storeSet = derivedStore.set as Writable['set']; + const storeSubscribe = derivedStore.subscribe; + let unsubscribers: Unsubscriber[] | undefined; + + function startStore() { + unsubscribers = stores.map((store, index) => { + return store.subscribe(($store: any) => { + values[index] = $store; + storeSet(deriver(values as ReadableValues)); + }); + }); + } + + function stopStore() { + unsubscribers?.forEach((unsub) => unsub()); + } + + derivedStore.subscribe = function subscribe( + subscriber: (value: R) => void + ) { + const unsubscribe = storeSubscribe(subscriber); + return () => { + unsubscribe(); + }; + }; + + return [derivedStore, startStore, stopStore]; + }; +} + +export function createStores>( + storeFactory: StoreFactory, + config: FormConfig & { preventStoreStart?: boolean } +) { + const derived = createDerivedFactory(storeFactory); + const initialValues = (config.initialValues = config.initialValues + ? deepSetKey( + executeTransforms( + _cloneDeep(config.initialValues as Data), + config.transform + ) + ) + : ({} as Data)); + const initialTouched = deepSetTouched(deepRemoveKey(initialValues), false); const touched = storeFactory(initialTouched); - const isValid = storeFactory(!config.validate); + const validationCount = storeFactory(0); + const [isValidating, startIsValidating, stopIsValidating] = derived( + [touched, validationCount], + ([$touched, $validationCount]) => { + const isTouched = deepSome($touched as Obj, (t) => !!t); + return isTouched && $validationCount >= 1; + }, + false + ); + + // It is important not to destructure stores created with the factory + // since some stores may be callable. + delete isValidating.set; + delete isValidating.update; + + function cancellableValidation( + store: PartialWritableErrors + ) { + let activeController: ValidationController | undefined; + return async function executeValidations( + $data: Data, + shape: Errors, + validations?: ValidationFunction[] | ValidationFunction, + priority = false + ) { + if (!validations || !$data) return; + let current = + shape && Object.keys(shape).length > 0 + ? shape + : (deepSet($data, []) as Errors); + + // Keeping a controller allows us to cancel previous asynchronous + // validations if they've become stale. + const controller = createValidationController(priority); + + // By assigning `priority` we can prevent specific validations + // from being aborted. Used when submitting the form or + // calling the `validate` helper. + if (!activeController?.signal.priority || priority) { + activeController?.abort(); + activeController = controller; + } + + // If the current controller has priority and we're not trying to + // override it, completely prevent validations + if (activeController.signal.priority && !priority) return; + validationCount.update((c) => c + 1); + const results = runValidations($data, validations); + results.forEach(async (promise: any) => { + const result = await promise; + if (controller.signal.aborted) return; + current = mergeErrors([current, result]); + store.set(current); + }); + await Promise.all(results); + activeController = undefined; + validationCount.update((c) => c - 1); + return current; + }; + } + + let storesShape = deepSet(initialTouched, []) as Errors; + const data = storeFactory(initialValues); + + const initialErrors = deepSet(initialTouched, []) as Errors; + const immediateErrors = storeFactory( + initialErrors + ) as PartialWritableErrors & StoreExt; + const debouncedErrors = storeFactory( + _cloneDeep(initialErrors) + ) as PartialWritableErrors & StoreExt; + const [errors, startErrors, stopErrors] = derived>( + [ + immediateErrors as Readable>, + debouncedErrors as Readable>, + ], + mergeErrors, + _cloneDeep(initialErrors) + ); + + const initialWarnings = deepSet(initialTouched, []) as Errors; + const immediateWarnings = storeFactory( + initialWarnings + ) as PartialWritableErrors & StoreExt; + const debouncedWarnings = storeFactory( + _cloneDeep(initialWarnings) + ) as PartialWritableErrors & StoreExt; + const [warnings, startWarnings, stopWarnings] = derived>( + [ + immediateWarnings as Readable>, + debouncedWarnings as Readable>, + ], + mergeErrors, + _cloneDeep(initialWarnings) + ); + + const [filteredErrors, startFilteredErrors, stopFilteredErrors] = derived( + [errors as Readable>, touched as Readable>], + filterErrors, + _cloneDeep(initialErrors) + ); + + const [ + filteredWarnings, + startFilteredWarnings, + stopFilteredWarnings, + ] = derived( + [warnings as Readable>, touched as Readable>], + filterWarnings, + _cloneDeep(initialWarnings) + ); + + // This is necessary since, on the first run, validations + // have not run yet. We assume the form is not valid in the first calling + // if there's validation functions assigned in the configuration. + let firstCalled = false; + const [isValid, startIsValid, stopIsValid] = derived( + errors, + ([$errors]) => { + if (!firstCalled) { + firstCalled = true; + return !config.validate && !config.debounced?.validate; + } else { + return !deepSome($errors, (error) => + Array.isArray(error) ? error.length >= 1 : !!error + ); + } + }, + !config.validate && !config.debounced?.validate + ); + + delete isValid.set; + delete isValid.update; const isSubmitting = storeFactory(false); const isDirty = storeFactory(false); - async function validateErrors($data?: Data) { - let currentErrors: Errors | undefined = {}; - if (!config.validate || !$data) return; - currentErrors = await executeValidation($data, config.validate); - errors.set(currentErrors || {}); - } + const interacted = storeFactory(null); + + const validateErrors = cancellableValidation(immediateErrors); + const validateWarnings = cancellableValidation(immediateWarnings); + const validateDebouncedErrors = cancellableValidation(debouncedErrors); + const validateDebouncedWarnings = cancellableValidation(debouncedWarnings); + const _validateDebouncedErrors = debounce( + validateDebouncedErrors, + config.debounced?.validateTimeout ?? config.debounced?.timeout + ); + const _validateDebouncedWarnings = debounce( + validateDebouncedWarnings, + config.debounced?.warnTimeout ?? config.debounced?.timeout + ); - async function validateWarnings($data?: Data) { - let currentWarnings: Errors | undefined = {}; - if (!config.warn || !$data) return; - currentWarnings = await executeValidation($data, config.warn); - warnings.set(_merge(deepSet($data, null), currentWarnings || {})); + async function executeErrorsValidation( + data: Data | Keyed, + altValidate?: ValidationFunction | ValidationFunction[] + ): Promise | undefined> { + const $data = deepRemoveKey(data); + const errors = validateErrors( + $data, + storesShape, + altValidate ?? config.validate, + true + ); + if (altValidate) return errors; + const debouncedErrors = validateDebouncedErrors( + $data, + storesShape, + config.debounced?.validate, + true + ); + return mergeErrors>( + await Promise.all([errors, debouncedErrors]) + ); } - const dataUnsubscriber = data.subscribe(($data) => { - validateErrors($data); - validateWarnings($data); - }); + async function executeWarningsValidation( + data: Data | Keyed, + altWarn?: ValidationFunction | ValidationFunction[] + ): Promise | undefined> { + const $data = deepRemoveKey(data); + const warnings = validateWarnings( + $data, + storesShape, + altWarn ?? config.warn, + true + ); + if (altWarn) return warnings; + const debouncedWarnings = validateDebouncedWarnings( + $data, + storesShape, + config.debounced?.warn, + true + ); + return mergeErrors>( + await Promise.all([warnings, debouncedWarnings]) + ); + } - let touchedValue = initialTouched; let errorsValue = initialErrors; - let firstCalled = false; - const errorsUnsubscriber = errors.subscribe(($errors) => { - if (!firstCalled) { - firstCalled = true; - isValid.set(!config.validate); - } else { - const hasErrors = deepSome($errors, (error) => !!error); - isValid.set(!hasErrors); + let warningsValue = initialWarnings; + function start() { + const dataUnsubscriber = data.subscribe(($keyedData) => { + const $data = deepRemoveKey($keyedData); + validateErrors($data, storesShape, config.validate); + validateWarnings($data, storesShape, config.warn); + _validateDebouncedErrors($data, storesShape, config.debounced?.validate); + _validateDebouncedWarnings($data, storesShape, config.debounced?.warn); + }); + + const unsubscribeTouched = touched.subscribe(($touched) => { + storesShape = deepSet($touched, []) as Errors; + }); + const unsubscribeErrors = errors.subscribe(($errors) => { + errorsValue = $errors; + }); + const unsubscribeWarnings = warnings.subscribe(($warnings) => { + warningsValue = $warnings; + }); + + startErrors(); + startIsValid(); + startWarnings(); + startFilteredErrors(); + startFilteredWarnings(); + startIsValidating(); + + function cleanup() { + dataUnsubscriber(); + stopFilteredErrors(); + stopErrors(); + stopWarnings(); + stopFilteredWarnings(); + stopIsValid(); + stopIsValidating(); + unsubscribeTouched(); + unsubscribeErrors(); + unsubscribeWarnings(); } + return cleanup; + } - errorsValue = $errors; - const mergedErrors = _mergeWith>( - $errors, - touchedValue, - errorFilterer - ); - filteredErrors.set(mergedErrors); - }); - - const touchedUnsubscriber = touched.subscribe(($touched) => { - touchedValue = $touched; - const mergedErrors = _mergeWith>( - errorsValue, - $touched, - errorFilterer - ); - filteredErrors.set(mergedErrors); - }); + function publicErrorsUpdater( + updater: (value: Errors) => AssignableErrors + ): void { + immediateErrors.set(updater(errorsValue)); + debouncedErrors.set({} as AssignableErrors); + } + + function publicWarningsUpdater( + updater: (value: Errors) => AssignableErrors + ): void { + immediateWarnings.set(updater(warningsValue)); + debouncedWarnings.set({} as AssignableErrors); + } - function cleanup() { - dataUnsubscriber(); - errorsUnsubscriber(); - touchedUnsubscriber(); + function publicErrorsSetter(value: AssignableErrors): void { + publicErrorsUpdater(() => value); } + function publicWarningsSetter(value: AssignableErrors): void { + publicWarningsUpdater(() => value); + } + + filteredErrors.set = publicErrorsSetter; + (filteredErrors as PartialWritableErrors).update = publicErrorsUpdater; + filteredWarnings.set = publicWarningsSetter; + (filteredWarnings as PartialWritableErrors).update = publicWarningsUpdater; + return { - data, - errors: { - ...filteredErrors, - set: errors.set, - update: errors.update, - subscribe: filteredErrors.subscribe, - }, - warnings, + data: data as KeyedWritable & StoreExt, + errors: filteredErrors as PartialWritableErrors & StoreExt, + warnings: filteredWarnings as PartialWritableErrors & StoreExt, touched, isValid, isSubmitting, isDirty, - cleanup, + isValidating, + interacted, + validateErrors: executeErrorsValidation, + validateWarnings: executeWarningsValidation, + cleanup: config.preventStoreStart ? () => undefined : start(), + start, }; } diff --git a/packages/core/tests/common.ts b/packages/core/tests/common.ts index 010f81d0..8583d409 100644 --- a/packages/core/tests/common.ts +++ b/packages/core/tests/common.ts @@ -1,3 +1,19 @@ +import 'uvu-expect-dom/extend'; +import { createForm as coreCreateForm, CoreForm } from '../src'; +import { writable } from 'svelte/store'; +import type { + FormConfig, + FormConfigWithTransformFn, + FormConfigWithoutTransformFn, + Obj, + UnknownStores, + Stores, + KnownStores, + Helpers, + UnknownHelpers, + KnownHelpers, +} from '@felte/common'; + export function createDOM(): void { const formElement = document.createElement('form'); formElement.name = 'test-form'; @@ -14,6 +30,7 @@ export type InputAttributes = { name?: string; value?: string; checked?: boolean; + index?: number; }; export function createInputElement(attrs: InputAttributes): HTMLInputElement { @@ -22,6 +39,8 @@ export function createInputElement(attrs: InputAttributes): HTMLInputElement { if (attrs.type) inputElement.type = attrs.type; if (attrs.value) inputElement.value = attrs.value; if (attrs.checked) inputElement.checked = attrs.checked; + if (typeof attrs.index !== 'undefined') + inputElement.name = `${attrs.name}.${attrs.index}.value`; inputElement.required = !!attrs.required; return inputElement; } @@ -38,9 +57,22 @@ export function createMultipleInputElements( ): HTMLInputElement[] { const inputs = []; for (let i = 0; i < amount; i++) { - const input = createInputElement(attr); - input.dataset.felteIndex = String(i); + const input = createInputElement({ ...attr, index: i }); inputs.push(input); } return inputs; } + +export function createForm( + config?: FormConfigWithTransformFn +): CoreForm & UnknownHelpers & UnknownStores; +export function createForm( + config?: FormConfigWithoutTransformFn +): CoreForm & KnownHelpers & KnownStores; +export function createForm( + config: FormConfig = {} +): CoreForm & Helpers & Stores { + return coreCreateForm(config as any, { + storeFactory: writable, + }); +} diff --git a/packages/core/tests/create-field.spec.ts b/packages/core/tests/create-field.spec.ts new file mode 100644 index 00000000..592fdf9a --- /dev/null +++ b/packages/core/tests/create-field.spec.ts @@ -0,0 +1,205 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { waitFor, screen } from '@testing-library/dom'; +import { createInputElement, createDOM, cleanupDOM } from './common'; +import { createField } from '../src'; + +function createContentEditable() { + const input = document.createElement('div'); + input.contentEditable = 'true'; + input.tabIndex = 0; + input.setAttribute('role', 'textbox'); + return input; +} + +const Field = suite('Custom controls with createField'); + +Field.before.each(createDOM); +Field.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +Field('adds hidden input when none is present', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + + expect(formElement.querySelector('input[name="test"]')).to.be.null; + + const { field } = createField('test'); + + field(inputElement); + + await waitFor(() => { + expect(formElement.querySelector('input[name="test"]')).not.to.be.null; + }); +}); + +Field('does not add hidden input when one is already present', () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const hiddenElement = createInputElement({ name: 'test', type: 'hidden' }); + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + formElement.appendChild(hiddenElement); + + expect(formElement.querySelectorAll('input[name="test"]').length).to.equal(1); + + const { field } = createField({ name: 'test' }); + + field(inputElement); + + expect(formElement.querySelectorAll('input[name="test"]').length).to.equal(1); +}); + +Field( + 'does not add hidden input when assigning to a native input', + async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ name: '', type: 'text' }); + formElement.appendChild(inputElement); + + expect(formElement.querySelector('input[name="test"]')).to.be.null; + + const { field } = createField({ name: 'test', touchOnChange: false }); + + field(inputElement); + + await waitFor(() => { + expect( + formElement.querySelectorAll('input[name="test"]').length + ).to.equal(1); + expect(formElement.querySelector('input[name="test"]')).to.be.visible; + }); + } +); + +Field('dispatches input events', async () => { + const inputListener = sinon.fake(); + const blurListener = sinon.fake(); + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + formElement.addEventListener('input', inputListener); + formElement.addEventListener('focusout', blurListener); + + const { field, onChange, onBlur } = createField('test'); + + expect(inputListener).to.have.not.been.called; + expect(blurListener).to.have.not.been.called; + + onChange('ignored value'); + onBlur(); + + expect(inputListener).to.have.not.been.called; + expect(blurListener).to.have.not.been.called; + + field(inputElement); + + await waitFor(() => { + const hiddenElement = document.querySelector( + 'input[name="test"]' + ) as HTMLInputElement; + + expect(hiddenElement).not.to.be.null; + + onChange('new value'); + + expect(hiddenElement.value).to.equal('new value'); + expect(inputListener).to.have.been.called.with( + expect.match({ + target: hiddenElement, + }) + ); + expect(blurListener).to.have.not.been.called; + + onBlur(); + + expect(blurListener).to.have.been.called.with( + expect.match({ + target: hiddenElement, + }) + ); + }); + + formElement.removeEventListener('input', inputListener); + formElement.removeEventListener('focusout', blurListener); +}); + +Field('dispatches change events', async () => { + const changeListener = sinon.fake(); + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + formElement.addEventListener('change', changeListener); + + const { field, onChange } = createField('test', { touchOnChange: true }); + + expect(changeListener).to.have.not.been.called; + + onChange('ignored value'); + + expect(changeListener).to.have.not.been.called; + + const { destroy } = field(inputElement); + + await waitFor(() => { + const hiddenElement = document.querySelector( + 'input[name="test"]' + ) as HTMLInputElement; + + expect(hiddenElement).not.to.be.null; + }); + + onChange('new value'); + expect(changeListener).to.have.been.called; + + formElement.removeEventListener('change', changeListener); + + destroy?.(); +}); + +Field('listens to hidden input attribute changes', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const hiddenElement = createInputElement({ name: 'test', type: 'hidden' }); + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + formElement.appendChild(hiddenElement); + + const { field } = createField('test'); + + field(inputElement); + + await new Promise((r) => setTimeout(r, 10)); + + hiddenElement.setAttribute('aria-invalid', 'true'); + expect(inputElement).to.be.valid; + await waitFor(() => { + expect(inputElement).to.be.invalid; + }); + hiddenElement.removeAttribute('aria-invalid'); + expect(inputElement).to.be.invalid; + await waitFor(() => { + expect(inputElement).to.be.valid; + }); + + hiddenElement.setAttribute('data-felte-validation-message', 'a message'); + await waitFor(() => { + expect(inputElement) + .to.have.attribute('data-felte-validation-message') + .that.equals('a message'); + }); + hiddenElement.removeAttribute('data-felte-validation-message'); + await waitFor(() => { + expect(inputElement).not.to.have.attribute('data-felte-validation-message'); + }); +}); + +Field('does nothing with unmounted element', () => { + const inputElement = createContentEditable(); + const { field } = createField('test'); + expect(field(inputElement)).to.have.property('destroy'); +}); + +Field.run(); diff --git a/packages/core/tests/dom-interactions.spec.ts b/packages/core/tests/dom-interactions.spec.ts new file mode 100644 index 00000000..ce6a9b60 --- /dev/null +++ b/packages/core/tests/dom-interactions.spec.ts @@ -0,0 +1,174 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { waitFor, screen } from '@testing-library/dom'; +import { + createInputElement, + createDOM, + cleanupDOM, + createForm, + createMultipleInputElements, +} from './common'; +import { get } from 'svelte/store'; + +const DomMutations = suite('Form action DOM mutations'); + +DomMutations.before.each(createDOM); +DomMutations.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +DomMutations('Adds novalidate to form when using a validate function', () => { + const { form } = createForm({ + validate: sinon.fake(), + onSubmit: sinon.fake(), + }); + const formElement = screen.getByRole('form') as HTMLFormElement; + expect(formElement).not.to.have.attribute('novalidate'); + + form(formElement); + + expect(formElement).to.have.attribute('novalidate'); +}); + +DomMutations( + 'Propagates felte-keep-on-remove attribute respecting specificity', + () => { + const { form } = createForm({ onSubmit: sinon.fake() }); + const outerFieldset = document.createElement('fieldset'); + outerFieldset.dataset.felteKeepOnRemove = 'false'; + const outerTextInput = createInputElement({ name: 'outerText' }); + const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); + outerSecondaryInput.dataset.felteKeepOnRemove = 'true'; + const innerFieldset = document.createElement('fieldset'); + const innerTextInput = createInputElement({ name: 'innerText' }); + const innerSecondaryinput = createInputElement({ name: 'innerSecondary' }); + innerSecondaryinput.dataset.felteKeepOnRemove = 'true'; + innerFieldset.append(innerTextInput, innerSecondaryinput); + outerFieldset.append(outerTextInput, outerSecondaryInput, innerFieldset); + const formElement = screen.getByRole('form') as HTMLFormElement; + formElement.appendChild(outerFieldset); + form(formElement); + [outerFieldset, outerTextInput, innerFieldset, innerTextInput].forEach( + (el) => { + expect(el) + .to.have.attribute('data-felte-keep-on-remove') + .that.equals('false'); + } + ); + [outerSecondaryInput, innerSecondaryinput].forEach((el) => { + expect(el) + .to.have.attribute('data-felte-keep-on-remove') + .that.equals('true'); + }); + } +); + +DomMutations('Keeps fields tagged with felte-keep-on-remove', async () => { + const { form, data } = createForm({ onSubmit: sinon.fake() }); + const outerFieldset = document.createElement('fieldset'); + outerFieldset.dataset.felteKeepOnRemove = 'false'; + const outerTextInput = createInputElement({ name: 'outerText' }); + const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); + outerSecondaryInput.dataset.felteKeepOnRemove = ''; + const multipleOuterInputs = createMultipleInputElements({ + name: 'multiple', + }); + multipleOuterInputs[1].dataset.felteKeepOnRemove = 'true'; + const innerFieldset = document.createElement('fieldset'); + const innerTextInput = createInputElement({ name: 'inner.innerText' }); + const innerSecondaryinput = createInputElement({ + name: 'inner.innerSecondary', + }); + innerSecondaryinput.dataset.felteKeepOnRemove = 'true'; + innerFieldset.append(innerTextInput, innerSecondaryinput); + outerFieldset.append( + ...multipleOuterInputs, + outerTextInput, + outerSecondaryInput, + innerFieldset + ); + const formElement = screen.getByRole('form') as HTMLFormElement; + formElement.appendChild(outerFieldset); + form(formElement); + expect(get(data)).to.deep.include({ + outerText: '', + outerSecondary: '', + inner: { + innerText: '', + innerSecondary: '', + }, + }); + expect(get(data)) + .to.have.a.property('multiple') + .that.is.an('array') + .with.lengthOf(3) + .and.has.a.nested.property('0.key') + .that.is.a('string'); + formElement.removeChild(outerFieldset); + await waitFor(() => { + expect(get(data)).to.deep.include({ + outerSecondary: '', + inner: { + innerSecondary: '', + }, + }); + expect(get(data)).to.have.a.property('multiple').with.lengthOf(1); + }); +}); + +DomMutations('Handles fields added after form load', async () => { + const { form, data } = createForm({ onSubmit: sinon.fake() }); + const outerFieldset = document.createElement('fieldset'); + outerFieldset.dataset.felteKeepOnRemove = 'false'; + const outerTextInput = createInputElement({ name: 'outerText' }); + const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); + outerSecondaryInput.dataset.felteKeepOnRemove = 'true'; + const innerFieldset = document.createElement('fieldset'); + const innerTextInput = createInputElement({ name: 'inner.innerText' }); + const innerSecondaryinput = createInputElement({ + name: 'inner.innerSecondary', + }); + innerSecondaryinput.dataset.felteKeepOnRemove = 'true'; + innerFieldset.append(innerTextInput, innerSecondaryinput); + outerFieldset.append(outerTextInput, outerSecondaryInput); + const formElement = screen.getByRole('form') as HTMLFormElement; + formElement.appendChild(outerFieldset); + form(formElement); + expect(get(data)).to.deep.equal({ + outerText: '', + outerSecondary: '', + }); + + formElement.appendChild(innerFieldset); + + await waitFor(() => { + expect(get(data)).to.deep.equal({ + outerText: '', + outerSecondary: '', + inner: { + innerText: '', + innerSecondary: '', + }, + }); + }); +}); + +DomMutations('Adds and removes event listeners', () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const { form } = createForm({ onSubmit: sinon.fake() }); + const addEventListener = sinon.fake(); + const removeEventListener = sinon.fake(); + formElement.addEventListener = addEventListener; + formElement.removeEventListener = removeEventListener; + sinon.assert.notCalled(addEventListener); + sinon.assert.notCalled(removeEventListener); + const { destroy } = form(formElement); + sinon.assert.callCount(addEventListener, 5); + sinon.assert.notCalled(removeEventListener); + destroy(); + sinon.assert.callCount(removeEventListener, 5); +}); + +DomMutations.run(); diff --git a/packages/core/tests/dom-interactions.test.ts b/packages/core/tests/dom-interactions.test.ts deleted file mode 100644 index 0b6c0a81..00000000 --- a/packages/core/tests/dom-interactions.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen, waitFor } from '@testing-library/dom'; -import { createForm as coreCreateForm } from '../src'; -import { - cleanupDOM, - createInputElement, - createDOM, - createMultipleInputElements, -} from './common'; -import { get, writable } from 'svelte/store'; -import type { FormConfig, Form, Obj } from '@felte/common'; - -function createForm(config: FormConfig): Form { - const { cleanup, ...rest } = coreCreateForm(config, { - storeFactory: writable, - }); - return rest; -} - -describe('Form action DOM mutations', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('Adds novalidate to form when using a validate function', () => { - const { form } = createForm({ - validate: jest.fn(), - onSubmit: jest.fn(), - }); - const formElement = screen.getByRole('form') as HTMLFormElement; - expect(formElement).not.toHaveAttribute('novalidate'); - - form(formElement); - - expect(formElement).toHaveAttribute('novalidate'); - }); - - test('Adds data-felte-fieldset to children of fieldset', () => { - const { form } = createForm({ - onSubmit: jest.fn(), - }); - const formElement = screen.getByRole('form') as HTMLFormElement; - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'user'; - const inputElement = document.createElement('input'); - inputElement.name = 'email'; - fieldsetElement.appendChild(inputElement); - formElement.appendChild(fieldsetElement); - form(formElement); - expect(inputElement).toHaveAttribute('data-felte-fieldset'); - }); - - test('Fieldsets can be nested', () => { - const { form } = createForm({ onSubmit: jest.fn() }); - const userFieldset = document.createElement('fieldset'); - userFieldset.name = 'user'; - const profileFieldset = document.createElement('fieldset'); - profileFieldset.name = 'profile'; - const emailInput = createInputElement({ type: 'email', name: 'email' }); - const passwordInput = createInputElement({ - type: 'password', - name: 'password', - }); - const nameInput = createInputElement({ name: 'name' }); - const bioInput = createInputElement({ name: 'bio' }); - profileFieldset.append(nameInput, bioInput); - userFieldset.append(emailInput, passwordInput, profileFieldset); - const formElement = screen.getByRole('form') as HTMLFormElement; - formElement.appendChild(userFieldset); - form(formElement); - [emailInput, passwordInput, profileFieldset].forEach((el) => { - expect(el).toHaveAttribute('data-felte-fieldset', 'user'); - }); - [nameInput, bioInput].forEach((el) => { - expect(el).toHaveAttribute('data-felte-fieldset', 'user.profile'); - }); - }); - - test('Propagates felte-unset-on-remove attribute respecting specificity', () => { - const { form } = createForm({ onSubmit: jest.fn() }); - const outerFieldset = document.createElement('fieldset'); - outerFieldset.dataset.felteUnsetOnRemove = 'true'; - const outerTextInput = createInputElement({ name: 'outerText' }); - const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); - outerSecondaryInput.dataset.felteUnsetOnRemove = 'false'; - const innerFieldset = document.createElement('fieldset'); - const innerTextInput = createInputElement({ name: 'innerText' }); - const innerSecondaryinput = createInputElement({ name: 'innerSecondary' }); - innerSecondaryinput.dataset.felteUnsetOnRemove = 'false'; - innerFieldset.append(innerTextInput, innerSecondaryinput); - outerFieldset.append(outerTextInput, outerSecondaryInput, innerFieldset); - const formElement = screen.getByRole('form') as HTMLFormElement; - formElement.appendChild(outerFieldset); - form(formElement); - [outerFieldset, outerTextInput, innerFieldset, innerTextInput].forEach( - (el) => { - expect(el).toHaveAttribute('data-felte-unset-on-remove', 'true'); - } - ); - [outerSecondaryInput, innerSecondaryinput].forEach((el) => { - expect(el).toHaveAttribute('data-felte-unset-on-remove', 'false'); - }); - }); - - test('Unsets fields tagged with felte-unset-on-remove', async () => { - const { form, data } = createForm({ onSubmit: jest.fn() }); - const outerFieldset = document.createElement('fieldset'); - outerFieldset.dataset.felteUnsetOnRemove = 'true'; - const outerTextInput = createInputElement({ name: 'outerText' }); - const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); - outerSecondaryInput.dataset.felteUnsetOnRemove = 'false'; - const multipleOuterInputs = createMultipleInputElements({ - name: 'multiple', - }); - multipleOuterInputs[1].dataset.felteUnsetOnRemove = 'false'; - const innerFieldset = document.createElement('fieldset'); - innerFieldset.name = 'inner'; - const innerTextInput = createInputElement({ name: 'innerText' }); - const innerSecondaryinput = createInputElement({ name: 'innerSecondary' }); - innerSecondaryinput.dataset.felteUnsetOnRemove = 'false'; - innerFieldset.append(innerTextInput, innerSecondaryinput); - outerFieldset.append( - ...multipleOuterInputs, - outerTextInput, - outerSecondaryInput, - innerFieldset - ); - const formElement = screen.getByRole('form') as HTMLFormElement; - formElement.appendChild(outerFieldset); - form(formElement); - expect(get(data)).toEqual({ - outerText: '', - outerSecondary: '', - multiple: ['', '', ''], - inner: { - innerText: '', - innerSecondary: '', - }, - }); - formElement.removeChild(outerFieldset); - await waitFor(() => { - expect(get(data)).toEqual({ - outerSecondary: '', - multiple: [undefined, '', undefined], - inner: { - innerSecondary: '', - }, - }); - }); - }); - - test('Handles fields added after form load', async () => { - const { form, data } = createForm({ onSubmit: jest.fn() }); - const outerFieldset = document.createElement('fieldset'); - outerFieldset.dataset.felteUnsetOnRemove = 'true'; - const outerTextInput = createInputElement({ name: 'outerText' }); - const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); - outerSecondaryInput.dataset.felteUnsetOnRemove = 'false'; - const innerFieldset = document.createElement('fieldset'); - innerFieldset.name = 'inner'; - const innerTextInput = createInputElement({ name: 'innerText' }); - const innerSecondaryinput = createInputElement({ name: 'innerSecondary' }); - innerSecondaryinput.dataset.felteUnsetOnRemove = 'false'; - innerFieldset.append(innerTextInput, innerSecondaryinput); - outerFieldset.append(outerTextInput, outerSecondaryInput); - const formElement = screen.getByRole('form') as HTMLFormElement; - formElement.appendChild(outerFieldset); - form(formElement); - expect(get(data)).toEqual({ - outerText: '', - outerSecondary: '', - }); - - formElement.appendChild(innerFieldset); - - await waitFor(() => { - expect(get(data)).toEqual({ - outerText: '', - outerSecondary: '', - inner: { - innerText: '', - innerSecondary: '', - }, - }); - }); - }); - - test('Adds and removes event listeners', () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const { form } = createForm({ onSubmit: jest.fn() }); - const addEventListener = jest.fn(); - const removeEventListener = jest.fn(); - formElement.addEventListener = addEventListener; - formElement.removeEventListener = removeEventListener; - expect(addEventListener).not.toHaveBeenCalled(); - expect(removeEventListener).not.toHaveBeenCalled(); - const { destroy } = form(formElement); - expect(addEventListener).toHaveBeenCalledTimes(4); - expect(removeEventListener).not.toHaveBeenCalled(); - destroy(); - expect(removeEventListener).toHaveBeenCalledTimes(4); - }); - - test('Listens to programmatic changes of inputs', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const textInput = createInputElement({ type: 'text', name: 'text' }); - const checkboxInput = createInputElement({ - type: 'checkbox', - name: 'checkbox', - }); - const fileInput = createInputElement({ - type: 'file', - name: 'file', - }); - formElement.append(textInput, checkboxInput, fileInput); - const { form, data } = createForm({ onSubmit: jest.fn() }); - form(formElement); - - expect(get(data)).toEqual({ - text: '', - checkbox: false, - file: undefined, - }); - - textInput.value = 'test'; - checkboxInput.checked = true; - fileInput.files = null; - - await waitFor(() => { - expect(get(data)).toEqual({ - text: 'test', - checkbox: true, - file: undefined, - }); - }); - }); -}); diff --git a/packages/core/tests/extenders.spec.ts b/packages/core/tests/extenders.spec.ts new file mode 100644 index 00000000..14207bc3 --- /dev/null +++ b/packages/core/tests/extenders.spec.ts @@ -0,0 +1,491 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { waitFor, screen } from '@testing-library/dom'; +import { get } from 'svelte/store'; +import { + createInputElement, + createDOM, + cleanupDOM, + createForm, +} from './common'; +import type { CurrentForm } from '@felte/common'; + +const Extenders = suite('Extenders'); + +Extenders.before.each(createDOM); +Extenders.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +Extenders('calls extender', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const mockExtenderHandler = { + destroy: sinon.fake(), + }; + const mockExtender = sinon.fake.returns(mockExtenderHandler); + const { + form, + data: { set, ...data }, + errors, + touched, + } = createForm({ + onSubmit: sinon.fake(), + extend: mockExtender, + }); + + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + stage: 'SETUP', + }) + ); + + sinon.assert.calledOnce(mockExtender); + + form(formElement); + + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + stage: 'MOUNT', + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.calledTwice(mockExtender); + + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + + formElement.appendChild(inputElement); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + stage: 'UPDATE', + errors, + touched, + form: formElement, + controls: sinon.match([inputElement]), + }) + ); + + sinon.assert.calledThrice(mockExtender); + + sinon.assert.calledOnce(mockExtenderHandler.destroy); + }); + + formElement.removeChild(inputElement); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + stage: 'UPDATE', + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.callCount(mockExtender, 4); + + sinon.assert.calledTwice(mockExtenderHandler.destroy); + }); +}); + +Extenders('calls multiple extenders', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const mockExtenderHandler = { + destroy: sinon.fake(), + }; + const mockExtenderHandlerNoD = {}; + const mockExtender = sinon.fake.returns(mockExtenderHandler); + const mockExtenderNoD = sinon.fake.returns(mockExtenderHandlerNoD); + const { + form, + data: { set, ...data }, + errors, + touched, + } = createForm({ + onSubmit: sinon.fake(), + extend: [mockExtender, mockExtenderNoD], + }); + + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + }) + ); + + sinon.assert.callCount(mockExtender, 1); + + sinon.assert.calledWith( + mockExtenderNoD, + sinon.match({ + data: sinon.match(data), + errors, + touched, + }) + ); + + sinon.assert.callCount(mockExtenderNoD, 1); + + const { destroy } = form(formElement); + + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.callCount(mockExtender, 2); + + sinon.assert.calledWith( + mockExtenderNoD, + sinon.match({ + data: sinon.match(data), + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.callCount(mockExtenderNoD, 2); + + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + + formElement.appendChild(inputElement); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + form: formElement, + controls: sinon.match([inputElement]), + }) + ); + + sinon.assert.callCount(mockExtender, 3); + + sinon.assert.callCount(mockExtenderHandler.destroy, 1); + }); + + formElement.removeChild(inputElement); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.callCount(mockExtender, 4); + + sinon.assert.callCount(mockExtenderHandler.destroy, 2); + }); + + destroy(); +}); + +Extenders('calls onSubmitError', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const mockExtenderHandler = { + onSubmitError: sinon.fake(), + }; + const mockExtender = sinon.fake(() => mockExtenderHandler); + const mockErrors = { account: { email: 'Not email' } }; + + const { form, data } = createForm({ + onSubmit: sinon.fake(() => { + throw mockErrors; + }), + onError: () => mockErrors, + extend: mockExtender, + }); + + form(formElement); + + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtenderHandler.onSubmitError, + sinon.match({ + data: sinon.match(get(data)), + errors: mockErrors, + }) + ); + }); +}); + +Extenders('calls onSubmitError on multiple extenders', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const mockExtenderHandler = { + onSubmitError: sinon.fake(), + }; + const mockExtender = sinon.fake(() => mockExtenderHandler); + const validate = sinon.stub(); + const mockErrors = { account: { email: 'Not email' } }; + const onSubmit = sinon.fake(() => { + throw mockErrors; + }); + + const { form, data } = createForm({ + onSubmit, + onError: () => mockErrors, + validate, + extend: [mockExtender, mockExtender], + }); + + form(formElement); + + formElement.submit(); + + await waitFor(() => { + sinon.assert.alwaysCalledWith( + mockExtenderHandler.onSubmitError, + sinon.match({ + data: get(data), + errors: mockErrors, + }) + ); + sinon.assert.callCount(mockExtenderHandler.onSubmitError, 2); + sinon.assert.callCount(onSubmit, 1); + }); + + validate.returns(mockErrors); + validate.resetHistory(); + + formElement.submit(); + + await waitFor(() => { + sinon.assert.alwaysCalledWith( + mockExtenderHandler.onSubmitError, + sinon.match({ + data: get(data), + errors: mockErrors, + }) + ); + sinon.assert.callCount(mockExtenderHandler.onSubmitError, 4); + sinon.assert.callCount(onSubmit, 1); + }); +}); + +Extenders( + 'adds validator when no validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + await validate(); + sinon.assert.callCount(validator, 1); + } +); + +Extenders( + 'adds validator when validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + validate: validator, + }); + await validate(); + sinon.assert.callCount(validator, 3); + } +); + +Extenders( + 'adds warn validator when no validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { level: 'warning' }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + await validate(); + sinon.assert.callCount(validator, 1); + } +); + +Extenders( + 'adds warn validator when validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { level: 'warning' }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + warn: validator, + }); + await validate(); + sinon.assert.callCount(validator, 3); + } +); + +// DEBOUNCED +Extenders( + 'adds debounced validator when no validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { debounced: true }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + await validate(); + sinon.assert.callCount(validator, 1); + } +); + +Extenders( + 'adds debounced validator when validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { debounced: true }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + validate: validator, + }); + await validate(); + sinon.assert.callCount(validator, 3); + } +); + +Extenders( + 'adds debounced warn validator when no validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { + level: 'warning', + debounced: true, + }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + await validate(); + sinon.assert.callCount(validator, 1); + } +); + +Extenders( + 'adds debounced warn validator when validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { + level: 'warning', + debounced: true, + }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + warn: validator, + }); + await validate(); + sinon.assert.callCount(validator, 3); + } +); + +Extenders( + 'adds transformer when no validators are present with addTransformer', + async () => { + const transformer = sinon.fake((v) => v); + function extender(currentForm: CurrentForm) { + currentForm.addTransformer(transformer); + return {}; + } + const { data } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + data.set({}); + sinon.assert.callCount(transformer, 1); + } +); + +Extenders( + 'adds transformer when validators are present with addTransformer', + async () => { + const transformer = sinon.fake((v) => v); + function extender(currentForm: CurrentForm) { + currentForm.addTransformer(transformer); + return {}; + } + const { data } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + transform: transformer, + }); + data.set({}); + sinon.assert.callCount(transformer, 2); + } +); + +Extenders.run(); diff --git a/packages/core/tests/extenders.test.ts b/packages/core/tests/extenders.test.ts deleted file mode 100644 index 8cc41d1b..00000000 --- a/packages/core/tests/extenders.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen, waitFor } from '@testing-library/dom'; -import { createForm as coreCreateForm } from '../src'; -import { cleanupDOM, createDOM, createInputElement } from './common'; -import { get, writable } from 'svelte/store'; -import type { CurrentForm, FormConfig, Form, Obj } from '@felte/common'; - -function createForm(config: FormConfig): Form { - const { cleanup, ...rest } = coreCreateForm(config, { - storeFactory: writable, - }); - return rest; -} - -describe('Extenders', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('calls extender', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const mockExtenderHandler = { - destroy: jest.fn(), - }; - const mockExtender = jest.fn(() => mockExtenderHandler); - const { - form, - data: { set, ...data }, - errors, - touched, - } = createForm({ - onSubmit: jest.fn(), - extend: mockExtender, - }); - - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(1); - - form(formElement); - - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(2); - - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - - formElement.appendChild(inputElement); - - await waitFor(() => { - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([inputElement]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(3); - - expect(mockExtenderHandler.destroy).toHaveBeenCalledTimes(1); - }); - - formElement.removeChild(inputElement); - - await waitFor(() => { - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(4); - - expect(mockExtenderHandler.destroy).toHaveBeenCalledTimes(2); - }); - }); - - test('calls multiple extenders', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const mockExtenderHandler = { - destroy: jest.fn(), - }; - const mockExtender = jest.fn(() => mockExtenderHandler); - const { - form, - data: { set, ...data }, - errors, - touched, - } = createForm({ - onSubmit: jest.fn(), - extend: [mockExtender, mockExtender], - }); - - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(2); - - form(formElement); - - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(4); - - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - - formElement.appendChild(inputElement); - - await waitFor(() => { - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([inputElement]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(6); - - expect(mockExtenderHandler.destroy).toHaveBeenCalledTimes(2); - }); - - formElement.removeChild(inputElement); - - await waitFor(() => { - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(8); - - expect(mockExtenderHandler.destroy).toHaveBeenCalledTimes(4); - }); - }); - - test('calls onSubmitError', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const mockExtenderHandler = { - onSubmitError: jest.fn(), - }; - const mockExtender = jest.fn(() => mockExtenderHandler); - const mockErrors = { account: { email: 'Not email' } }; - - const { form, data } = createForm({ - onSubmit: jest.fn(() => { - throw mockErrors; - }), - onError: () => mockErrors, - extend: mockExtender, - }); - - form(formElement); - - formElement.submit(); - - await waitFor(() => { - expect(mockExtenderHandler.onSubmitError).toHaveBeenCalledWith( - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - }); - }); - - test('calls onSubmitError on multiple extenders', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const mockExtenderHandler = { - onSubmitError: jest.fn(), - }; - const mockExtender = jest.fn(() => mockExtenderHandler); - const validate = jest.fn(); - const mockErrors = { account: { email: 'Not email' } }; - const onSubmit = jest.fn(() => { - throw mockErrors; - }); - - const { form, data } = createForm({ - onSubmit, - onError: () => mockErrors, - validate, - extend: [mockExtender, mockExtender], - }); - - form(formElement); - - formElement.submit(); - - await waitFor(() => { - expect(mockExtenderHandler.onSubmitError).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - expect(mockExtenderHandler.onSubmitError).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - expect(mockExtenderHandler.onSubmitError).toHaveBeenCalledTimes(2); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - - validate.mockImplementation(() => mockErrors); - - formElement.submit(); - - await waitFor(() => { - expect(mockExtenderHandler.onSubmitError).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - expect(mockExtenderHandler.onSubmitError).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - expect(mockExtenderHandler.onSubmitError).toHaveBeenCalledTimes(4); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - }); - - test('adds validator when no validators are present with addValidator', async () => { - const validator = jest.fn(); - function extender(currentForm: CurrentForm) { - currentForm.addValidator(validator); - return {}; - } - const { validate } = createForm({ - onSubmit: jest.fn(), - extend: extender, - }); - await validate(); - expect(validator).toHaveBeenCalledTimes(1); - }); - - test('adds validator when validators are present with addValidator', async () => { - const validator = jest.fn(); - function extender(currentForm: CurrentForm) { - currentForm.addValidator(validator); - return {}; - } - const { validate } = createForm({ - onSubmit: jest.fn(), - extend: extender, - validate: validator, - }); - await validate(); - expect(validator).toHaveBeenCalledTimes(3); - }); - - test('adds warn validator when no validators are present with addWarnValidator', async () => { - const validator = jest.fn(); - function extender(currentForm: CurrentForm) { - currentForm.addWarnValidator(validator); - return {}; - } - const { validate } = createForm({ - onSubmit: jest.fn(), - extend: extender, - }); - await validate(); - expect(validator).toHaveBeenCalledTimes(1); - }); - - test('adds warn validator when validators are present with addWarnValidator', async () => { - const validator = jest.fn(); - function extender(currentForm: CurrentForm) { - currentForm.addWarnValidator(validator); - return {}; - } - const { validate } = createForm({ - onSubmit: jest.fn(), - extend: extender, - warn: validator, - }); - await validate(); - expect(validator).toHaveBeenCalledTimes(3); - }); - - test('adds transformer when no validators are present with addTransformer', async () => { - const transformer = jest.fn((v) => v); - function extender(currentForm: CurrentForm) { - currentForm.addTransformer(transformer); - return {}; - } - const { data } = createForm({ - onSubmit: jest.fn(), - extend: extender, - }); - data.set({}); - expect(transformer).toHaveBeenCalledTimes(1); - }); - - test('adds transformer when validators are present with addTransformer', async () => { - const transformer = jest.fn((v) => v); - function extender(currentForm: CurrentForm) { - currentForm.addTransformer(transformer); - return {}; - } - const { data } = createForm({ - onSubmit: jest.fn(), - extend: extender, - transform: transformer, - }); - data.set({}); - expect(transformer).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/core/tests/helpers.spec.ts b/packages/core/tests/helpers.spec.ts new file mode 100644 index 00000000..220a2cfd --- /dev/null +++ b/packages/core/tests/helpers.spec.ts @@ -0,0 +1,669 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { waitFor, screen } from '@testing-library/dom'; +import { writable } from 'svelte/store'; +import { get } from '../src/get'; +import userEvent from '@testing-library/user-event'; +import { + createInputElement, + createMultipleInputElements, + createDOM, + cleanupDOM, + createForm, +} from './common'; + +const Helpers = suite('Helpers'); + +Helpers.before.each(createDOM); + +Helpers.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +Helpers('setFields should update and touch field', () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const fieldsetElement = document.createElement('fieldset'); + const inputElement = createInputElement({ + name: 'account.email', + value: '', + type: 'text', + }); + fieldsetElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + type Data = { + account: { + email: string; + }; + }; + const { form, data, touched, setFields } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + setFields('account.email', 'jacek@soplica.com', true); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + }, + }); + + form(formElement); + + expect(get(data).account.email).to.equal(''); + expect(inputElement.value).to.equal(''); + + setFields('account.email', 'jacek@soplica.com', true); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(inputElement.value).to.equal('jacek@soplica.com'); +}); + +Helpers('setField should update without touching field', () => { + type Data = { + account: { + email: string; + }; + }; + const { data, touched, setFields } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + setFields('account.email', 'jacek@soplica.com', false); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(get(touched)).to.deep.equal({ + account: { + email: false, + }, + }); +}); + +Helpers('setFields should set all fields', () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const fieldsetElement = document.createElement('fieldset'); + const inputElement = createInputElement({ + name: 'account.email', + value: '', + type: 'text', + }); + fieldsetElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + type Data = { + account: { + email: string; + }; + }; + const { form, data, touched, setFields } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + setFields({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + + form(formElement); + + expect(get(data).account.email).to.equal(''); + expect(inputElement.value).to.equal(''); + + setFields({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(inputElement.value).to.equal('jacek@soplica.com'); +}); + +Helpers('setTouched should touch field', () => { + type Data = { + account: { + email: string; + }; + }; + const { touched, setTouched } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(touched).account.email).to.equal(false); + setTouched('account.email', true); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + }, + }); +}); + +Helpers('setError should set a field error when touched', () => { + type Data = { + account: { + email: string; + }; + }; + const { errors, touched, setErrors, setTouched } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(errors)?.account?.email).to.be.null; + setErrors('account.email', 'Not an email'); + expect(get(errors)).to.deep.equal({ + account: { + email: null, + }, + }); + setTouched('account.email', () => true); + expect(get(touched).account.email).to.equal(true); + expect(get(errors)).to.deep.equal({ + account: { + email: ['Not an email'], + }, + }); +}); + +Helpers('setWarning should set a field warning', () => { + type Data = { + account: { + email: string; + }; + }; + const { warnings, setWarnings } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(warnings)?.account?.email).to.be.null; + setWarnings('account.email', 'Not an email'); + expect(get(warnings)).to.deep.equal({ + account: { + email: ['Not an email'], + }, + }); +}); + +Helpers('validate should force validation', async () => { + type Data = { + account: { + email: string; + }; + }; + const mockErrors = { account: { email: 'Not email' } }; + const mockValidate = sinon.stub().returns(mockErrors); + const { errors, touched, validate } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + validate: mockValidate, + onSubmit: sinon.fake(), + }); + + sinon.assert.calledOnce(mockValidate); + validate(); + sinon.assert.calledTwice(mockValidate); + await waitFor(() => { + expect(get(errors)).to.deep.equal({ account: { email: ['Not email'] } }); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + }, + }); + }); + + mockValidate.returns({}); + validate(); + sinon.assert.calledThrice(mockValidate); + await waitFor(() => { + expect(get(errors)).to.deep.equal({ account: { email: null } }); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + }, + }); + }); +}); + +Helpers('reset should reset form to default values', () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const accountFieldset = document.createElement('fieldset'); + const emailInput = createInputElement({ + name: 'account.email', + type: 'text', + value: '', + }); + accountFieldset.appendChild(emailInput); + formElement.appendChild(accountFieldset); + type Data = { + account: { + email: string; + }; + }; + const { data, touched, reset, form, isDirty, setFields } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + + setFields('account.email', 'jacek@soplica.com', true); + + expect(get(data).account.email).to.equal('jacek@soplica.com'); + + expect(get(touched).account.email).to.equal(true); + + reset(); + + expect(get(data)).to.deep.equal({ + account: { + email: '', + }, + }); + + expect(get(touched)).to.deep.equal({ + account: { + email: false, + }, + }); + + expect(get(isDirty)).to.equal(false); + + form(formElement); + + expect(get(data)).to.deep.equal({ + account: { + email: '', + }, + }); + + expect(get(isDirty)).to.equal(false); + + userEvent.click(emailInput); + userEvent.click(formElement); + + expect(get(isDirty)).to.equal(false); + + userEvent.type(emailInput, 'jacek@soplica.com'); + expect(get(data).account.email).to.equal('jacek@soplica.com'); + + expect(get(isDirty)).to.equal(true); + + reset(); + + expect(get(data)).to.deep.equal({ + account: { + email: '', + }, + }); + + expect(get(touched)).to.deep.equal({ + account: { + email: false, + }, + }); + + expect(get(isDirty)).to.equal(false); + + userEvent.click(emailInput); + userEvent.click(formElement); + + expect(get(isDirty)).to.equal(false); + + userEvent.type(emailInput, 'jacek@soplica.com'); + expect(get(data).account.email).to.equal('jacek@soplica.com'); + + expect(get(isDirty)).to.equal(true); + + formElement.reset(); + + expect(get(data)).to.deep.equal({ + account: { + email: '', + }, + }); + + expect(get(touched)).to.deep.equal({ + account: { + email: false, + }, + }); + + expect(get(isDirty)).to.equal(false); +}); + +Helpers('setInitialValues sets new initial values', () => { + type Data = { + account: { + email: string; + }; + }; + const { + data, + setInitialValues, + touched, + setFields, + reset, + } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + + setInitialValues({ account: { email: 'zaphod@beeblebrox.com' } }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + + setFields('account.email', 'jacek@soplica.com', true); + + expect(get(data).account.email).to.equal('jacek@soplica.com'); + expect(get(touched).account.email).to.equal(true); + + reset(); + + expect(get(data).account.email).to.equal('zaphod@beeblebrox.com'); + expect(get(touched).account.email).to.equal(false); +}); + +Helpers('get gets current value of store', () => { + const store = writable(true); + + expect(get(store)).to.equal(true); + + const originalSubscribe = store.subscribe; + const rxStore = { + subscribe(subscriber: any) { + const unsubscribe = originalSubscribe(subscriber); + return { unsubscribe }; + }, + }; + expect(get(rxStore as any)).to.equal(true); +}); + +Helpers('unsetField removes a field from all stores', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const fieldsetElement = document.createElement('fieldset'); + const inputElement = createInputElement({ + name: 'account.email', + value: '', + type: 'text', + }); + fieldsetElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + type Data = { + account: { + email: string; + }; + }; + const { + form, + data, + touched, + errors, + warnings, + unsetField, + } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + form(formElement); + + userEvent.type(inputElement, 'zaphod@beeblebrox.com'); + + await waitFor(() => { + expect(get(data).account.email).to.equal('zaphod@beeblebrox.com'); + }); + + unsetField('account.email'); + + await waitFor(() => { + expect(get(data)).to.deep.equal({ account: {} }); + expect(get(touched)).to.deep.equal({ account: {} }); + expect(get(errors)).to.deep.equal({ account: {} }); + expect(get(warnings)).to.deep.equal({ account: {} }); + expect(inputElement).to.not.have.a.value; + }); +}); + +Helpers('resetField resets a field to its initial value', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const fieldsetElement = document.createElement('fieldset'); + const inputElement = createInputElement({ + name: 'account.email', + value: '', + type: 'text', + }); + fieldsetElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + type Data = { + account: { + email: string; + }; + }; + const { form, data, touched, errors, resetField } = createForm({ + initialValues: { + account: { + email: 'zaphod@beeblebrox.com', + }, + }, + onSubmit: sinon.fake(), + }); + + form(formElement); + + userEvent.clear(inputElement); + userEvent.type(inputElement, 'jacek@soplica.com'); + userEvent.click(formElement); + + errors.set({ account: { email: 'Error' } }); + + await waitFor(() => { + expect(get(data).account.email).to.equal('jacek@soplica.com'); + expect(get(touched).account.email).to.equal(true); + expect(get(errors).account?.email).to.deep.equal(['Error']); + }); + + resetField('account.email'); + + await waitFor(() => { + expect(get(data).account.email).to.equal('zaphod@beeblebrox.com'); + expect(get(touched).account.email).to.equal(false); + expect(get(errors).account?.email).to.equal(null); + expect(inputElement).to.have.value.that.equals('zaphod@beeblebrox.com'); + }); +}); + +Helpers( + 'addField and unsetField add and remove fields accordingly', + async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const multipleInputs = createMultipleInputElements( + { + name: 'todos', + }, + 3 + ); + formElement.append(...multipleInputs); + type Data = { + todos: { + value: string; + }[]; + }; + const { + form, + data, + touched, + errors, + addField, + unsetField, + swapFields, + moveField, + } = createForm({ + initialValues: { + todos: new Array(3).fill({ value: '' }), + }, + onSubmit: sinon.fake(), + }); + + form(formElement); + + userEvent.type(multipleInputs[0], 'First todo'); + userEvent.type(multipleInputs[1], 'Third todo'); + userEvent.type(multipleInputs[2], 'Fourth todo'); + + errors.set({ + todos: [ + { + value: '', + }, + { + value: 'Invalid', + }, + { + value: '', + }, + ], + }); + + await waitFor(() => { + expect(get(data).todos[1].value).to.equal('Third todo'); + expect(get(touched).todos[1].value).to.equal(true); + expect(get(errors).todos?.[1].value).to.deep.equal(['Invalid']); + }); + + addField('todos', { value: 'Second todo' }, 1); + addField('todos.1', 'ignored'); + + await waitFor(() => { + expect(get(data).todos[1].value).to.equal('Second todo'); + expect(get(touched).todos[1].value).to.equal(false); + expect(get(errors).todos?.[1].value).to.equal(null); + expect(multipleInputs[1]).to.have.value.that.equals('Second todo'); + expect(get(data).todos[2].value).to.equal('Third todo'); + expect(get(touched).todos[2].value).to.equal(true); + expect(get(errors).todos?.[2].value).to.deep.equal(['Invalid']); + expect(multipleInputs[2]).to.have.value.that.equals('Third todo'); + }); + + unsetField('todos.2.'); + + await waitFor(() => { + expect(get(data).todos[1].value).to.equal('Second todo'); + expect(get(touched).todos[1].value).to.equal(false); + expect(get(errors).todos?.[1].value).to.equal(null); + expect(multipleInputs[1]).to.have.value.that.equals('Second todo'); + expect(get(data).todos[2].value).to.equal('Fourth todo'); + expect(get(touched).todos[2].value).to.equal(false); + expect(get(errors).todos?.[2].value).to.equal(null); + expect(multipleInputs[2]).to.have.value.that.equals('Fourth todo'); + }); + + addField('todos', { value: 'Fifth todo' }); + + await waitFor(() => { + expect(get(data).todos[2].value).to.equal('Fourth todo'); + expect(get(touched).todos[2].value).to.equal(false); + expect(get(errors).todos[2].value).to.equal(null); + expect(multipleInputs[2]).to.have.value.that.equals('Fourth todo'); + expect(get(data).todos[3].value).to.equal('Fifth todo'); + expect(get(touched).todos[3].value).to.equal(false); + expect(get(errors).todos[3].value).to.equal(null); + }); + + swapFields('todos', 1, 3); + + await waitFor(() => { + expect(get(data).todos[3].value).to.equal('Second todo'); + expect(get(touched).todos[3].value).to.equal(false); + expect(get(errors).todos?.[3].value).to.equal(null); + expect(get(data).todos[1].value).to.equal('Fifth todo'); + expect(get(touched).todos[1].value).to.equal(false); + expect(get(errors).todos[1].value).to.equal(null); + }); + + moveField('todos', 3, 0); + + await waitFor(() => { + expect(get(data).todos[0].value).to.equal('Second todo'); + expect(get(touched).todos[0].value).to.equal(false); + expect(get(errors).todos?.[0].value).to.equal(null); + expect(get(data).todos[1].value).to.equal('First todo'); + expect(get(touched).todos[1].value).to.equal(true); + expect(get(errors).todos?.[1].value).to.equal(null); + }); + } +); + +Helpers.run(); diff --git a/packages/core/tests/helpers.test.ts b/packages/core/tests/helpers.test.ts deleted file mode 100644 index 57e8e695..00000000 --- a/packages/core/tests/helpers.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { waitFor, screen } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import { createForm as coreCreateForm } from '../src'; -import { createInputElement, createDOM, cleanupDOM } from './common'; -import { writable } from 'svelte/store'; -import { get } from '../src/get'; -import type { FormConfig, Form, Obj } from '@felte/common'; - -function createForm(config: FormConfig): Form { - const { cleanup, ...rest } = coreCreateForm(config, { - storeFactory: writable, - }); - return rest; -} - -describe('Helpers', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('setField should update and touch field', () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'account'; - const inputElement = createInputElement({ - name: 'email', - value: '', - type: 'text', - }); - fieldsetElement.appendChild(inputElement); - formElement.appendChild(fieldsetElement); - type Data = { - account: { - email: string; - }; - }; - const { form, data, touched, setField } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - setField('account.email', 'jacek@soplica.com'); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(get(touched)).toEqual({ - account: { - email: true, - }, - }); - - form(formElement); - - expect(get(data).account.email).toBe(''); - expect(inputElement.value).toBe(''); - - setField('account.email', 'jacek@soplica.com'); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(inputElement.value).toBe('jacek@soplica.com'); - }); - - test('setField should update without touching field', () => { - type Data = { - account: { - email: string; - }; - }; - const { data, touched, setField } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - setField('account.email', 'jacek@soplica.com', false); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(get(touched)).toEqual({ - account: { - email: false, - }, - }); - }); - - test('setFields should set all fields', () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'account'; - const inputElement = createInputElement({ - name: 'email', - value: '', - type: 'text', - }); - fieldsetElement.appendChild(inputElement); - formElement.appendChild(fieldsetElement); - type Data = { - account: { - email: string; - }; - }; - const { form, data, touched, setFields } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - setFields({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - - form(formElement); - - expect(get(data).account.email).toBe(''); - expect(inputElement.value).toBe(''); - - setFields({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(inputElement.value).toBe('jacek@soplica.com'); - }); - - test('setTouched should touch field', () => { - type Data = { - account: { - email: string; - }; - }; - const { touched, setTouched } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(touched).account.email).toBe(false); - setTouched('account.email'); - expect(get(touched)).toEqual({ - account: { - email: true, - }, - }); - }); - - test('setError should set a field error when touched', () => { - type Data = { - account: { - email: string; - }; - }; - const { errors, touched, setError, setTouched } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(errors)?.account?.email).toBeFalsy(); - setError('account.email', 'Not an email'); - expect(get(errors)).toEqual({ - account: { - email: null, - }, - }); - setTouched('account.email'); - expect(get(touched).account.email).toBe(true); - expect(get(errors)).toEqual({ - account: { - email: 'Not an email', - }, - }); - }); - - test('setWarning should set a field warning', () => { - type Data = { - account: { - email: string; - }; - }; - const { warnings, setWarning } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(warnings)?.account?.email).toBeFalsy(); - setWarning('account.email', 'Not an email'); - expect(get(warnings)).toEqual({ - account: { - email: 'Not an email', - }, - }); - }); - - test('validate should force validation', async () => { - type Data = { - account: { - email: string; - }; - }; - const mockErrors = { account: { email: 'Not email' } }; - const mockValidate = jest.fn(() => mockErrors); - const { errors, touched, validate } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - validate: mockValidate, - onSubmit: jest.fn(), - }); - - expect(mockValidate).toHaveBeenCalledTimes(1); - validate(); - expect(mockValidate).toHaveBeenCalledTimes(2); - await waitFor(() => { - expect(get(errors)).toEqual(mockErrors); - expect(get(touched)).toEqual({ - account: { - email: true, - }, - }); - }); - - mockValidate.mockImplementation(() => ({} as any)); - validate(); - expect(mockValidate).toHaveBeenCalledTimes(3); - await waitFor(() => { - expect(get(errors)).toEqual({ account: { email: null } }); - expect(get(touched)).toEqual({ - account: { - email: true, - }, - }); - }); - }); - - test('setting directly to data should touch value', () => { - type Data = { - account: { - email: string; - password: string; - }; - }; - const { data, touched } = createForm({ - initialValues: { - account: { - email: '', - password: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(data).account.password).toBe(''); - - data.set({ - account: { email: 'jacek@soplica.com', password: '' }, - }); - - expect(get(data).account.email).toBe('jacek@soplica.com'); - expect(get(data).account.password).toBe(''); - - expect(get(touched)).toEqual({ - account: { - email: true, - password: false, - }, - }); - }); - - test('reset should reset form to default values', () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const accountFieldset = document.createElement('fieldset'); - accountFieldset.name = 'account'; - const emailInput = createInputElement({ - name: 'email', - type: 'text', - value: '', - }); - accountFieldset.appendChild(emailInput); - formElement.appendChild(accountFieldset); - type Data = { - account: { - email: string; - }; - }; - const { data, touched, reset, form, isDirty } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - - expect(get(isDirty)).toBe(false); - - data.set({ - account: { email: 'jacek@soplica.com' }, - }); - - expect(get(data).account.email).toBe('jacek@soplica.com'); - - expect(get(isDirty)).toBe(true); - - reset(); - - expect(get(data)).toEqual({ - account: { - email: '', - }, - }); - - expect(get(touched)).toEqual({ - account: { - email: false, - }, - }); - - expect(get(isDirty)).toBe(false); - - form(formElement); - - expect(get(data)).toEqual({ - account: { - email: '', - }, - }); - - expect(get(isDirty)).toBe(false); - - userEvent.click(emailInput); - userEvent.click(formElement); - - expect(get(isDirty)).toBe(false); - - userEvent.type(emailInput, 'jacek@soplica.com'); - expect(get(data).account.email).toBe('jacek@soplica.com'); - - expect(get(isDirty)).toBe(true); - - reset(); - - expect(get(data)).toEqual({ - account: { - email: '', - }, - }); - - expect(get(touched)).toEqual({ - account: { - email: false, - }, - }); - - expect(get(isDirty)).toBe(false); - }); - - test('getField should get the value of a field', () => { - type Data = { - account: { - email: string; - }; - }; - const { data, getField } = createForm({ - initialValues: { - account: { - email: 'jacek@soplica.com', - }, - }, - onSubmit: jest.fn(), - }); - expect(get(data).account.email).toBe(getField('account.email')); - }); - - test('setInitialValues sets new initial values', () => { - type Data = { - account: { - email: string; - }; - }; - const { - data, - setInitialValues, - touched, - isDirty, - reset, - } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - expect(get(isDirty)).toBe(false); - - setInitialValues({ account: { email: 'zaphod@beeblebrox.com' } }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - expect(get(isDirty)).toBe(false); - - data.set({ account: { email: 'jacek@soplica.com' } }); - - expect(get(data).account.email).toBe('jacek@soplica.com'); - expect(get(touched).account.email).toBe(true); - expect(get(isDirty)).toBe(true); - - reset(); - - expect(get(data).account.email).toBe('zaphod@beeblebrox.com'); - expect(get(touched).account.email).toBe(false); - expect(get(isDirty)).toBe(false); - }); - - test('get gets current value of store', () => { - const store = writable(true); - - expect(get(store)).toBe(true); - - const originalSubscribe = store.subscribe; - const rxStore = { - subscribe(subscriber: any) { - const unsubscribe = originalSubscribe(subscriber); - return { unsubscribe }; - }, - }; - expect(get(rxStore)).toBe(true); - }); -}); diff --git a/packages/core/tests/stores.spec.ts b/packages/core/tests/stores.spec.ts new file mode 100644 index 00000000..3ddde812 --- /dev/null +++ b/packages/core/tests/stores.spec.ts @@ -0,0 +1,106 @@ +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { waitFor } from '@testing-library/dom'; +import { createStores, errorFilterer, warningFilterer } from '../src/stores'; +import { writable, get } from 'svelte/store'; + +const Stores = suite('createStores'); + +Stores('filters errors', async () => { + const { start, cleanup, errors, touched, warnings } = createStores(writable, { + initialValues: { + arr: [ + { + value: '', + }, + ], + strings: ['test'], + }, + preventStoreStart: true, + }); + const clean = start(); + await waitFor(() => { + expect(get(errors)).to.deep.equal({ + arr: [ + { + value: null, + }, + ], + strings: null, + }); + expect(get(warnings)).to.deep.equal({ + arr: [ + { + value: null, + }, + ], + strings: null, + }); + }); + errors.set({ + arr: [ + { + value: 'test error', + }, + ], + strings: [], + }); + touched.set({ + arr: [ + { + value: true, + }, + ], + strings: [true], + }); + await waitFor(() => { + expect(get(errors)).to.deep.equal({ + arr: [ + { + value: ['test error'], + }, + ], + strings: [null], + }); + }); + cleanup(); + clean(); +}); + +const Filterers = suite('Filterers'); + +Filterers('errorFilterer', () => { + expect(errorFilterer({ touched: true }, undefined)).to.deep.equal({ + touched: null, + }); + expect(errorFilterer(['test'], undefined)).to.deep.equal([null]); + expect(errorFilterer(['test'], ['err'])).to.deep.equal(['err']); + expect(errorFilterer(['test'], [['err']])).to.deep.equal([['err']]); + expect(errorFilterer(['test'], [[]])).to.deep.equal([null]); + expect(errorFilterer(undefined, [])).to.deep.equal(null); + expect(errorFilterer(undefined, ['err'])).to.deep.equal(null); + expect(errorFilterer(true, ['err'])).to.deep.equal(['err']); + expect(errorFilterer(true, 'err')).to.deep.equal(['err']); + expect(errorFilterer(false, 'err')).to.deep.equal(null); + expect(errorFilterer(true, '')).to.deep.equal(null); +}); + +Filterers('warningFilterer', () => { + expect(warningFilterer({ touched: true }, undefined)).to.deep.equal({ + touched: null, + }); + expect(warningFilterer(['test'], undefined)).to.deep.equal([null]); + expect(warningFilterer(['test'], ['err'])).to.deep.equal(['err']); + expect(warningFilterer(['test'], [['err']])).to.deep.equal([['err']]); + expect(warningFilterer(['test'], [[]])).to.deep.equal([null]); + expect(warningFilterer(undefined, [])).to.deep.equal(null); + expect(warningFilterer(undefined, ['err'])).to.deep.equal(['err']); + expect(warningFilterer(true, ['err'])).to.deep.equal(['err']); + expect(warningFilterer(true, 'err')).to.deep.equal(['err']); + expect(warningFilterer(false, 'err')).to.deep.equal(['err']); + expect(warningFilterer(true, '')).to.deep.equal(null); +}); + +Stores.run(); + +Filterers.run(); diff --git a/packages/core/tests/user-interactions.spec.ts b/packages/core/tests/user-interactions.spec.ts new file mode 100644 index 00000000..e92e51ab --- /dev/null +++ b/packages/core/tests/user-interactions.spec.ts @@ -0,0 +1,1146 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { waitFor, screen } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { + createInputElement, + createDOM, + cleanupDOM, + createForm, + createMultipleInputElements, +} from './common'; +import { get } from 'svelte/store'; +import { isFormControl } from '@felte/common'; +import { FelteSubmitError } from '../src'; + +function createSelectElement({ + name, + options, +}: { + name: string; + options: string[]; +}) { + const selectElement = document.createElement('select'); + selectElement.name = name; + const optionElements = options.map((option) => { + const element = document.createElement('option'); + element.value = option; + return element; + }); + selectElement.append(...optionElements); + return selectElement; +} + +function createLoginForm() { + const formElement = screen.getByRole('form') as HTMLFormElement; + const emailInput = createInputElement({ + name: 'account.email', + type: 'email', + }); + const passwordInput = createInputElement({ + name: 'account.password', + type: 'password', + }); + const submitInput = createInputElement({ type: 'submit' }); + const accountFieldset = document.createElement('fieldset'); + accountFieldset.append(emailInput, passwordInput); + formElement.append(accountFieldset, submitInput); + return { formElement, emailInput, passwordInput, submitInput }; +} + +function createSignupForm() { + const formElement = screen.getByRole('form') as HTMLFormElement; + const emailInput = createInputElement({ + name: 'account.email', + type: 'email', + }); + const passwordInput = createInputElement({ + name: 'account.password', + type: 'password', + }); + const showPasswordInput = createInputElement({ + name: 'account.showPassword', + type: 'checkbox', + }); + const confirmPasswordInput = createInputElement({ + name: 'account.confirmPassword', + type: 'password', + }); + const publicEmailYesRadio = createInputElement({ + name: 'account.publicEmail', + value: 'yes', + type: 'radio', + }); + const publicEmailNoRadio = createInputElement({ + name: 'account.publicEmail', + value: 'no', + type: 'radio', + }); + const accountTypeElement = createSelectElement({ + name: 'account.accountType', + options: ['user', 'admin'], + }); + const accountFieldset = document.createElement('fieldset'); + accountFieldset.append( + emailInput, + passwordInput, + showPasswordInput, + publicEmailYesRadio, + publicEmailNoRadio, + confirmPasswordInput, + accountTypeElement + ); + formElement.appendChild(accountFieldset); + const profileFieldset = document.createElement('fieldset'); + const firstNameInput = createInputElement({ name: 'profile.firstName' }); + const lastNameInput = createInputElement({ name: 'profile.lastName' }); + const bioInput = createInputElement({ name: 'profile.bio' }); + profileFieldset.append(firstNameInput, lastNameInput, bioInput); + formElement.appendChild(profileFieldset); + const pictureInput = createInputElement({ + name: 'profile.picture', + type: 'file', + }); + formElement.appendChild(pictureInput); + const extraPicsInput = createInputElement({ + name: 'extra.pictures', + type: 'file', + }); + extraPicsInput.multiple = true; + formElement.appendChild(extraPicsInput); + const submitInput = createInputElement({ type: 'submit' }); + const techCheckbox = createInputElement({ + type: 'checkbox', + name: 'preferences', + value: 'technology', + }); + const filmsCheckbox = createInputElement({ + type: 'checkbox', + name: 'preferences', + value: 'films', + }); + formElement.append(techCheckbox, filmsCheckbox, submitInput); + const multipleFieldsetElement = document.createElement('fieldset'); + const extraTextInputs = createMultipleInputElements({ + type: 'text', + name: 'multiple.extraText', + }); + const extraNumberInputs = createMultipleInputElements({ + type: 'number', + name: 'multiple.extraNumber', + }); + const extraFileInputs = createMultipleInputElements({ + type: 'file', + name: 'multiple.extraFiles', + }); + const extraCheckboxes = createMultipleInputElements({ + type: 'checkbox', + name: 'multiple.extraCheckbox', + }); + const extraPreferences1 = createMultipleInputElements({ + type: 'checkbox', + name: 'multiple.extraPreference', + value: 'preference1', + }); + const extraPreferences2 = createMultipleInputElements({ + type: 'checkbox', + name: 'multiple.extraPreference', + value: 'preference2', + }); + multipleFieldsetElement.append( + ...extraTextInputs, + ...extraNumberInputs, + ...extraFileInputs, + ...extraCheckboxes, + ...extraPreferences1, + ...extraPreferences2 + ); + formElement.appendChild(multipleFieldsetElement); + + return { + formElement, + emailInput, + passwordInput, + confirmPasswordInput, + showPasswordInput, + publicEmailYesRadio, + publicEmailNoRadio, + firstNameInput, + lastNameInput, + bioInput, + pictureInput, + extraPicsInput, + techCheckbox, + filmsCheckbox, + submitInput, + extraTextInputs, + extraNumberInputs, + extraFileInputs, + extraCheckboxes, + extraPreferences1, + extraPreferences2, + accountFieldset, + accountTypeElement, + }; +} + +const UserInteractions = suite('User interactions with form'); + +UserInteractions.before.each(() => { + createDOM(); +}); +UserInteractions.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +UserInteractions('Sets default data correctly', () => { + const { form, data, cleanup } = createForm({ + onSubmit: sinon.fake(), + }); + const { formElement } = createSignupForm(); + form(formElement); + const $data = get(data); + expect($data).to.deep.include({ + account: { + email: '', + password: '', + confirmPassword: '', + showPassword: false, + publicEmail: undefined, + accountType: 'user', + }, + profile: { + firstName: '', + lastName: '', + bio: '', + picture: undefined, + }, + extra: { + pictures: [], + }, + preferences: [], + }); + cleanup(); +}); + +UserInteractions('Validates default data correctly', async () => { + type Data = { + account: { + email: string; + password: string; + }; + multiple: Record[]>; + }; + const { form, data, errors, warnings, setTouched } = createForm({ + onSubmit: sinon.fake(), + validate: (values) => { + const errors: { + account: { password?: string; email?: string }; + } = { account: {} }; + if (!values.account?.email) errors.account.email = 'Must not be empty'; + if (!values.account?.password) + errors.account.password = 'Must not be empty'; + return errors; + }, + warn: (values: any) => { + const warnings: { + account: { password?: string; email?: string }; + } = { account: {} }; + if (!values.account?.password) + warnings.account.password = 'Should be safer'; + return warnings; + }, + }); + const { formElement } = createSignupForm(); + form(formElement); + const $data = get(data); + expect($data).to.have.nested.property('multiple.extraText'); + Object.keys($data.multiple).forEach((key) => { + expect($data.multiple[key]).to.be.an('array'); + $data.multiple[key].forEach((obj) => { + expect(obj).to.have.a.property('key').that.is.a('string'); + }); + }); + expect($data).to.deep.include({ + account: { + email: '', + password: '', + confirmPassword: '', + showPassword: false, + publicEmail: undefined, + accountType: 'user', + }, + profile: { + firstName: '', + lastName: '', + bio: '', + picture: undefined, + }, + extra: { + pictures: [], + }, + preferences: [], + }); + expect(get(errors)).to.have.property('account').that.includes({ + email: null, + password: null, + }); + setTouched('account.email', true); + await waitFor(() => { + expect(get(warnings)) + .to.have.property('account') + .that.deep.includes({ + password: ['Should be safer'], + }); + expect(get(errors)) + .to.have.property('account') + .that.deep.include({ + email: ['Must not be empty'], + password: null, + }); + }); + setTouched('account.password', true); + await waitFor(() => { + expect(get(errors)) + .to.have.property('account') + .that.deep.include({ + email: ['Must not be empty'], + password: ['Must not be empty'], + }); + }); +}); + +UserInteractions('Sets custom default data correctly', () => { + const { form, data, isValid } = createForm({ + onSubmit: sinon.fake(), + }); + const { + formElement, + emailInput, + bioInput, + publicEmailYesRadio, + showPasswordInput, + techCheckbox, + extraTextInputs, + extraNumberInputs, + extraCheckboxes, + extraPreferences1, + accountTypeElement, + } = createSignupForm(); + emailInput.value = 'jacek@soplica.com'; + const bioTest = 'Litwo! Ojczyzno moja! ty jesteś jak zdrowie'; + bioInput.value = bioTest; + publicEmailYesRadio.checked = true; + showPasswordInput.checked = true; + techCheckbox.checked = true; + extraTextInputs[1].value = 'demo text'; + extraNumberInputs[1].value = '1'; + extraCheckboxes[1].checked = true; + extraPreferences1[1].checked = true; + accountTypeElement.value = 'admin'; + form(formElement); + const $data = get(data); + expect($data) + .to.have.a.nested.property('multiple.extraText.1.value') + .that.equals('demo text'); + expect($data) + .to.have.a.nested.property('multiple.extraNumber.1.value') + .that.equals(1); + expect($data).to.have.a.nested.property('multiple.extraCheckbox.1.value').that + .true; + expect($data) + .to.have.a.nested.property('multiple.extraPreference.1.value') + .that.is.an('array') + .that.deep.equals(['preference1']); + expect($data).to.deep.include({ + account: { + email: 'jacek@soplica.com', + password: '', + confirmPassword: '', + showPassword: true, + publicEmail: 'yes', + accountType: 'admin', + }, + profile: { + firstName: '', + lastName: '', + bio: bioTest, + picture: undefined, + }, + extra: { + pictures: [], + }, + preferences: ['technology'], + }); + expect(get(isValid)).to.be.ok; +}); + +UserInteractions('Input and data object get same value', () => { + const { form, data } = createForm({ + onSubmit: sinon.fake(), + }); + const { formElement, emailInput, passwordInput } = createLoginForm(); + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + userEvent.type(passwordInput, 'password'); + const $data = get(data); + expect($data).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + password: 'password', + }, + }); +}); + +UserInteractions('Calls validation function on submit', async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isSubmitting } = createForm({ + onSubmit, + validate, + warn, + }); + const { formElement } = createLoginForm(); + form(formElement); + formElement.submit(); + expect(validate).to.have.been.called; + expect(warn).to.have.been.called; + await waitFor(() => { + sinon.assert.calledWith( + onSubmit, + sinon.match({ + account: { + email: '', + password: '', + }, + }), + sinon.match({ + form: formElement, + controls: sinon.match( + Array.from(formElement.elements).filter(isFormControl) + ), + }) + ); + expect(get(isSubmitting)).not.to.be.ok; + }); +}); + +UserInteractions( + 'Calls validation function on submit without calling onSubmit', + async () => { + type Data = { + account: { + email: string; + password: string; + }; + }; + const validate = sinon.fake(() => ({ account: { email: 'Not email' } })); + const warn = sinon.fake(() => ({ account: { email: 'Not email' } })); + const onSubmit = sinon.fake(); + const { form, isValid, isSubmitting } = createForm({ + onSubmit, + validate, + warn, + }); + const { formElement } = createLoginForm(); + form(formElement); + formElement.submit(); + expect(validate).to.have.been.called; + expect(warn).to.have.been.called; + await waitFor(() => { + expect(onSubmit).to.have.not.been.called; + }); + expect(get(isValid)).not.to.be.ok; + await waitFor(() => { + expect(get(isSubmitting)).not.to.be.ok; + }); + } +); + +UserInteractions('Calls validate on input', async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isValid } = createForm({ + onSubmit, + validate, + warn, + }); + const { formElement, emailInput } = createLoginForm(); + expect(get(isValid)).to.be.false; + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + await waitFor(() => { + expect(validate).to.have.been.called; + expect(warn).to.have.been.called; + expect(get(isValid)).to.be.ok; + }); +}); + +UserInteractions('Calls debounced validate on input', async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isValid } = createForm({ + onSubmit, + debounced: { + timeout: 0, + validate, + warn, + }, + }); + const { formElement, emailInput } = createLoginForm(); + expect(get(isValid)).to.be.false; + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.'); + await waitFor(() => { + sinon.assert.calledOnce(validate); + sinon.assert.calledOnce(warn); + expect(get(isValid)).to.be.ok; + }); + userEvent.type(emailInput, 'c'); + userEvent.type(emailInput, 'o'); + userEvent.type(emailInput, 'm'); + await waitFor(() => { + sinon.assert.calledTwice(validate); + sinon.assert.calledTwice(warn); + expect(get(isValid)).to.be.ok; + }); +}); + +UserInteractions( + 'Calls debounced validate on input with custom timeout', + async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isValid } = createForm({ + onSubmit, + debounced: { + timeout: 100, + validate, + warn, + }, + }); + const { formElement, emailInput } = createLoginForm(); + expect(get(isValid)).to.be.false; + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + await waitFor(() => { + expect(validate).to.have.been.called; + expect(warn).to.have.been.called; + expect(get(isValid)).to.be.ok; + }); + } +); + +UserInteractions( + 'Calls debounced validate on input with validate and warn timeout', + async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isValid } = createForm({ + onSubmit, + debounced: { + validateTimeout: 100, + validate, + warn, + warnTimeout: 100, + }, + }); + const { formElement, emailInput } = createLoginForm(); + expect(get(isValid)).to.be.false; + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + await waitFor(() => { + expect(validate).to.have.been.called; + expect(warn).to.have.been.called; + expect(get(isValid)).to.be.ok; + }); + } +); + +UserInteractions('Handles user events', () => { + type Data = { + account: { + email: string; + password: string; + confirmPassword: string; + showPassword: boolean; + publicEmail?: 'yes' | 'no'; + accountType: 'user' | 'admin'; + }; + profile: { + firstName: string; + lastName: string; + bio: string; + picture: any; + }; + extra: { + pictures: any[]; + }; + preferences: any[]; + }; + const { form, touched, data, interacted, isDirty } = createForm({ + onSubmit: sinon.fake(), + }); + const { + formElement, + emailInput, + passwordInput, + confirmPasswordInput, + showPasswordInput, + publicEmailYesRadio, + firstNameInput, + lastNameInput, + bioInput, + techCheckbox, + pictureInput, + extraPicsInput, + extraTextInputs, + extraNumberInputs, + extraCheckboxes, + extraPreferences1, + extraFileInputs, + accountTypeElement, + } = createSignupForm(); + + form(formElement); + + expect(get(data)).to.deep.include({ + account: { + email: '', + password: '', + confirmPassword: '', + showPassword: false, + publicEmail: undefined, + accountType: 'user', + }, + profile: { + firstName: '', + lastName: '', + bio: '', + picture: undefined, + }, + extra: { + pictures: [], + }, + preferences: [], + }); + + const mockFile = new File(['test file'], 'test.png', { type: 'image/png' }); + expect(get(isDirty)).to.be.false; + expect(get(interacted)).to.be.null; + userEvent.type(emailInput, 'jacek@soplica.com'); + expect(get(touched).account.email).to.be.false; + expect(get(isDirty)).to.be.true; + expect(get(interacted)).to.equal(emailInput.name); + userEvent.type(passwordInput, 'password'); + expect(get(touched).account.email).to.be.true; + userEvent.type(confirmPasswordInput, 'password'); + userEvent.click(showPasswordInput); + userEvent.click(publicEmailYesRadio); + userEvent.type(firstNameInput, 'Jacek'); + userEvent.type(lastNameInput, 'Soplica'); + const bioTest = 'Litwo! Ojczyzno moja! ty jesteś jak zdrowie'; + userEvent.type(bioInput, bioTest); + userEvent.click(techCheckbox); + userEvent.upload(pictureInput, mockFile); + userEvent.upload(extraPicsInput, [mockFile, mockFile]); + userEvent.type(extraTextInputs[1], 'demo text'); + userEvent.type(extraNumberInputs[1], '1'); + userEvent.click(extraCheckboxes[1]); + userEvent.click(extraPreferences1[1]); + userEvent.upload(extraFileInputs[1], mockFile); + userEvent.selectOptions(accountTypeElement, ['admin']); + + expect(get(data)).to.deep.include({ + account: { + email: 'jacek@soplica.com', + password: 'password', + confirmPassword: 'password', + showPassword: true, + publicEmail: 'yes', + accountType: 'admin', + }, + profile: { + firstName: 'Jacek', + lastName: 'Soplica', + bio: bioTest, + picture: mockFile, + }, + extra: { + pictures: [mockFile, mockFile], + }, + preferences: ['technology'], + }); +}); + +UserInteractions('Sets default data with initialValues', () => { + const { emailInput, passwordInput, formElement } = createLoginForm(); + const { data, form } = createForm({ + onSubmit: sinon.fake(), + initialValues: { + account: { + email: 'jacek@soplica.com', + password: 'password', + }, + }, + }); + expect(get(data)).to.deep.include({ + account: { + email: 'jacek@soplica.com', + password: 'password', + }, + }); + + form(formElement); + + expect(emailInput.value).to.equal('jacek@soplica.com'); + expect(passwordInput.value).to.equal('password'); +}); + +UserInteractions('Validates initial values correctly', async () => { + type Data = { + account: { + email: string; + password: string; + }; + }; + const { data, errors, setTouched, touched } = createForm({ + onSubmit: sinon.fake(), + validate: (values: any) => { + const errors: any = { account: {} }; + if (!values.account.email) errors.account.email = 'Must not be empty'; + if (!values.account.password) + errors.account.password = 'Must not be empty'; + return errors; + }, + initialValues: { + account: { + email: 'jacek@soplica.com', + password: '', + }, + }, + }); + expect(get(errors)).to.deep.equal({ + account: { + email: null, + password: null, + }, + }); + setTouched('account.email', true); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + password: false, + }, + }); + expect(get(errors)).to.deep.equal({ + account: { + email: null, + password: null, + }, + }); + setTouched('account.password', true); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + password: true, + }, + }); + await waitFor(() => { + expect(get(errors)).to.deep.equal({ + account: { + email: null, + password: ['Must not be empty'], + }, + }); + }); + expect(get(data)).to.deep.include({ + account: { + email: 'jacek@soplica.com', + password: '', + }, + }); +}); + +UserInteractions('calls onError', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const onError = sinon.fake(); + const mockErrors = { account: { email: 'Not email' } }; + const onSubmit = sinon.fake(() => { + throw mockErrors; + }); + + const { form, isSubmitting } = createForm({ + onSubmit, + onError, + }); + + form(formElement); + + expect(onError).to.have.not.been.called; + + formElement.submit(); + + await waitFor(() => { + expect(onSubmit).to.have.been.called; + sinon.assert.calledWith(onError, mockErrors); + expect(get(isSubmitting)).not.to.be.ok; + }); +}); + +UserInteractions('use createSubmitHandler to override submit', async () => { + const mockOnSubmit = sinon.stub(); + const mockValidate = sinon.fake(); + const mockOnError = sinon.fake(); + const formElement = screen.getByRole('form') as HTMLFormElement; + const defaultConfig = { + onSubmit: sinon.fake(), + validate: sinon.fake(), + onError: sinon.fake(), + }; + const { form, createSubmitHandler, isSubmitting } = createForm(defaultConfig); + const altOnSubmit = createSubmitHandler({ + onSubmit: mockOnSubmit, + onError: mockOnError, + validate: mockValidate, + }); + + form(formElement); + + const submitInput = createInputElement({ + type: 'submit', + value: 'Alt Submit', + }); + + submitInput.addEventListener('click', altOnSubmit); + + formElement.appendChild(submitInput); + + userEvent.click(submitInput); + + await waitFor(() => { + sinon.assert.calledOnce(mockValidate); + expect(defaultConfig.onSubmit).to.have.not.been.called; + sinon.assert.calledOnce(mockOnSubmit); + expect(defaultConfig.onError).to.have.not.been.called; + expect(mockOnError).to.have.not.been.called; + expect(get(isSubmitting)).not.to.be.ok; + }); + + const mockErrors = { account: { email: 'Not email' } }; + mockOnSubmit.resetHistory(); + mockOnSubmit.onFirstCall().throws(mockErrors); + + userEvent.click(submitInput); + + await waitFor(() => { + expect(mockOnError).to.have.been.called; + sinon.assert.calledTwice(mockValidate); + sinon.assert.calledOnce(mockOnSubmit); + expect(get(isSubmitting)).not.to.be.ok; + }); +}); + +UserInteractions('calls submit handler without event', async () => { + const { createSubmitHandler, isSubmitting } = createForm({ + onSubmit: sinon.fake(), + }); + const mockOnSubmit = sinon.fake(); + const altOnSubmit = createSubmitHandler({ onSubmit: mockOnSubmit }); + altOnSubmit(); + await waitFor(() => { + expect(mockOnSubmit).to.have.been.called; + expect(get(isSubmitting)).not.to.be.ok; + }); +}); + +UserInteractions('ignores inputs with data-felte-ignore', async () => { + type Data = { + account: { + email: string; + password: string; + confirmPassword: string; + showPassword: boolean; + publicEmail?: 'yes' | 'no'; + }; + profile: { + firstName: string; + lastName: string; + bio: string; + picture: any; + }; + extra: { + pictures: any[]; + }; + preferences: any[]; + }; + const { + formElement, + accountFieldset, + emailInput, + passwordInput, + firstNameInput, + lastNameInput, + publicEmailYesRadio, + } = createSignupForm(); + accountFieldset.setAttribute('data-felte-ignore', ''); + firstNameInput.setAttribute('data-felte-ignore', ''); + const { data, form } = createForm({ + onSubmit: sinon.fake(), + }); + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + userEvent.type(passwordInput, 'password'); + userEvent.type(firstNameInput, 'Jacek'); + userEvent.type(lastNameInput, 'Soplica'); + userEvent.click(publicEmailYesRadio); + await waitFor(() => { + expect(get(data).profile.lastName).to.equal('Soplica'); + expect(get(data).profile.firstName).to.equal(''); + expect(get(data).account.email).to.equal(''); + expect(get(data).account.password).to.equal(''); + expect(get(data).account.publicEmail).to.equal(undefined); + }); +}); + +UserInteractions('transforms data', async () => { + type Data = { + account: { + email: string; + password: string; + confirmPassword: string; + showPassword: boolean; + publicEmail?: boolean; + }; + profile: { + firstName: string; + lastName: string; + bio: string; + picture: any; + }; + extra: { + pictures: any[]; + }; + preferences: any[]; + }; + const { + formElement, + publicEmailYesRadio, + publicEmailNoRadio, + } = createSignupForm(); + const { data, form } = createForm({ + onSubmit: sinon.fake(), + transform: (values: any) => { + if (values.account.publicEmail === 'yes') { + values.account.publicEmail = true; + } else { + values.account.publicEmail = false; + } + return values; + }, + }); + + form(formElement); + + userEvent.click(publicEmailYesRadio); + await waitFor(() => { + expect(get(data).account.publicEmail).to.be.true; + }); + userEvent.click(publicEmailNoRadio); + await waitFor(() => { + expect(get(data).account.publicEmail).to.be.false; + }); +}); + +UserInteractions('submits without requestSubmit', async () => { + const onSubmit = sinon.fake(); + const { form } = createForm({ onSubmit }); + const { formElement } = createLoginForm(); + formElement.requestSubmit = undefined as any; + form(formElement); + formElement.submit(); + + await waitFor(() => { + expect(onSubmit).to.have.been.called; + }); +}); + +UserInteractions('submits post request with default action', async () => { + window.fetch = sinon.stub().resolves({ ok: true }); + const onSuccess = sinon.fake(); + const eventOnSuccess = sinon.fake(); + const { form } = createForm({ onSuccess }); + const { formElement } = createLoginForm(); + formElement.action = '/example'; + formElement.method = 'post'; + formElement.addEventListener('feltesuccess', eventOnSuccess); + form(formElement); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match('/example'), + sinon.match({ + body: sinon.match.instanceOf(URLSearchParams), + method: 'post', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + ); + sinon.assert.calledWith( + onSuccess, + sinon.match({ + ok: true, + }), + sinon.match.any + ); + }); +}); + +UserInteractions('submits get request with default action', async () => { + window.fetch = sinon.stub().resolves({ ok: true }); + const onSuccess = sinon.fake(); + const eventOnSuccess = sinon.fake(); + const { form } = createForm({ onSuccess }); + const { formElement, emailInput } = createLoginForm(); + formElement.action = '/example'; + formElement.method = 'get'; + formElement.addEventListener('feltesuccess', eventOnSuccess); + form(formElement); + + userEvent.type(emailInput, 'zaphod@beeblebrox.com'); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match( + '/example?account.email=zaphod%40beeblebrox.com&account.password=' + ), + sinon.match({ + method: 'get', + }) + ); + sinon.assert.calledWith( + onSuccess, + sinon.match({ + ok: true, + }), + sinon.match.any + ); + sinon.assert.calledWith( + eventOnSuccess, + sinon.match({ + detail: sinon.match({ + response: sinon.match({ + ok: true, + }), + }), + }) + ); + }); +}); + +UserInteractions( + 'submits with default action and overriden method', + async () => { + window.fetch = sinon.stub().resolves({ ok: true }); + const { form } = createForm(); + const { formElement } = createLoginForm(); + formElement.action = '/example?_method=put'; + formElement.method = 'post'; + form(formElement); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match('/example'), + sinon.match({ + body: sinon.match.instanceOf(URLSearchParams), + method: 'put', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + ); + }); + } +); + +UserInteractions('submits with default action and file input', async () => { + window.fetch = sinon.stub().resolves({ ok: true }); + const { form } = createForm(); + const { formElement } = createLoginForm(); + formElement.action = '/example'; + formElement.method = 'post'; + const fileInput = createInputElement({ name: 'profilePic', type: 'file' }); + formElement.appendChild(fileInput); + form(formElement); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match('/example'), + sinon.match({ + body: sinon.match.instanceOf(FormData), + method: 'post', + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + ); + }); +}); + +UserInteractions('submits with default action and throws', async () => { + window.fetch = sinon.stub().resolves({ ok: false }); + const onError = sinon.fake(); + const eventOnError = sinon.fake(); + const { form } = createForm({ onError }); + const { formElement } = createLoginForm(); + formElement.action = '/example'; + formElement.method = 'post'; + formElement.addEventListener('felteerror', eventOnError); + form(formElement); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match('/example'), + sinon.match({ + body: sinon.match.instanceOf(URLSearchParams), + method: 'post', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + ); + sinon.assert.calledWith( + onError, + sinon.match.instanceOf(FelteSubmitError), + sinon.match.any + ); + sinon.assert.calledWith( + eventOnError, + sinon.match({ + detail: sinon.match({ + error: sinon.match.instanceOf(FelteSubmitError), + }), + }) + ); + }); +}); + +UserInteractions.run(); diff --git a/packages/core/tests/user-interactions.test.ts b/packages/core/tests/user-interactions.test.ts deleted file mode 100644 index 72f250dd..00000000 --- a/packages/core/tests/user-interactions.test.ts +++ /dev/null @@ -1,861 +0,0 @@ -import { screen, waitFor } from '@testing-library/dom'; -import { - cleanupDOM, - createInputElement, - createDOM, - createMultipleInputElements, -} from './common'; -import { createForm as coreCreateForm } from '../src'; -import userEvent from '@testing-library/user-event'; -import { get, writable } from 'svelte/store'; -import { isFormControl } from '@felte/common'; -import type { FormConfig, Form, Obj } from '@felte/common'; - -function createSelectElement({ - name, - options, -}: { - name: string; - options: string[]; -}) { - const selectElement = document.createElement('select'); - selectElement.name = name; - const optionElements = options.map((option) => { - const element = document.createElement('option'); - element.value = option; - return element; - }); - selectElement.append(...optionElements); - return selectElement; -} - -function createForm(config: FormConfig) { - return coreCreateForm(config, { - storeFactory: writable, - }); -} - -function createLoginForm() { - const formElement = screen.getByRole('form') as HTMLFormElement; - const emailInput = createInputElement({ name: 'email', type: 'email' }); - const passwordInput = createInputElement({ - name: 'password', - type: 'password', - }); - const submitInput = createInputElement({ type: 'submit' }); - const accountFieldset = document.createElement('fieldset'); - accountFieldset.name = 'account'; - accountFieldset.append(emailInput, passwordInput); - formElement.append(accountFieldset, submitInput); - return { formElement, emailInput, passwordInput, submitInput }; -} - -function createSignupForm() { - const formElement = screen.getByRole('form') as HTMLFormElement; - const emailInput = createInputElement({ name: 'email', type: 'email' }); - const passwordInput = createInputElement({ - name: 'password', - type: 'password', - }); - const showPasswordInput = createInputElement({ - name: 'showPassword', - type: 'checkbox', - }); - const confirmPasswordInput = createInputElement({ - name: 'confirmPassword', - type: 'password', - }); - const publicEmailYesRadio = createInputElement({ - name: 'publicEmail', - value: 'yes', - type: 'radio', - }); - const publicEmailNoRadio = createInputElement({ - name: 'publicEmail', - value: 'no', - type: 'radio', - }); - const accountTypeElement = createSelectElement({ - name: 'accountType', - options: ['user', 'admin'], - }); - const accountFieldset = document.createElement('fieldset'); - accountFieldset.name = 'account'; - accountFieldset.append( - emailInput, - passwordInput, - showPasswordInput, - publicEmailYesRadio, - publicEmailNoRadio, - confirmPasswordInput, - accountTypeElement - ); - formElement.appendChild(accountFieldset); - const profileFieldset = document.createElement('fieldset'); - profileFieldset.name = 'profile'; - const firstNameInput = createInputElement({ name: 'firstName' }); - const lastNameInput = createInputElement({ name: 'lastName' }); - const bioInput = createInputElement({ name: 'bio' }); - profileFieldset.append(firstNameInput, lastNameInput, bioInput); - formElement.appendChild(profileFieldset); - const pictureInput = createInputElement({ - name: 'profile.picture', - type: 'file', - }); - formElement.appendChild(pictureInput); - const extraPicsInput = createInputElement({ - name: 'extra.pictures', - type: 'file', - }); - extraPicsInput.multiple = true; - formElement.appendChild(extraPicsInput); - const submitInput = createInputElement({ type: 'submit' }); - const techCheckbox = createInputElement({ - type: 'checkbox', - name: 'preferences', - value: 'technology', - }); - const filmsCheckbox = createInputElement({ - type: 'checkbox', - name: 'preferences', - value: 'films', - }); - formElement.append(techCheckbox, filmsCheckbox, submitInput); - const multipleFieldsetElement = document.createElement('fieldset'); - multipleFieldsetElement.name = 'multiple'; - const extraTextInputs = createMultipleInputElements({ - type: 'text', - name: 'extraText', - }); - const extraNumberInputs = createMultipleInputElements({ - type: 'number', - name: 'extraNumber', - }); - const extraFileInputs = createMultipleInputElements({ - type: 'file', - name: 'extraFiles', - }); - const extraCheckboxes = createMultipleInputElements({ - type: 'checkbox', - name: 'extraCheckbox', - }); - const extraPreferences1 = createMultipleInputElements({ - type: 'checkbox', - name: 'extraPreference', - value: 'preference1', - }); - const extraPreferences2 = createMultipleInputElements({ - type: 'checkbox', - name: 'extraPreference', - value: 'preference2', - }); - multipleFieldsetElement.append( - ...extraTextInputs, - ...extraNumberInputs, - ...extraFileInputs, - ...extraCheckboxes, - ...extraPreferences1, - ...extraPreferences2 - ); - formElement.appendChild(multipleFieldsetElement); - - return { - formElement, - emailInput, - passwordInput, - confirmPasswordInput, - showPasswordInput, - publicEmailYesRadio, - publicEmailNoRadio, - firstNameInput, - lastNameInput, - bioInput, - pictureInput, - extraPicsInput, - techCheckbox, - filmsCheckbox, - submitInput, - extraTextInputs, - extraNumberInputs, - extraFileInputs, - extraCheckboxes, - extraPreferences1, - extraPreferences2, - accountFieldset, - accountTypeElement, - }; -} - -describe('User interactions with form', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('Sets default data correctly', () => { - const { form, data, cleanup } = createForm({ - onSubmit: jest.fn(), - }); - const { formElement } = createSignupForm(); - form(formElement); - const $data = get(data); - expect($data).toEqual( - expect.objectContaining({ - account: { - email: '', - password: '', - confirmPassword: '', - showPassword: false, - publicEmail: undefined, - accountType: 'user', - }, - profile: { - firstName: '', - lastName: '', - bio: '', - picture: undefined, - }, - extra: { - pictures: expect.arrayContaining([]), - }, - preferences: expect.arrayContaining([]), - }) - ); - cleanup(); - }); - - test('Validates default data correctly', async () => { - const { form, data, errors, setTouched } = createForm({ - onSubmit: jest.fn(), - validate: (values: any) => { - const errors: { - account: { password?: string; email?: string }; - } = { account: {} }; - if (!values.account?.email) errors.account.email = 'Must not be empty'; - if (!values.account?.password) - errors.account.password = 'Must not be empty'; - return errors; - }, - }); - const { formElement } = createSignupForm(); - form(formElement); - const $data = get(data); - expect($data).toEqual( - expect.objectContaining({ - account: { - email: '', - password: '', - confirmPassword: '', - showPassword: false, - publicEmail: undefined, - accountType: 'user', - }, - profile: { - firstName: '', - lastName: '', - bio: '', - picture: undefined, - }, - extra: { - pictures: expect.arrayContaining([]), - }, - preferences: expect.arrayContaining([]), - multiple: { - extraText: expect.arrayContaining(['', '', '']), - extraNumber: expect.arrayContaining([ - undefined, - undefined, - undefined, - ]), - extraFiles: expect.arrayContaining([undefined, undefined, undefined]), - extraCheckbox: expect.arrayContaining([false, false, false]), - extraPreference: expect.arrayContaining([[], [], []]), - }, - }) - ); - expect(get(errors)).toMatchObject({ - account: { - email: null, - password: null, - }, - }); - setTouched('account.email'); - await waitFor(() => { - expect(get(errors)).toMatchObject({ - account: { - email: 'Must not be empty', - password: null, - }, - }); - }); - setTouched('account.password'); - await waitFor(() => { - expect(get(errors)).toMatchObject({ - account: { - email: 'Must not be empty', - password: 'Must not be empty', - }, - }); - }); - }); - - test('Sets custom default data correctly', () => { - const { form, data, isValid } = createForm({ - onSubmit: jest.fn(), - }); - const { - formElement, - emailInput, - bioInput, - publicEmailYesRadio, - showPasswordInput, - techCheckbox, - extraTextInputs, - extraNumberInputs, - extraCheckboxes, - extraPreferences1, - accountTypeElement, - } = createSignupForm(); - emailInput.value = 'jacek@soplica.com'; - const bioTest = 'Litwo! Ojczyzno moja! ty jesteś jak zdrowie'; - bioInput.value = bioTest; - publicEmailYesRadio.checked = true; - showPasswordInput.checked = true; - techCheckbox.checked = true; - extraTextInputs[1].value = 'demo text'; - extraNumberInputs[1].value = '1'; - extraCheckboxes[1].checked = true; - extraPreferences1[1].checked = true; - accountTypeElement.value = 'admin'; - form(formElement); - const $data = get(data); - expect($data).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: '', - confirmPassword: '', - showPassword: true, - publicEmail: 'yes', - accountType: 'admin', - }, - profile: { - firstName: '', - lastName: '', - bio: bioTest, - picture: undefined, - }, - extra: { - pictures: expect.arrayContaining([]), - }, - preferences: expect.arrayContaining(['technology']), - multiple: { - extraText: expect.arrayContaining(['', 'demo text', '']), - extraNumber: expect.arrayContaining([undefined, 1, undefined]), - extraFiles: expect.arrayContaining([undefined, undefined, undefined]), - extraCheckbox: expect.arrayContaining([false, true, false]), - extraPreference: expect.arrayContaining([[], ['preference1'], []]), - }, - }) - ); - expect(get(isValid)).toBeTruthy(); - }); - - test('Input and data object get same value', () => { - const { form, data } = createForm({ - onSubmit: jest.fn(), - }); - const { formElement, emailInput, passwordInput } = createLoginForm(); - form(formElement); - userEvent.type(emailInput, 'jacek@soplica.com'); - userEvent.type(passwordInput, 'password'); - const $data = get(data); - expect($data).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: 'password', - }, - }) - ); - }); - - test('Calls validation function on submit', async () => { - const validate = jest.fn(() => ({})); - const onSubmit = jest.fn(); - const { form, isSubmitting } = createForm({ - onSubmit, - validate, - }); - const { formElement } = createLoginForm(); - form(formElement); - formElement.submit(); - expect(validate).toHaveBeenCalled(); - await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - account: { - email: '', - password: '', - }, - }), - expect.objectContaining({ - form: formElement, - controls: Array.from(formElement.elements).filter(isFormControl), - }) - ); - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('Calls validation function on submit without calling onSubmit', async () => { - const validate = jest.fn(() => ({ account: { email: 'Not email' } })); - const onSubmit = jest.fn(); - const { form, isValid, isSubmitting } = createForm({ - onSubmit, - validate, - }); - const { formElement } = createLoginForm(); - form(formElement); - formElement.submit(); - expect(validate).toHaveBeenCalled(); - await waitFor(() => { - expect(onSubmit).not.toHaveBeenCalled(); - }); - expect(get(isValid)).toBeFalsy(); - await waitFor(() => { - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('Calls validate on input', async () => { - const validate = jest.fn(() => ({})); - const onSubmit = jest.fn(); - const { form, isValid } = createForm({ - onSubmit, - validate, - }); - const { formElement, emailInput } = createLoginForm(); - form(formElement); - userEvent.type(emailInput, 'jacek@soplica.com'); - await waitFor(() => { - expect(validate).toHaveBeenCalled(); - expect(get(isValid)).toBeTruthy(); - }); - }); - - test('Handles user events', () => { - type Data = { - account: { - email: string; - password: string; - confirmPassword: string; - showPassword: boolean; - publicEmail?: 'yes' | 'no'; - accountType: 'user' | 'admin'; - }; - profile: { - firstName: string; - lastName: string; - bio: string; - picture: any; - }; - extra: { - pictures: any[]; - }; - preferences: any[]; - }; - const { form, touched, data } = createForm({ - onSubmit: jest.fn(), - }); - const { - formElement, - emailInput, - passwordInput, - confirmPasswordInput, - showPasswordInput, - publicEmailYesRadio, - firstNameInput, - lastNameInput, - bioInput, - techCheckbox, - pictureInput, - extraPicsInput, - extraTextInputs, - extraNumberInputs, - extraCheckboxes, - extraPreferences1, - extraFileInputs, - accountTypeElement, - } = createSignupForm(); - - form(formElement); - - expect(get(data)).toEqual( - expect.objectContaining({ - account: { - email: '', - password: '', - confirmPassword: '', - showPassword: false, - publicEmail: undefined, - accountType: 'user', - }, - profile: { - firstName: '', - lastName: '', - bio: '', - picture: undefined, - }, - extra: { - pictures: expect.arrayContaining([]), - }, - preferences: expect.arrayContaining([]), - multiple: { - extraText: expect.arrayContaining(['', '', '']), - extraNumber: expect.arrayContaining([ - undefined, - undefined, - undefined, - ]), - extraFiles: expect.arrayContaining([undefined, undefined, undefined]), - extraCheckbox: expect.arrayContaining([false, false, false]), - extraPreference: expect.arrayContaining([[], [], []]), - }, - }) - ); - - const mockFile = new File(['test file'], 'test.png', { type: 'image/png' }); - userEvent.type(emailInput, 'jacek@soplica.com'); - expect(get(touched).account.email).toBe(false); - userEvent.type(passwordInput, 'password'); - expect(get(touched).account.email).toBe(true); - userEvent.type(confirmPasswordInput, 'password'); - userEvent.click(showPasswordInput); - userEvent.click(publicEmailYesRadio); - userEvent.type(firstNameInput, 'Jacek'); - userEvent.type(lastNameInput, 'Soplica'); - const bioTest = 'Litwo! Ojczyzno moja! ty jesteś jak zdrowie'; - userEvent.type(bioInput, bioTest); - userEvent.click(techCheckbox); - userEvent.upload(pictureInput, mockFile); - userEvent.upload(extraPicsInput, [mockFile, mockFile]); - userEvent.type(extraTextInputs[1], 'demo text'); - userEvent.type(extraNumberInputs[1], '1'); - userEvent.click(extraCheckboxes[1]); - userEvent.click(extraPreferences1[1]); - userEvent.upload(extraFileInputs[1], mockFile); - userEvent.selectOptions(accountTypeElement, ['admin']); - - expect(get(data)).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: 'password', - confirmPassword: 'password', - showPassword: true, - publicEmail: 'yes', - accountType: 'admin', - }, - profile: { - firstName: 'Jacek', - lastName: 'Soplica', - bio: bioTest, - picture: mockFile, - }, - extra: { - pictures: expect.arrayContaining([mockFile, mockFile]), - }, - preferences: expect.arrayContaining(['technology']), - multiple: { - extraText: expect.arrayContaining(['', 'demo text', '']), - extraNumber: expect.arrayContaining([undefined, 1, undefined]), - extraFiles: expect.arrayContaining([undefined, mockFile, undefined]), - extraCheckbox: expect.arrayContaining([false, true, false]), - extraPreference: expect.arrayContaining([[], ['preference1'], []]), - }, - }) - ); - }); - - test('Sets default data with initialValues', () => { - const { emailInput, passwordInput, formElement } = createLoginForm(); - const { data, form } = createForm({ - onSubmit: jest.fn(), - initialValues: { - account: { - email: 'jacek@soplica.com', - password: 'password', - }, - }, - }); - expect(get(data)).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: 'password', - }, - }) - ); - - form(formElement); - - expect(emailInput.value).toBe('jacek@soplica.com'); - expect(passwordInput.value).toBe('password'); - }); - - test('Validates initial values correctly', async () => { - const { data, errors, setTouched, touched } = createForm({ - onSubmit: jest.fn(), - validate: (values: any) => { - const errors: { - account: { password?: string; email?: string }; - } = { account: {} }; - if (!values.account.email) errors.account.email = 'Must not be empty'; - if (!values.account.password) - errors.account.password = 'Must not be empty'; - return errors; - }, - initialValues: { - account: { - email: 'jacek@soplica.com', - password: '', - }, - }, - }); - expect(get(errors)).toEqual({ - account: { - email: null, - password: null, - }, - }); - setTouched('account.email'); - expect(get(touched)).toEqual({ - account: { - email: true, - password: false, - }, - }); - expect(get(errors)).toEqual({ - account: { - email: null, - password: null, - }, - }); - setTouched('account.password'); - expect(get(touched)).toEqual({ - account: { - email: true, - password: true, - }, - }); - await waitFor(() => { - expect(get(errors)).toEqual({ - account: { - email: null, - password: 'Must not be empty', - }, - }); - }); - expect(get(data)).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: '', - }, - }) - ); - }); - - test('calls onError', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const onError = jest.fn(); - const mockErrors = { account: { email: 'Not email' } }; - const onSubmit = jest.fn(() => { - throw mockErrors; - }); - - const { form, isSubmitting } = createForm({ - onSubmit, - onError, - }); - - form(formElement); - - expect(onError).not.toHaveBeenCalled(); - - formElement.submit(); - - await waitFor(() => { - expect(onSubmit).toHaveBeenCalled(); - expect(onError).toHaveBeenCalledWith(mockErrors); - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('use createSubmitHandler to override submit', async () => { - const mockOnSubmit = jest.fn(); - const mockValidate = jest.fn(); - const mockOnError = jest.fn(); - const formElement = screen.getByRole('form') as HTMLFormElement; - const defaultConfig = { - onSubmit: jest.fn(), - validate: jest.fn(), - onError: jest.fn(), - }; - const { form, createSubmitHandler, isSubmitting } = createForm( - defaultConfig - ); - const altOnSubmit = createSubmitHandler({ - onSubmit: mockOnSubmit, - onError: mockOnError, - validate: mockValidate, - }); - - form(formElement); - - const submitInput = createInputElement({ - type: 'submit', - value: 'Alt Submit', - }); - - submitInput.addEventListener('click', altOnSubmit); - - formElement.appendChild(submitInput); - - userEvent.click(submitInput); - - await waitFor(() => { - expect(mockValidate).toHaveBeenCalledTimes(1); - expect(defaultConfig.onSubmit).not.toHaveBeenCalled(); - expect(mockOnSubmit).toHaveBeenCalledTimes(1); - expect(defaultConfig.onError).not.toHaveBeenCalled(); - expect(mockOnError).not.toHaveBeenCalled(); - expect(get(isSubmitting)).toBeFalsy(); - }); - - const mockErrors = { account: { email: 'Not email' } }; - mockOnSubmit.mockImplementationOnce(() => { - throw mockErrors; - }); - - userEvent.click(submitInput); - - await waitFor(() => { - expect(mockOnError).toHaveBeenCalled(); - expect(mockValidate).toHaveBeenCalledTimes(2); - expect(mockOnSubmit).toHaveBeenCalledTimes(2); - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('calls submit handler without event', async () => { - const { createSubmitHandler, isSubmitting } = createForm({ - onSubmit: jest.fn(), - }); - const mockOnSubmit = jest.fn(); - const altOnSubmit = createSubmitHandler({ onSubmit: mockOnSubmit }); - altOnSubmit(); - await waitFor(() => { - expect(mockOnSubmit).toHaveBeenCalled(); - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('ignores inputs with data-felte-ignore', async () => { - type Data = { - account: { - email: string; - password: string; - confirmPassword: string; - showPassword: boolean; - publicEmail?: 'yes' | 'no'; - }; - profile: { - firstName: string; - lastName: string; - bio: string; - picture: any; - }; - extra: { - pictures: any[]; - }; - preferences: any[]; - }; - const { - formElement, - accountFieldset, - emailInput, - passwordInput, - firstNameInput, - lastNameInput, - publicEmailYesRadio, - } = createSignupForm(); - accountFieldset.setAttribute('data-felte-ignore', ''); - firstNameInput.setAttribute('data-felte-ignore', ''); - const { data, form } = createForm({ - onSubmit: jest.fn(), - }); - form(formElement); - userEvent.type(emailInput, 'jacek@soplica.com'); - userEvent.type(passwordInput, 'password'); - userEvent.type(firstNameInput, 'Jacek'); - userEvent.type(lastNameInput, 'Soplica'); - userEvent.click(publicEmailYesRadio); - await waitFor(() => { - expect(get(data).profile.lastName).toBe('Soplica'); - expect(get(data).profile.firstName).toBe(''); - expect(get(data).account.email).toBe(''); - expect(get(data).account.password).toBe(''); - expect(get(data).account.publicEmail).toBe(undefined); - }); - }); - - test('transforms data', async () => { - type Data = { - account: { - email: string; - password: string; - confirmPassword: string; - showPassword: boolean; - publicEmail?: boolean; - }; - profile: { - firstName: string; - lastName: string; - bio: string; - picture: any; - }; - extra: { - pictures: any[]; - }; - preferences: any[]; - }; - const { - formElement, - publicEmailYesRadio, - publicEmailNoRadio, - } = createSignupForm(); - const { data, form } = createForm({ - onSubmit: jest.fn(), - transform: (values: any) => { - if (values.account.publicEmail === 'yes') { - values.account.publicEmail = true; - } else { - values.account.publicEmail = false; - } - return values; - }, - }); - - form(formElement); - - userEvent.click(publicEmailYesRadio); - await waitFor(() => { - expect(get(data).account.publicEmail).toBe(true); - }); - userEvent.click(publicEmailNoRadio); - await waitFor(() => { - expect(get(data).account.publicEmail).toBe(false); - }); - }); -}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 37029954..ef3e034f 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -10,7 +10,7 @@ "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, - "declarationDir": "./dist" + "declarationDir": "./dist/types" }, "typedocOptions": { "entryPoints": ["src/index.ts"], diff --git a/packages/extender-persist/CHANGELOG.md b/packages/extender-persist/CHANGELOG.md index 96dbf3f1..05cc63bb 100644 --- a/packages/extender-persist/CHANGELOG.md +++ b/packages/extender-persist/CHANGELOG.md @@ -1,5 +1,182 @@ # @felte/validator-yup +## 1.0.0-next.22 + +### Patch Changes + +- Updated dependencies [7f3d8b8] + - @felte/common@1.0.0-next.23 + +## 1.0.0-next.21 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/common@1.0.0-next.22 + +## 1.0.0-next.20 + +### Patch Changes + +- Updated dependencies [fcbdaed] + - @felte/common@1.0.0-next.21 + +## 1.0.0-next.19 + +### Patch Changes + +- Updated dependencies [990034e] + - @felte/common@1.0.0-next.20 + +## 1.0.0-next.18 + +### Patch Changes + +- Updated dependencies [a174e87] + - @felte/common@1.0.0-next.19 + +## 1.0.0-next.17 + +### Patch Changes + +- Updated dependencies [70cfada] + - @felte/common@1.0.0-next.18 + +## 1.0.0-next.16 + +### Patch Changes + +- Updated dependencies [2e7aad3] + - @felte/common@1.0.0-next.17 + +## 1.0.0-next.15 + +### Patch Changes + +- Updated dependencies [c8c1511] + - @felte/common@1.0.0-next.16 + +## 1.0.0-next.14 + +### Patch Changes + +- Updated dependencies [093482a] + - @felte/common@1.0.0-next.15 + +## 1.0.0-next.13 + +### Patch Changes + +- Updated dependencies [dd52c94] + - @felte/common@1.0.0-next.14 + +## 1.0.0-next.12 + +### Patch Changes + +- Updated dependencies [a45d56c] + - @felte/common@1.0.0-next.13 + +## 1.0.0-next.11 + +### Patch Changes + +- Updated dependencies [452fe5a] +- Updated dependencies [15d0ce2] + - @felte/common@1.0.0-next.12 + +## 1.0.0-next.10 + +### Patch Changes + +- Updated dependencies [a1dbc28] +- Updated dependencies [ec740a0] +- Updated dependencies [34e0393] +- Updated dependencies [b7ef442] +- Updated dependencies [e1ad8cd] + - @felte/common@1.0.0-next.11 + +## 1.0.0-next.9 + +### Patch Changes + +- Updated dependencies [dc1f21a] +- Updated dependencies [eea3afa] + - @felte/common@1.0.0-next.10 + +## 1.0.0-next.8 + +### Patch Changes + +- Updated dependencies [38fbb49] + - @felte/common@1.0.0-next.9 + +## 1.0.0-next.7 + +### Patch Changes + +- Updated dependencies [c86a82a] + - @felte/common@1.0.0-next.8 + +## 1.0.0-next.6 + +### Patch Changes + +- Updated dependencies [e49c094] + - @felte/common@1.0.0-next.7 + +## 1.0.0-next.5 + +### Patch Changes + +- Updated dependencies [d1b62bf] + - @felte/common@1.0.0-next.6 + +## 1.0.0-next.4 + +### Patch Changes + +- Updated dependencies [e2f4e18] + - @felte/common@1.0.0-next.5 + +## 1.0.0-next.3 + +### Patch Changes + +- 8c29b4a: Fix unset on Safari +- Updated dependencies [8c29b4a] + - @felte/common@1.0.0-next.3 + +## 1.0.0-next.2 + +### Patch Changes + +- Updated dependencies [6f48123] + - @felte/common@1.0.0-next.2 + +## 1.0.0-next.1 + +### Patch Changes + +- Updated dependencies [02a77e3] + - @felte/common@1.0.0-next.1 + +## 1.0.0-next.0 + +### Major Changes + +- 9a48a40: Pass a new property `stage` to extenders to distinguish between setup, mount and update stages + +### Patch Changes + +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/common@1.0.0-next.0 + ## 0.1.17 ### Patch Changes diff --git a/packages/extender-persist/jest.config.js b/packages/extender-persist/jest.config.js deleted file mode 100644 index 553799a5..00000000 --- a/packages/extender-persist/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], -}; diff --git a/packages/extender-persist/package.json b/packages/extender-persist/package.json index 918f8e83..1ccda40c 100644 --- a/packages/extender-persist/package.json +++ b/packages/extender-persist/package.json @@ -1,11 +1,15 @@ { "name": "@felte/extender-persist", - "version": "0.1.17", + "version": "1.0.0-next.22", "description": "Use localStorage to persist your Felte forms", - "main": "dist/index.js", - "browser": "dist/index.js", + "main": "dist/index.cjs", + "browser": "dist/index.mjs", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "sideEffects": false, "author": "Pablo Berganza ", "repository": "github:pablo-abc/felte", @@ -17,18 +21,20 @@ ], "scripts": { "prebuild": "rimraf ./dist", - "build": "cross-env NODE_ENV=production rollup -c", + "build": "pnpm prebuild && cross-env NODE_ENV=production rollup -c", "dev": "rollup -cw", "prepublishOnly": "pnpm build && pnpm test", - "test": "jest", - "test:ci": "jest --ci --coverage" + "test": "uvu -r module-alias/register -r tsm -r global-jsdom/register tests -i common -i mocks", + "test:ci": "nyc -n src pnpm test" }, "license": "MIT", "dependencies": { "@felte/common": "workspace:*" }, "devDependencies": { - "felte": "workspace:*" + "felte": "workspace:*", + "module-alias": "^2.2.2", + "svelte": "^3.46.4" }, "publishConfig": { "access": "public" @@ -36,9 +42,13 @@ "exports": { ".": { "import": "./dist/index.mjs", - "require": "./dist/index.js", + "require": "./dist/index.cjs", "default": "./dist/index.mjs" }, "./package.json": "./package.json" + }, + "_moduleAliases": { + "svelte": "tests/mocks/svelte.js", + "svelte/store": "node_modules/svelte/store/index.mjs" } } diff --git a/packages/extender-persist/rollup.config.js b/packages/extender-persist/rollup.config.js index c5217199..2bee935e 100644 --- a/packages/extender-persist/rollup.config.js +++ b/packages/extender-persist/rollup.config.js @@ -2,7 +2,6 @@ import typescript from 'rollup-plugin-ts'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; -import { terser } from 'rollup-plugin-terser'; import bundleSize from 'rollup-plugin-bundle-size'; import pkg from './package.json'; @@ -17,7 +16,7 @@ export default { external: ['tippy.js'], output: [ { - file: pkg.browser, + file: pkg.main, format: 'cjs', sourcemap: prod, exports: 'named', @@ -34,8 +33,7 @@ export default { }), resolve({ browser: true }), commonjs(), - typescript(), - prod && terser(), + typescript({ browserlist: false }), prod && bundleSize(), ], }; diff --git a/packages/extender-persist/src/index.ts b/packages/extender-persist/src/index.ts index 66448e8e..37be42dc 100644 --- a/packages/extender-persist/src/index.ts +++ b/packages/extender-persist/src/index.ts @@ -1,7 +1,6 @@ -import type { Obj, ExtenderHandler, FormControl } from '@felte/common'; +import type { Obj, ExtenderHandler } from '@felte/common'; import { _unset, - _get, CurrentForm, setForm, _cloneDeep, @@ -19,8 +18,8 @@ export function extender(config: ExtenderConfig) { return function ( currentForm: CurrentForm ): ExtenderHandler { + if (currentForm.stage === 'SETUP') return {}; const { controls, form } = currentForm; - if (!controls || !form) return {}; const unsubscribe = currentForm.data.subscribe(($data) => { if (!loaded[config.id]) return; let savedData = _cloneDeep($data); diff --git a/packages/extender-persist/tests/common.ts b/packages/extender-persist/tests/common.ts index ce8f1b10..7f05cee8 100644 --- a/packages/extender-persist/tests/common.ts +++ b/packages/extender-persist/tests/common.ts @@ -1,3 +1,5 @@ +import 'uvu-expect-dom/extend'; + export function createDOM(): void { const formElement = document.createElement('form'); formElement.name = 'test-form'; diff --git a/packages/extender-persist/tests/extender.spec.ts b/packages/extender-persist/tests/extender.spec.ts new file mode 100644 index 00000000..c504d42f --- /dev/null +++ b/packages/extender-persist/tests/extender.spec.ts @@ -0,0 +1,132 @@ +import { createForm } from 'felte'; +import { waitFor, screen } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { createDOM, cleanupDOM, createInputElement } from './common'; +import { extender } from '../src'; +import { get } from 'svelte/store'; +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; + +const Extender = suite('Extender persist'); + +Extender.before.each(createDOM); +Extender.after.each(cleanupDOM); + +Extender('updates localStorage', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + type Data = { + account: { + email: string; + password: string; + confirmPassword: string; + someSecret: string; + }; + }; + const { form } = createForm({ + initialValues: { + account: { + email: '', + password: '', + confirmPassword: '', + someSecret: '', + }, + }, + onSubmit: sinon.fake(), + extend: extender({ + id: 'test-update', + ignore: ['account.someSecret', 'account.password'], + }), + }); + + const fieldsetElement = document.createElement('fieldset'); + const emailInput = createInputElement({ + name: 'account.email', + }); + const passwordInput = createInputElement({ + type: 'password', + name: 'account.password', + }); + const confirmPasswordInput = createInputElement({ + type: 'text', + name: 'account.confirmPassword', + }); + confirmPasswordInput.setAttribute('data-felte-extender-persist-ignore', ''); + const someSecretInput = createInputElement({ + type: 'text', + name: 'account.someSecret', + }); + fieldsetElement.appendChild(emailInput); + fieldsetElement.appendChild(passwordInput); + fieldsetElement.appendChild(confirmPasswordInput); + fieldsetElement.appendChild(someSecretInput); + formElement.appendChild(fieldsetElement); + + const { destroy } = form(formElement); + + setTimeout(() => { + userEvent.type(emailInput, 'jacek@soplica.com'); + userEvent.type(passwordInput, 'password'); + userEvent.type(confirmPasswordInput, 'password'); + userEvent.type(someSecretInput, 'secret'); + }); + await waitFor(() => { + const stringData = localStorage.getItem('test-update'); + const data = stringData ? JSON.parse(stringData) : {}; + expect(data).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + }); + destroy(); +}); + +Extender('restores from localStorage', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + + localStorage.setItem( + 'test-restore', + JSON.stringify({ + account: { email: 'jacek@soplica.com', password: 'password' }, + }) + ); + type Data = { + account: { + email: string; + password: string; + }; + }; + const { form, data } = createForm({ + initialValues: { + account: { + email: '', + password: '', + }, + }, + onSubmit: sinon.fake(), + extend: extender({ id: 'test-restore' }), + }); + + const emailInput = createInputElement({ + name: 'account.email', + }); + const passwordInput = createInputElement({ + type: 'password', + name: 'account.password', + }); + formElement.appendChild(emailInput); + formElement.appendChild(passwordInput); + + form(formElement); + + await waitFor(() => { + expect(get(data)).to.deep.equal({ + account: { email: 'jacek@soplica.com', password: 'password' }, + }); + expect(emailInput.value).to.equal('jacek@soplica.com'); + expect(passwordInput.value).to.equal('password'); + }); +}); + +Extender.run(); diff --git a/packages/extender-persist/tests/extender.test.ts b/packages/extender-persist/tests/extender.test.ts deleted file mode 100644 index 13fcbdbd..00000000 --- a/packages/extender-persist/tests/extender.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { createForm } from 'felte'; -import { waitFor, screen } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import { createDOM, cleanupDOM, createInputElement } from './common'; -import { extender } from '../src'; -import { get } from 'svelte/store'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn() })); - -describe('Extender persist', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('updates localStorage', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - type Data = { - account: { - email: string; - password: string; - confirmPassword: string; - someSecret: string; - }; - }; - const { form } = createForm({ - initialValues: { - account: { - email: '', - password: '', - confirmPassword: '', - someSecret: '', - }, - }, - onSubmit: jest.fn(), - extend: extender({ - id: 'test-update', - ignore: ['account.someSecret', 'account.password'], - }), - }); - - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'account'; - const emailInput = createInputElement({ - name: 'email', - }); - const passwordInput = createInputElement({ - type: 'password', - name: 'password', - }); - const confirmPasswordInput = createInputElement({ - type: 'text', - name: 'confirmPassword', - }); - confirmPasswordInput.setAttribute('data-felte-extender-persist-ignore', ''); - const someSecretInput = createInputElement({ - type: 'text', - name: 'someSecret', - }); - fieldsetElement.appendChild(emailInput); - fieldsetElement.appendChild(passwordInput); - fieldsetElement.appendChild(confirmPasswordInput); - fieldsetElement.appendChild(someSecretInput); - formElement.appendChild(fieldsetElement); - - const { destroy } = form(formElement); - - setTimeout(() => { - userEvent.type(emailInput, 'jacek@soplica.com'); - userEvent.type(passwordInput, 'password'); - userEvent.type(confirmPasswordInput, 'password'); - userEvent.type(someSecretInput, 'secret'); - }); - await waitFor(() => { - const stringData = localStorage.getItem('test-update'); - const data = stringData ? JSON.parse(stringData) : {}; - expect(data).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - }); - destroy(); - }); - - test('restores from localStorage', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - - localStorage.setItem( - 'test-restore', - JSON.stringify({ - account: { email: 'jacek@soplica.com', password: 'password' }, - }) - ); - type Data = { - account: { - email: string; - password: string; - }; - }; - const { form, data } = createForm({ - initialValues: { - account: { - email: '', - password: '', - }, - }, - onSubmit: jest.fn(), - extend: extender({ id: 'test-restore' }), - }); - - const emailInput = createInputElement({ - name: 'account.email', - }); - const passwordInput = createInputElement({ - type: 'password', - name: 'account.password', - }); - formElement.appendChild(emailInput); - formElement.appendChild(passwordInput); - - form(formElement); - - await waitFor(() => { - expect(get(data)).toEqual({ - account: { email: 'jacek@soplica.com', password: 'password' }, - }); - expect(emailInput.value).toBe('jacek@soplica.com'); - expect(passwordInput.value).toBe('password'); - }); - }); -}); diff --git a/packages/extender-persist/tests/mocks/svelte.js b/packages/extender-persist/tests/mocks/svelte.js new file mode 100644 index 00000000..069b2a6f --- /dev/null +++ b/packages/extender-persist/tests/mocks/svelte.js @@ -0,0 +1,3 @@ +import * as sinon from 'sinon'; + +export const onDestroy = sinon.fake(); diff --git a/packages/felte/CHANGELOG.md b/packages/felte/CHANGELOG.md index d99d5fce..4bc8df38 100644 --- a/packages/felte/CHANGELOG.md +++ b/packages/felte/CHANGELOG.md @@ -1,5 +1,341 @@ # felte +## 1.0.0-next.27 + +### Patch Changes + +- @felte/core@1.0.0-next.27 + +## 1.0.0-next.26 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/core@1.0.0-next.26 + +## 1.0.0-next.25 + +### Minor Changes + +- fcbdaed: Add `swapFields` and `moveField` helper functions + +### Patch Changes + +- Updated dependencies [fcbdaed] + - @felte/core@1.0.0-next.25 + +## 1.0.0-next.24 + +### Minor Changes + +- 990034e: Add `interacted` store to show which is the last field the user has interacted with +- 0faaa8f: Add isValidating store + +### Patch Changes + +- Updated dependencies [990034e] +- Updated dependencies [5c71750] +- Updated dependencies [0faaa8f] + - @felte/core@1.0.0-next.24 + +## 1.0.0-next.23 + +### Patch Changes + +- Updated dependencies [8282a70] + - @felte/core@1.0.0-next.23 + +## 1.0.0-next.22 + +### Minor Changes + +- c412050: Add support for custom controls with `createField`/`useField` + +### Patch Changes + +- Updated dependencies [b9ea48d] + - @felte/core@1.0.0-next.22 + +## 1.0.0-next.21 + +### Patch Changes + +- Updated dependencies [0b38b98] + - @felte/core@1.0.0-next.21 + +## 1.0.0-next.20 + +### Patch Changes + +- 2e7aad3: Add type for keyed Data +- Updated dependencies [2e7aad3] +- Updated dependencies [2e7aad3] + - @felte/core@1.0.0-next.20 + +## 1.0.0-next.19 + +### Minor Changes + +- c8c1511: Add unique key to field arrays + +### Patch Changes + +- Updated dependencies [c8c1511] + - @felte/core@1.0.0-next.19 + +## 1.0.0-next.18 + +### Major Changes + +- 093482a: BREAKING: Setting directly to `data` using `data.set` no longer touches the field. The `setFields` helper should be used instead if this behaviour is desired. + +### Minor Changes + +- 093482a: Add isValidating store + +### Patch Changes + +- Updated dependencies [093482a] +- Updated dependencies [093482a] + - @felte/core@1.0.0-next.18 + +## 1.0.0-next.17 + +### Patch Changes + +- Updated dependencies [dd52c94] + - @felte/core@1.0.0-next.17 + +## 1.0.0-next.16 + +### Major Changes + +- a45d56c: BREAKING: `errors` and `warning` stores will either have `null` or an array of strings as errors + +### Patch Changes + +- Updated dependencies [a45d56c] + - @felte/core@1.0.0-next.16 + +## 1.0.0-next.15 + +### Major Changes + +- 452fe5a: BREAKING: Remove `data-felte-index` attribute support. + + This means that you should replace this: + + ```html + + ``` + + To this: + + ```html + + ``` + + This was done in order to allow for future improvements of the type system for TypeScript users, and to also follow the same behaviour the browser would do if JavaScript is disabled + +- 15d0ce2: BREAKING: Stop grabbing nested names from fieldset + + This means that this won't work anymore: + + ```html +
+ +
+ ``` + + So it needs to be changed to this: + + ```html +
+ +
+ ``` + + This was done to allow for future improvements on type-safety, as well to keep consistency with the browser's behaviour when JavaScript is disabled. + +### Patch Changes + +- Updated dependencies [452fe5a] +- Updated dependencies [15d0ce2] + - @felte/core@1.0.0-next.15 + +## 1.0.0-next.14 + +### Major Changes + +- b7ef442: BREAKING: Remove `addWarnValidator` in favour of options to `addValidator`. + + This gives a smaller and more unified API, as well as opening to add more options in the future. + + If you have an extender using `addWarnValidator`, you must update it by calling `addValidator` instead with the following options: + + ```javascript + addValidator(yourValidationFunction, { level: 'warning' }); + ``` + +### Minor Changes + +- a1dbc28: Improve types + +### Patch Changes + +- Updated dependencies [a1dbc28] +- Updated dependencies [ec740a0] +- Updated dependencies [34e0393] +- Updated dependencies [b7ef442] +- Updated dependencies [477bb45] + - @felte/core@1.0.0-next.14 + +## 1.0.0-next.13 + +### Patch Changes + +- f315439: Export events as types +- Updated dependencies [f315439] + - @felte/core@1.0.0-next.13 + +## 1.0.0-next.12 + +### Minor Changes + +- dc1f21a: Add helper functions to context passed to `onSuccess`, `onSubmit` and `onError` +- eea3afa: Pass context data to `onError` and `onSuccess` + +### Patch Changes + +- Updated dependencies [dc1f21a] +- Updated dependencies [eea3afa] + - @felte/core@1.0.0-next.12 + +## 1.0.0-next.11 + +### Patch Changes + +- 38fbb49: Point "browser" field to esm bundle +- Updated dependencies [38fbb49] + - @felte/core@1.0.0-next.11 + +## 1.0.0-next.10 + +### Patch Changes + +- @felte/core@1.0.0-next.10 + +## 1.0.0-next.9 + +### Patch Changes + +- 46b05e3: Fix when publishing as modules +- Updated dependencies [46b05e3] + - @felte/core@1.0.0-next.9 + +## 1.0.0-next.8 + +### Patch Changes + +- e49c094: Use `preserveModules` for better tree-shaking +- Updated dependencies [e49c094] + - @felte/core@1.0.0-next.8 + +## 1.0.0-next.7 + +### Patch Changes + +- Updated dependencies [62ceb3f] + - @felte/core@1.0.0-next.7 + +## 1.0.0-next.6 + +### Minor Changes + +- f9b9125: Add `feltesuccess` and `felteerror` events +- 96c3c18: Add default submit handler + +### Patch Changes + +- Updated dependencies [f9b9125] +- Updated dependencies [96c3c18] +- Updated dependencies [d1b62bf] + - @felte/core@1.0.0-next.6 + +## 1.0.0-next.5 + +### Patch Changes + +- @felte/core@1.0.0-next.5 + +## 1.0.0-next.4 + +### Patch Changes + +- 8c29b4a: Fix unset on Safari +- Updated dependencies [8c29b4a] + - @felte/core@1.0.0-next.4 + +## 1.0.0-next.3 + +### Minor Changes + +- 6f48123: Add `addField` helper function + +### Patch Changes + +- Updated dependencies [6f48123] + - @felte/core@1.0.0-next.3 + +## 1.0.0-next.2 + +### Major Changes + +- 77de471: BREAKING: Stop proxying inputs. This was causing all sorts of race conditions which were a headache to solve. Instead we're going to keep a single recommendation: If you wish to programatically set the value of an input, use the `setFields` helper. +- 02a77e3: BREAKING: When removing an input from an array of inputs, Felte now splices the array instead of setting the value to `null`/`undefined`. This means that an `index` on an array of inputs is no longer a _unique_ identifier and the value can move around if fields are added/removed. + +### Patch Changes + +- Updated dependencies [77de471] +- Updated dependencies [02a77e3] + - @felte/core@1.0.0-next.2 + +## 1.0.0-next.0 + +### Major Changes + +- a2ea0b2: BREAKING: `setFields` no longer touches a field by default. It needs to be explicit and it's only possible when passing a string path. E.g. `setField(‘email’ , 'zaphod@beeblebrox.com')` now is `setFields('email', 'zaphod@beeblebrox.com', true)`. +- 1dd68e7: BREAKING: Remove `data-felte-unset-on-remove` in favour of `data-felte-keep-on-remove`. Felte will now remove fields by default if removed from the DOM. + + To keep the same behaviour as before, add `data-felte-keep-on-remove` to any dynamic inputs you had that didn't have `data-felte-unset-on-remove` previously. And remove `data-felte-unset-on-remove` from the inputs that had it, or replace it for `data-felte-keep-on-remove="false"` if it was used to override a parent's attribute. + +- 6109533: BREAKING: apply transforms to initialValues +- 0d22bc6: BREAKING: Helpers have been completely reworked. + `setField` and `setFields` have been unified in a single `setFields` helper. + Others such as `setError` and `setWarning` have been pluralized to `setErrors` and `setWarnings` since now they can accept the whole object. + `setTouched` now requires to be passed the value to assign. E.g. `setTouched('path')` is now `setTouched('path', true)`. It no longer accepts an index as an argument since that can be assigned in the path itself using `[]`. +- 3d571bb: BREAKING: Remove `getField` helper in favor of `getValue` export. E.g. `getField('email')` now is `getValue($data, 'email')` and accessors. +- 2c0f874: Make type of helpers and stores looser when using a transform function + +### Minor Changes + +- c1f32a0: Add `unsetField` and `resetField` helper functions + +### Patch Changes + +- Updated dependencies [1bc036e] +- Updated dependencies [6431ee4] +- Updated dependencies [a2ea0b2] +- Updated dependencies [1dd68e7] +- Updated dependencies [6109533] +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/core@1.0.0-next.0 + ## 0.9.1 ### Patch Changes diff --git a/packages/felte/README.md b/packages/felte/README.md index 519f427f..bdf5985c 100644 --- a/packages/felte/README.md +++ b/packages/felte/README.md @@ -6,9 +6,7 @@ [![NPM Downloads](https://img.shields.io/npm/dw/felte)](https://www.npmjs.com/package/felte) [![codecov](https://codecov.io/gh/pablo-abc/felte/branch/main/graph/badge.svg?token=T73OJZ50LC)](https://codecov.io/gh/pablo-abc/felte) -Felte is a simple to use form library for Svelte. It is based on Svelte stores and Svelte actions for its functionality. No `Field` or `Form` components, just plain stores and actions to build your form however you like. You can see it in action in this [CodeSandbox demo](https://codesandbox.io/s/felte-demo-wce2h?file=/App.svelte)! - -**STATUS:** Useable. Felte's API is stable enough to be used. I feel the main API is solid enough to not need breaking changes that fast, but more usage input would be useful. Reporter packages migh have breaking changes more often. If you're interested please give it a try and feel free to open an issue if there's anything missing! We would still recommend pinning the version of Felte or any of its packages and checking the changelogs whenever you want to upgrade. +Felte is a simple to use form library for Svelte. It is based on Svelte stores and Svelte actions for its functionality. No `Field` or `Form` components, just plain stores and actions to build your form however you like. You can see it in action in this [CodeSandbox demo](https://codesandbox.io/s/felte-v1-demo-svelte-0egr6?file=/App.svelte)! ## Features @@ -20,7 +18,7 @@ Felte is a simple to use form library for Svelte. It is based on Svelte stores a - Official solutions for error reporting using `reporter` packages. - Well tested. Currently at [99% code coverage](https://app.codecov.io/gh/pablo-abc/felte) and constantly working on improving test quality. - Supports validation with [yup](./packages/validator-yup/README.md), [zod](./packages/validator-zod/README.md) and [superstruct](./packages/validator-superstruct/README.md). -- Easily [extend its functionality](https://felte.dev/docs#extending-felte). +- Easily [extend its functionality](https://felte.dev/docs/svelte/extending-felte). ## Simple usage example @@ -54,238 +52,4 @@ yarn add felte ## Usage -Felte exports a single `createForm` function that accepts a config object with the following interface: - -```typescript -type ValidationFunction = ( - values: Data -) => Errors | undefined | Promise | undefined>; - -type SubmitContext = { - form?: HTMLFormElement; - controls?: FormControl[]; - config: FormConfig; -}; - -interface FormConfig> { - initialValues?: D; - validate?: ValidationFunction | ValidationFunction[]; - warn?: ValidationFunction | ValidationFunction[]; - onSubmit: (values: D, context: SubmitContext) => void; - onError?: (errors: unknown) => void | Errors; - extend?: Extender | Extender[]; -} -``` - -- `initialValues` refers to the initial values of the form. -- `validate` is a custom validation function that must return an object with the same shape as `data`, but with error messages or `undefined` as values. It can be an array of functions whose validation errors will be merged. -- `warn` is a custom validation function that must return an object with the same shape as `data`, but with warning messages or `undefined` as values. It can be an array of functions whose validation errors will be merged. -- `onSubmit` is the function that will be executed when the form is submited. -- `onError` is a an optional function that will run if the submit throws an exception. It will contain the error catched. If you return an object with the same shape as `Errors`, these errors can be reported by a reporter. -- `extend` a function or list of functions to extend Felte's behaviour. Currently it can be used to add `reporters` to Felte, these can handle error reporting for you. You can read more about them in [Felte's documentation](https://felte.dev/docs#reporters). - -When called, `createForm` will return an object with the following interface: - -```typescript -type FormAction = (node: HTMLFormElement) => { destroy: () => void }; -type FieldValue = string | string[] | boolean | number | File | File[]; -type CreateSubmitHandlerConfig = { - onSubmit: (values: D) => void; - validate?: ValidationFunction | ValidationFunction[]; - warn?: ValidationFunction | ValidationFunction[]; - onError: (errors: unknown) => void | Errors; -} - -export interface Form> { - form: FormAction; - data: Writable; - errors: Readable>; - warnings: Writable>; - touched: Writable>; - handleSubmit: (e: Event) => void; - isValid: Readable; - isSubmitting: Writable; - isDirty: Writable; - // Helper functions: - setTouched: (path: string) => void; - setError: (path: string, error: string | string[]) => void; - setField: (path: string, value?: FieldValue, touch?: boolean) => void; - getField: (path: string) => FieldValue | FieldValue[]; - setFields: (values: Data) => void; - validate: (values: D) => Promise | undefined>; - reset: () => void; - setInitialValues: (values: D) => void; - createSubmitHandler: (config?: CreateSubmitHandlerConfig) => (event?: Event) => void; -} -``` - -- `form` is a function to be used with the `use:` directive for Svelte. -- `data` is a writable store with the current values from the form. -- `errors` is a writable store with the current errors. -- `warnings`is a writable store with warnings set from the `warn` function. -- `touched` is a writable store that defines if the fields have been touched. It's an object with the same keys as data, but with boolean values. -- `handleSubmit` is the event handler to be passed to `on:submit`. -- `isValid` is a readable store that only holds a boolean denoting if the form has any errors or not. -- `isSubmitting` is a writable store that only holds a boolean denoting if the form is submitting or not. -- `isDirty` is a writable store that only holds a boolean denoting if the form is dirty or not. -- `setTouched` is a helper function to touch a specific field. -- `setError` is a helper function to set an error in a specific field. -- `setField` is a helper function to set the data of a specific field. If undefined, it clears the field. If you set `touch` to `false` the field will not be touched with this change. -- `getField` is a helper function to get a value from `data` using a string path. -- `setFields` is a helper function to set the data of all fields. -- `validate` is a helper function that forces validation of the whole form, updating the `errors` store and touching every field. Similar to what happens on submit. -- `reset` is a helper function that resets the form to its original values when the page was loaded. -- `setInitialValues` is a helper function that sets the initialValues Felte handles internally. If called after initialization of the form, these values will be used when calling `reset`. -- `createSubmitHandler` is a helper function that creates a submit handler with overriden `onSubmit`, `onError` and/or `validate` functions. If no config is passed it uses the default configuration from `createForm`. - -> If the helper functions are called before initialization of the form, whatever you set will be overwritten. - -If a `validate` function is provided, Felte will add a `novalidate` to the form so it doesn't clash with the browser's built-in validations such as the ones resulting from `required`, `pattern` or due to types such as `email` or `url`. This is done on JavaScript's mount so the browser's validations will be run if JavaScript does not load. You may add these attributes anyway for accessibility purposes, and leaving them in may help you make these forms works even if JavaScript hasn't loaded yet. If a `validate` function is not defined, Felte will not interfere with the browser's validation. - -### Using the form action - -The recommended way to use it is by using the `form` action from `createForm` and using it in the form element of your form. - -```html - - -
- - - -
-``` - -That's all you need! With the example above you'll see **Felte** automatically updating the values of `data` when you type, as well as `errors` when finding an error. Note that the only required property for `createForm` is `onSubmit`. - -Also note that using the `data` and `errors` store is completely optional in this method, since you already get access to the values of the form in the `onSubmit` function, and validation errors are reported with the browser's Constraint Validation API by using the `@felte/reporter-cvapi` package. - -> If using Felte this way, make sure to set the `name` attributes of your inputs since that is what Felte uses to map to the `data` store. - -> Default values are taken from the fields' `value` and/or `checked` attributes. `initialValues` is ignored if you use this approach. - -Using this approach `data` will be undefined until the form element loads. - -#### Nested forms - -Felte supports the usage of nested objects for forms by setting the name of an input to the format of `object.prop`. It supports multiple levels. The behaviour is the same as previously explained, taking the default values from the `value` and/or `checked` attributes when appropriate. - -```html -
- - - - - -
-``` - -You can also "namespace" the inputs using the `fieldset` tag like this: - -```html -
-
- - -
-
- - -
- -
-``` - -Both of these would result in a data object with this shape: - -```js -{ - account: { - email: '', - password: '', - }, - profile: { - firstName: '', - lastName: '', - }, -} -``` - -Again, be mindful of the fact that `data` will be undefined until the form element loads. - -#### Dynamic forms - -You can freely add/remove fields from the form and Felte will handle it. - -```html -
-
- - -
- {#if condition} -
- - -
- {/if} - -
-``` - -The `data-felte-unset-on-remove=true` tells Felte to remove the property from the data object when the HTML element is removed from the DOM. By default this is false. If you do not set this attribute to `true`, the properties from the removed elements will remain in the data object untouched. - -You can set the `data-felte-unset-on-remove=true` attribute to a `fieldset` element and all the elements contained within the fieldset will be unset on removal of the node, unless any element within the fieldset element have `data-felte-unset-on-remove` set to false. - -> Felte takes any value that is not `true` as `false` on the `data-felte-unset-on-remove` attribute. - -## Binding to inputs - -Since `data` is a writable store, you can also bind the data properties to your inputs instead of using the `form` action. - -```html - - -
- - - -
-``` - -With this approach you should see a similar behaviour to the previous way of using this. Note that the `name` attribute is optional here, but the `initialValues` property for `createForm` is required. It is a bit more verbose, so it's recommended to use the previous way of handling forms. +To learn more about how to use `felte` to handle your forms, check the [official documentation](https://felte.dev/docs/svelte/getting-started). diff --git a/packages/felte/jest.config.js b/packages/felte/jest.config.js deleted file mode 100644 index 553799a5..00000000 --- a/packages/felte/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], -}; diff --git a/packages/felte/package.json b/packages/felte/package.json index 25e473ab..297a8d86 100644 --- a/packages/felte/package.json +++ b/packages/felte/package.json @@ -1,11 +1,15 @@ { "name": "felte", - "version": "0.9.1", + "version": "1.0.0-next.27", "description": "An extensible form library for Svelte", - "main": "dist/index.js", - "browser": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", + "main": "dist/cjs/index.cjs", + "browser": "dist/esm/index.js", + "module": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "sideEffects": false, "author": "Pablo Berganza ", "license": "MIT", @@ -19,12 +23,12 @@ ], "scripts": { "prebuild": "rimraf ./dist", - "build": "cross-env NODE_ENV=production rollup -c", + "build": "pnpm prebuild && cross-env NODE_ENV=production rollup -c", "docs:build": "typedoc --out ../../docs", "dev": "rollup -cw", "prepublishOnly": "pnpm build && pnpm test", - "test": "jest", - "test:ci": "jest --ci --coverage" + "test": "uvu -r module-alias/register -r tsm -r global-jsdom/register tests -i common -i mocks", + "test:ci": "nyc -n src pnpm test" }, "peerDependencies": { "svelte": "^3.31.0" @@ -39,14 +43,20 @@ "access": "public" }, "devDependencies": { - "ts-jest": "^26.5.0" + "module-alias": "^2.2.2", + "svelte": "^3.46.4", + "uvu": "^0.5.3" }, "exports": { ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.js", - "default": "./dist/index.mjs" + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs", + "default": "./dist/esm/index.js" }, "./package.json": "./package.json" + }, + "_moduleAliases": { + "svelte": "tests/mocks/svelte.js", + "svelte/store": "node_modules/svelte/store/index.mjs" } } diff --git a/packages/felte/rollup.config.js b/packages/felte/rollup.config.js index 866efb76..e414b96e 100644 --- a/packages/felte/rollup.config.js +++ b/packages/felte/rollup.config.js @@ -2,22 +2,28 @@ import typescript from 'rollup-plugin-ts'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; -import { terser } from 'rollup-plugin-terser'; -import bundleSize from 'rollup-plugin-bundle-size'; +import renameNodeModules from 'rollup-plugin-rename-node-modules'; import pkg from './package.json'; const prod = process.env.NODE_ENV === 'production'; -const name = pkg.name - .replace(/^(@\S+\/)?(svelte-)?(\S+)/, '$3') - .replace(/^\w/, (m) => m.toUpperCase()) - .replace(/-\w/g, (m) => m[1].toUpperCase()); export default { input: './src/index.ts', external: ['svelte/store', 'svelte'], output: [ - { file: pkg.browser, format: 'cjs', sourcemap: prod, name }, - { file: pkg.module, format: 'esm', sourcemap: prod }, + { + file: pkg.main, + format: 'cjs', + sourcemap: prod, + }, + { + dir: 'dist/esm', + format: 'esm', + sourcemap: prod, + exports: 'named', + preserveModules: true, + preserveModulesRoot: 'src', + }, ], plugins: [ replace({ @@ -28,8 +34,7 @@ export default { }), resolve({ browser: true }), commonjs(), - typescript(), - prod && terser(), - prod && bundleSize(), + typescript({ browserlist: false }), + renameNodeModules('external', prod), ], }; diff --git a/packages/felte/src/create-form.ts b/packages/felte/src/create-form.ts index 63fb9f61..96bcef66 100644 --- a/packages/felte/src/create-form.ts +++ b/packages/felte/src/create-form.ts @@ -3,37 +3,30 @@ import { writable } from 'svelte/store'; import { onDestroy } from 'svelte'; import type { Form, + Paths, FormConfig, - FormConfigWithInitialValues, - FormConfigWithoutInitialValues, + FormConfigWithTransformFn, + FormConfigWithoutTransformFn, + Stores, + KnownStores, + UnknownStores, + Helpers, + KnownHelpers, + UnknownHelpers, } from '@felte/core'; type Obj = Record; -/** - * Creates the stores and `form` action to make the form reactive. - * In order to use auto-subscriptions with the stores, call this function at the top-level scope of the component. - * - * @param config - Configuration for the form itself. Since `initialValues` is set, `Data` will not be undefined - * - * @category Main - */ export function createForm( - config: FormConfigWithInitialValues & Ext -): Form; -/** - * Creates the stores and `form` action to make the form reactive. - * In order to use auto-subscriptions with the stores, call this function at the top-level scope of the component. - * - * @param config - Configuration for the form itself. Since `initialValues` is not set (when only using the `form` action), `Data` will be undefined until the `form` element loads. - */ + config: FormConfigWithTransformFn & Ext +): Form & UnknownHelpers> & UnknownStores; export function createForm( - config: FormConfigWithoutInitialValues & Ext -): Form; + config?: FormConfigWithoutTransformFn & Ext +): Form & KnownHelpers> & KnownStores; export function createForm( - config: FormConfig & Ext -): Form { - const { cleanup, ...rest } = coreCreateForm(config, { + config?: FormConfig & Ext +): Form & Helpers> & Stores { + const { cleanup, startStores, ...rest } = coreCreateForm(config ?? {}, { storeFactory: writable, }); onDestroy(cleanup); diff --git a/packages/felte/src/index.ts b/packages/felte/src/index.ts index 536d34b0..62d26c9b 100644 --- a/packages/felte/src/index.ts +++ b/packages/felte/src/index.ts @@ -1 +1,9 @@ -export * from './create-form'; +export type { + FelteSuccessDetail, + FelteErrorDetail, + FelteSuccessEvent, + FelteErrorEvent, +} from '@felte/core'; +export type { Field, FieldConfig } from '@felte/core'; +export { getValue, FelteSubmitError, createField } from '@felte/core'; +export { createForm } from './create-form'; diff --git a/packages/felte/tests/common.ts b/packages/felte/tests/common.ts index 010f81d0..871f275e 100644 --- a/packages/felte/tests/common.ts +++ b/packages/felte/tests/common.ts @@ -1,3 +1,5 @@ +import 'uvu-expect-dom/extend'; + export function createDOM(): void { const formElement = document.createElement('form'); formElement.name = 'test-form'; @@ -14,6 +16,7 @@ export type InputAttributes = { name?: string; value?: string; checked?: boolean; + index?: number; }; export function createInputElement(attrs: InputAttributes): HTMLInputElement { @@ -22,6 +25,8 @@ export function createInputElement(attrs: InputAttributes): HTMLInputElement { if (attrs.type) inputElement.type = attrs.type; if (attrs.value) inputElement.value = attrs.value; if (attrs.checked) inputElement.checked = attrs.checked; + if (typeof attrs.index !== 'undefined') + inputElement.name = `${attrs.name}.${attrs.index}.value`; inputElement.required = !!attrs.required; return inputElement; } @@ -38,8 +43,7 @@ export function createMultipleInputElements( ): HTMLInputElement[] { const inputs = []; for (let i = 0; i < amount; i++) { - const input = createInputElement(attr); - input.dataset.felteIndex = String(i); + const input = createInputElement({ ...attr, index: i }); inputs.push(input); } return inputs; diff --git a/packages/felte/tests/create-field.spec.ts b/packages/felte/tests/create-field.spec.ts new file mode 100644 index 00000000..7d68c2f3 --- /dev/null +++ b/packages/felte/tests/create-field.spec.ts @@ -0,0 +1,204 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { waitFor, screen } from '@testing-library/dom'; +import { createInputElement, createDOM, cleanupDOM } from './common'; +import { createField } from '../src'; + +function createContentEditable() { + const input = document.createElement('div'); + input.contentEditable = 'true'; + input.tabIndex = 0; + input.setAttribute('role', 'textbox'); + return input; +} + +const Field = suite('Custom controls with createField'); + +Field.before.each(createDOM); +Field.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +Field('adds hidden input when none is present', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + + expect(formElement.querySelector('input[name="test"]')).to.be.null; + + const { field } = createField('test'); + + field(inputElement); + + await waitFor(() => { + expect(formElement.querySelector('input[name="test"]')).not.to.be.null; + }); +}); + +Field('does not add hidden input when one is already present', () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const hiddenElement = createInputElement({ name: 'test', type: 'hidden' }); + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + formElement.appendChild(hiddenElement); + + expect(formElement.querySelectorAll('input[name="test"]').length).to.equal(1); + + const { field } = createField({ name: 'test' }); + + field(inputElement); + + expect(formElement.querySelectorAll('input[name="test"]').length).to.equal(1); +}); + +Field( + 'does not add hidden input when assigning to a native input', + async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ name: '', type: 'text' }); + formElement.appendChild(inputElement); + + expect(formElement.querySelector('input[name="test"]')).to.be.null; + + const { field } = createField({ name: 'test', touchOnChange: false }); + + field(inputElement); + + await waitFor(() => { + expect( + formElement.querySelectorAll('input[name="test"]').length + ).to.equal(1); + expect(formElement.querySelector('input[name="test"]')).to.be.visible; + }); + } +); + +Field('dispatches input events', async () => { + const inputListener = sinon.fake(); + const blurListener = sinon.fake(); + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + formElement.addEventListener('input', inputListener); + formElement.addEventListener('focusout', blurListener); + + const { field, onChange, onBlur } = createField('test'); + + sinon.assert.notCalled(inputListener); + sinon.assert.notCalled(blurListener); + + onChange('ignored value'); + onBlur(); + + sinon.assert.notCalled(inputListener); + sinon.assert.notCalled(blurListener); + + field(inputElement); + + await waitFor(() => { + const hiddenElement = document.querySelector( + 'input[name="test"]' + ) as HTMLInputElement; + + expect(hiddenElement).not.to.be.null; + + onChange('new value'); + + expect(hiddenElement.value).to.equal('new value'); + sinon.assert.calledWith( + inputListener, + sinon.match({ + target: hiddenElement, + }) + ); + sinon.assert.notCalled(blurListener); + + onBlur(); + + sinon.assert.calledWith( + blurListener, + sinon.match({ + target: hiddenElement, + }) + ); + }); + + formElement.removeEventListener('input', inputListener); + formElement.removeEventListener('focusout', blurListener); +}); + +Field('dispatches change events', async () => { + const changeListener = sinon.fake(); + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + formElement.addEventListener('change', changeListener); + + const { field, onChange } = createField('test', { touchOnChange: true }); + + sinon.assert.notCalled(changeListener); + + onChange('ignored value'); + + sinon.assert.notCalled(changeListener); + + const { destroy } = field(inputElement); + + await waitFor(() => { + const hiddenElement = document.querySelector( + 'input[name="test"]' + ) as HTMLInputElement; + + expect(hiddenElement).not.to.be.null; + }); + + onChange('new value'); + + formElement.removeEventListener('change', changeListener); + + destroy?.(); +}); + +Field('listens to hidden input attribute changes', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const hiddenElement = createInputElement({ name: 'test', type: 'hidden' }); + const inputElement = createContentEditable(); + formElement.appendChild(inputElement); + formElement.appendChild(hiddenElement); + + const { field } = createField('test'); + + field(inputElement); + + await new Promise((r) => setTimeout(r, 10)); + + hiddenElement.setAttribute('aria-invalid', 'true'); + await waitFor(() => { + expect(inputElement).to.to.be.invalid; + }); + hiddenElement.removeAttribute('aria-invalid'); + await waitFor(() => { + expect(inputElement).not.be.valid; + }); + + hiddenElement.setAttribute('data-felte-validation-message', 'a message'); + await waitFor(() => { + expect(inputElement) + .to.have.attribute('data-felte-validation-message') + .that.equals('a message'); + }); + hiddenElement.removeAttribute('data-felte-validation-message'); + await waitFor(() => { + expect(inputElement).not.to.have.attribute('data-felte-validation-message'); + }); +}); + +Field('does nothing with unmounted element', () => { + const inputElement = createContentEditable(); + const { field } = createField('test'); + expect(field(inputElement)).to.have.property('destroy'); +}); + +Field.run(); diff --git a/packages/felte/tests/dom-interactions.spec.ts b/packages/felte/tests/dom-interactions.spec.ts new file mode 100644 index 00000000..e5ab9a6b --- /dev/null +++ b/packages/felte/tests/dom-interactions.spec.ts @@ -0,0 +1,174 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { waitFor, screen } from '@testing-library/dom'; +import { + createInputElement, + createDOM, + cleanupDOM, + createMultipleInputElements, +} from './common'; +import { get } from 'svelte/store'; +import { createForm } from '../src'; + +const DomMutations = suite('Form action DOM mutations'); + +DomMutations.before.each(createDOM); +DomMutations.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +DomMutations('Adds novalidate to form when using a validate function', () => { + const { form } = createForm({ + validate: sinon.fake(), + onSubmit: sinon.fake(), + }); + const formElement = screen.getByRole('form') as HTMLFormElement; + expect(formElement).not.to.have.attribute('novalidate'); + + form(formElement); + + expect(formElement).to.have.attribute('novalidate'); +}); + +DomMutations( + 'Propagates felte-keep-on-remove attribute respecting specificity', + () => { + const { form } = createForm({ onSubmit: sinon.fake() }); + const outerFieldset = document.createElement('fieldset'); + outerFieldset.dataset.felteKeepOnRemove = 'false'; + const outerTextInput = createInputElement({ name: 'outerText' }); + const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); + outerSecondaryInput.dataset.felteKeepOnRemove = 'true'; + const innerFieldset = document.createElement('fieldset'); + const innerTextInput = createInputElement({ name: 'innerText' }); + const innerSecondaryinput = createInputElement({ name: 'innerSecondary' }); + innerSecondaryinput.dataset.felteKeepOnRemove = 'true'; + innerFieldset.append(innerTextInput, innerSecondaryinput); + outerFieldset.append(outerTextInput, outerSecondaryInput, innerFieldset); + const formElement = screen.getByRole('form') as HTMLFormElement; + formElement.appendChild(outerFieldset); + form(formElement); + [outerFieldset, outerTextInput, innerFieldset, innerTextInput].forEach( + (el) => { + expect(el) + .to.have.attribute('data-felte-keep-on-remove') + .that.equals('false'); + } + ); + [outerSecondaryInput, innerSecondaryinput].forEach((el) => { + expect(el) + .to.have.attribute('data-felte-keep-on-remove') + .that.equals('true'); + }); + } +); + +DomMutations('Keeps fields tagged with felte-keep-on-remove', async () => { + const { form, data } = createForm({ onSubmit: sinon.fake() }); + const outerFieldset = document.createElement('fieldset'); + outerFieldset.dataset.felteKeepOnRemove = 'false'; + const outerTextInput = createInputElement({ name: 'outerText' }); + const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); + outerSecondaryInput.dataset.felteKeepOnRemove = ''; + const multipleOuterInputs = createMultipleInputElements({ + name: 'multiple', + }); + multipleOuterInputs[1].dataset.felteKeepOnRemove = 'true'; + const innerFieldset = document.createElement('fieldset'); + const innerTextInput = createInputElement({ name: 'inner.innerText' }); + const innerSecondaryinput = createInputElement({ + name: 'inner.innerSecondary', + }); + innerSecondaryinput.dataset.felteKeepOnRemove = 'true'; + innerFieldset.append(innerTextInput, innerSecondaryinput); + outerFieldset.append( + ...multipleOuterInputs, + outerTextInput, + outerSecondaryInput, + innerFieldset + ); + const formElement = screen.getByRole('form') as HTMLFormElement; + formElement.appendChild(outerFieldset); + form(formElement); + expect(get(data)).to.deep.include({ + outerText: '', + outerSecondary: '', + inner: { + innerText: '', + innerSecondary: '', + }, + }); + expect(get(data)) + .to.have.a.property('multiple') + .that.is.an('array') + .with.lengthOf(3) + .and.has.a.nested.property('0.key') + .that.is.a('string'); + formElement.removeChild(outerFieldset); + await waitFor(() => { + expect(get(data)).to.deep.include({ + outerSecondary: '', + inner: { + innerSecondary: '', + }, + }); + expect(get(data)).to.have.a.property('multiple').with.lengthOf(1); + }); +}); + +DomMutations('Handles fields added after form load', async () => { + const { form, data } = createForm({ onSubmit: sinon.fake() }); + const outerFieldset = document.createElement('fieldset'); + outerFieldset.dataset.felteKeepOnRemove = 'false'; + const outerTextInput = createInputElement({ name: 'outerText' }); + const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); + outerSecondaryInput.dataset.felteKeepOnRemove = 'true'; + const innerFieldset = document.createElement('fieldset'); + const innerTextInput = createInputElement({ name: 'inner.innerText' }); + const innerSecondaryinput = createInputElement({ + name: 'inner.innerSecondary', + }); + innerSecondaryinput.dataset.felteKeepOnRemove = 'true'; + innerFieldset.append(innerTextInput, innerSecondaryinput); + outerFieldset.append(outerTextInput, outerSecondaryInput); + const formElement = screen.getByRole('form') as HTMLFormElement; + formElement.appendChild(outerFieldset); + form(formElement); + expect(get(data)).to.deep.equal({ + outerText: '', + outerSecondary: '', + }); + + formElement.appendChild(innerFieldset); + + await waitFor(() => { + expect(get(data)).to.deep.equal({ + outerText: '', + outerSecondary: '', + inner: { + innerText: '', + innerSecondary: '', + }, + }); + }); +}); + +DomMutations('Adds and removes event listeners', () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const { form } = createForm({ onSubmit: sinon.fake() }); + const addEventListener = sinon.fake(); + const removeEventListener = sinon.fake(); + formElement.addEventListener = addEventListener; + formElement.removeEventListener = removeEventListener; + sinon.assert.notCalled(addEventListener); + sinon.assert.notCalled(removeEventListener); + const { destroy } = form(formElement); + sinon.assert.callCount(addEventListener, 5); + sinon.assert.notCalled(removeEventListener); + destroy(); + sinon.assert.callCount(removeEventListener, 5); +}); + +DomMutations.run(); diff --git a/packages/felte/tests/dom-interactions.test.ts b/packages/felte/tests/dom-interactions.test.ts deleted file mode 100644 index 6a5b2cec..00000000 --- a/packages/felte/tests/dom-interactions.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen, waitFor } from '@testing-library/dom'; -import { createForm } from '../src'; -import { - cleanupDOM, - createInputElement, - createDOM, - createMultipleInputElements, -} from './common'; -import { get } from 'svelte/store'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn })); - -describe('Form action DOM mutations', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('Adds novalidate to form when using a validate function', () => { - const { form } = createForm({ - validate: jest.fn(), - onSubmit: jest.fn(), - }); - const formElement = screen.getByRole('form') as HTMLFormElement; - expect(formElement).not.toHaveAttribute('novalidate'); - - form(formElement); - - expect(formElement).toHaveAttribute('novalidate'); - }); - - test('Adds data-felte-fieldset to children of fieldset', () => { - const { form } = createForm({ - onSubmit: jest.fn(), - }); - const formElement = screen.getByRole('form') as HTMLFormElement; - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'user'; - const inputElement = document.createElement('input'); - inputElement.name = 'email'; - fieldsetElement.appendChild(inputElement); - formElement.appendChild(fieldsetElement); - form(formElement); - expect(inputElement).toHaveAttribute('data-felte-fieldset'); - }); - - test('Fieldsets can be nested', () => { - const { form } = createForm({ onSubmit: jest.fn() }); - const userFieldset = document.createElement('fieldset'); - userFieldset.name = 'user'; - const profileFieldset = document.createElement('fieldset'); - profileFieldset.name = 'profile'; - const emailInput = createInputElement({ type: 'email', name: 'email' }); - const passwordInput = createInputElement({ - type: 'password', - name: 'password', - }); - const nameInput = createInputElement({ name: 'name' }); - const bioInput = createInputElement({ name: 'bio' }); - profileFieldset.append(nameInput, bioInput); - userFieldset.append(emailInput, passwordInput, profileFieldset); - const formElement = screen.getByRole('form') as HTMLFormElement; - formElement.appendChild(userFieldset); - form(formElement); - [emailInput, passwordInput, profileFieldset].forEach((el) => { - expect(el).toHaveAttribute('data-felte-fieldset', 'user'); - }); - [nameInput, bioInput].forEach((el) => { - expect(el).toHaveAttribute('data-felte-fieldset', 'user.profile'); - }); - }); - - test('Propagates felte-unset-on-remove attribute respecting specificity', () => { - const { form } = createForm({ onSubmit: jest.fn() }); - const outerFieldset = document.createElement('fieldset'); - outerFieldset.dataset.felteUnsetOnRemove = 'true'; - const outerTextInput = createInputElement({ name: 'outerText' }); - const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); - outerSecondaryInput.dataset.felteUnsetOnRemove = 'false'; - const innerFieldset = document.createElement('fieldset'); - const innerTextInput = createInputElement({ name: 'innerText' }); - const innerSecondaryinput = createInputElement({ name: 'innerSecondary' }); - innerSecondaryinput.dataset.felteUnsetOnRemove = 'false'; - innerFieldset.append(innerTextInput, innerSecondaryinput); - outerFieldset.append(outerTextInput, outerSecondaryInput, innerFieldset); - const formElement = screen.getByRole('form') as HTMLFormElement; - formElement.appendChild(outerFieldset); - form(formElement); - [outerFieldset, outerTextInput, innerFieldset, innerTextInput].forEach( - (el) => { - expect(el).toHaveAttribute('data-felte-unset-on-remove', 'true'); - } - ); - [outerSecondaryInput, innerSecondaryinput].forEach((el) => { - expect(el).toHaveAttribute('data-felte-unset-on-remove', 'false'); - }); - }); - - test('Unsets fields tagged with felte-unset-on-remove', async () => { - const { form, data } = createForm({ onSubmit: jest.fn() }); - const outerFieldset = document.createElement('fieldset'); - outerFieldset.dataset.felteUnsetOnRemove = 'true'; - const outerTextInput = createInputElement({ name: 'outerText' }); - const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); - outerSecondaryInput.dataset.felteUnsetOnRemove = 'false'; - const multipleOuterInputs = createMultipleInputElements({ - name: 'multiple', - }); - multipleOuterInputs[1].dataset.felteUnsetOnRemove = 'false'; - const innerFieldset = document.createElement('fieldset'); - innerFieldset.name = 'inner'; - const innerTextInput = createInputElement({ name: 'innerText' }); - const innerSecondaryinput = createInputElement({ name: 'innerSecondary' }); - innerSecondaryinput.dataset.felteUnsetOnRemove = 'false'; - innerFieldset.append(innerTextInput, innerSecondaryinput); - outerFieldset.append( - ...multipleOuterInputs, - outerTextInput, - outerSecondaryInput, - innerFieldset - ); - const formElement = screen.getByRole('form') as HTMLFormElement; - formElement.appendChild(outerFieldset); - form(formElement); - expect(get(data)).toEqual({ - outerText: '', - outerSecondary: '', - multiple: ['', '', ''], - inner: { - innerText: '', - innerSecondary: '', - }, - }); - formElement.removeChild(outerFieldset); - await waitFor(() => { - expect(get(data)).toEqual({ - outerSecondary: '', - multiple: [undefined, '', undefined], - inner: { - innerSecondary: '', - }, - }); - }); - }); - - test('Handles fields added after form load', async () => { - const { form, data } = createForm({ onSubmit: jest.fn() }); - const outerFieldset = document.createElement('fieldset'); - outerFieldset.dataset.felteUnsetOnRemove = 'true'; - const outerTextInput = createInputElement({ name: 'outerText' }); - const outerSecondaryInput = createInputElement({ name: 'outerSecondary' }); - outerSecondaryInput.dataset.felteUnsetOnRemove = 'false'; - const innerFieldset = document.createElement('fieldset'); - innerFieldset.name = 'inner'; - const innerTextInput = createInputElement({ name: 'innerText' }); - const innerSecondaryinput = createInputElement({ name: 'innerSecondary' }); - innerSecondaryinput.dataset.felteUnsetOnRemove = 'false'; - innerFieldset.append(innerTextInput, innerSecondaryinput); - outerFieldset.append(outerTextInput, outerSecondaryInput); - const formElement = screen.getByRole('form') as HTMLFormElement; - formElement.appendChild(outerFieldset); - form(formElement); - expect(get(data)).toEqual({ - outerText: '', - outerSecondary: '', - }); - - formElement.appendChild(innerFieldset); - - await waitFor(() => { - expect(get(data)).toEqual({ - outerText: '', - outerSecondary: '', - inner: { - innerText: '', - innerSecondary: '', - }, - }); - }); - }); - - test('Adds and removes event listeners', () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const { form } = createForm({ onSubmit: jest.fn() }); - const addEventListener = jest.fn(); - const removeEventListener = jest.fn(); - formElement.addEventListener = addEventListener; - formElement.removeEventListener = removeEventListener; - expect(addEventListener).not.toHaveBeenCalled(); - expect(removeEventListener).not.toHaveBeenCalled(); - const { destroy } = form(formElement); - expect(addEventListener).toHaveBeenCalledTimes(4); - expect(removeEventListener).not.toHaveBeenCalled(); - destroy(); - expect(removeEventListener).toHaveBeenCalledTimes(4); - }); - - test('Listens to programmatic changes of inputs', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const textInput = createInputElement({ type: 'text', name: 'text' }); - const checkboxInput = createInputElement({ - type: 'checkbox', - name: 'checkbox', - }); - const fileInput = createInputElement({ - type: 'file', - name: 'file', - }); - formElement.append(textInput, checkboxInput, fileInput); - const { form, data } = createForm({ onSubmit: jest.fn() }); - form(formElement); - - expect(get(data)).toEqual({ - text: '', - checkbox: false, - file: undefined, - }); - - textInput.value = 'test'; - checkboxInput.checked = true; - fileInput.files = null; - - await waitFor(() => { - expect(get(data)).toEqual({ - text: 'test', - checkbox: true, - file: undefined, - }); - }); - }); -}); diff --git a/packages/felte/tests/extenders.spec.ts b/packages/felte/tests/extenders.spec.ts new file mode 100644 index 00000000..67e162f5 --- /dev/null +++ b/packages/felte/tests/extenders.spec.ts @@ -0,0 +1,487 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { waitFor, screen } from '@testing-library/dom'; +import { get } from 'svelte/store'; +import { createInputElement, createDOM, cleanupDOM } from './common'; +import type { CurrentForm } from '@felte/core'; +import { createForm } from '../src'; + +const Extenders = suite('Extenders'); + +Extenders.before.each(createDOM); +Extenders.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +Extenders('calls extender', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const mockExtenderHandler = { + destroy: sinon.fake(), + }; + const mockExtender = sinon.fake.returns(mockExtenderHandler); + const { + form, + data: { set, ...data }, + errors, + touched, + } = createForm({ + onSubmit: sinon.fake(), + extend: mockExtender, + }); + + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + stage: 'SETUP', + }) + ); + + sinon.assert.calledOnce(mockExtender); + + form(formElement); + + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + stage: 'MOUNT', + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.calledTwice(mockExtender); + + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + + formElement.appendChild(inputElement); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + stage: 'UPDATE', + errors, + touched, + form: formElement, + controls: sinon.match([inputElement]), + }) + ); + + sinon.assert.calledThrice(mockExtender); + + sinon.assert.calledOnce(mockExtenderHandler.destroy); + }); + + formElement.removeChild(inputElement); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + stage: 'UPDATE', + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.callCount(mockExtender, 4); + + sinon.assert.calledTwice(mockExtenderHandler.destroy); + }); +}); + +Extenders('calls multiple extenders', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const mockExtenderHandler = { + destroy: sinon.fake(), + }; + const mockExtenderHandlerNoD = {}; + const mockExtender = sinon.fake.returns(mockExtenderHandler); + const mockExtenderNoD = sinon.fake.returns(mockExtenderHandlerNoD); + const { + form, + data: { set, ...data }, + errors, + touched, + } = createForm({ + onSubmit: sinon.fake(), + extend: [mockExtender, mockExtenderNoD], + }); + + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + }) + ); + + sinon.assert.callCount(mockExtender, 1); + + sinon.assert.calledWith( + mockExtenderNoD, + sinon.match({ + data: sinon.match(data), + errors, + touched, + }) + ); + + sinon.assert.callCount(mockExtenderNoD, 1); + + const { destroy } = form(formElement); + + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.callCount(mockExtender, 2); + + sinon.assert.calledWith( + mockExtenderNoD, + sinon.match({ + data: sinon.match(data), + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.callCount(mockExtenderNoD, 2); + + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + + formElement.appendChild(inputElement); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + form: formElement, + controls: sinon.match([inputElement]), + }) + ); + + sinon.assert.callCount(mockExtender, 3); + + sinon.assert.callCount(mockExtenderHandler.destroy, 1); + }); + + formElement.removeChild(inputElement); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtender, + sinon.match({ + data: sinon.match(data), + errors, + touched, + form: formElement, + controls: sinon.match([]), + }) + ); + + sinon.assert.callCount(mockExtender, 4); + + sinon.assert.callCount(mockExtenderHandler.destroy, 2); + }); + + destroy(); +}); + +Extenders('calls onSubmitError', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const mockExtenderHandler = { + onSubmitError: sinon.fake(), + }; + const mockExtender = sinon.fake(() => mockExtenderHandler); + const mockErrors = { account: { email: 'Not email' } }; + + const { form, data } = createForm({ + onSubmit: sinon.fake(() => { + throw mockErrors; + }), + onError: () => mockErrors, + extend: mockExtender, + }); + + form(formElement); + + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + mockExtenderHandler.onSubmitError, + sinon.match({ + data: sinon.match(get(data)), + errors: mockErrors, + }) + ); + }); +}); + +Extenders('calls onSubmitError on multiple extenders', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const mockExtenderHandler = { + onSubmitError: sinon.fake(), + }; + const mockExtender = sinon.fake(() => mockExtenderHandler); + const validate = sinon.stub(); + const mockErrors = { account: { email: 'Not email' } }; + const onSubmit = sinon.fake(() => { + throw mockErrors; + }); + + const { form, data } = createForm({ + onSubmit, + onError: () => mockErrors, + validate, + extend: [mockExtender, mockExtender], + }); + + form(formElement); + + formElement.submit(); + + await waitFor(() => { + sinon.assert.alwaysCalledWith( + mockExtenderHandler.onSubmitError, + sinon.match({ + data: get(data), + errors: mockErrors, + }) + ); + sinon.assert.callCount(mockExtenderHandler.onSubmitError, 2); + sinon.assert.callCount(onSubmit, 1); + }); + + validate.returns(mockErrors); + validate.resetHistory(); + + formElement.submit(); + + await waitFor(() => { + sinon.assert.alwaysCalledWith( + mockExtenderHandler.onSubmitError, + sinon.match({ + data: get(data), + errors: mockErrors, + }) + ); + sinon.assert.callCount(mockExtenderHandler.onSubmitError, 4); + sinon.assert.callCount(onSubmit, 1); + }); +}); + +Extenders( + 'adds validator when no validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + await validate(); + sinon.assert.callCount(validator, 1); + } +); + +Extenders( + 'adds validator when validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + validate: validator, + }); + await validate(); + sinon.assert.callCount(validator, 3); + } +); + +Extenders( + 'adds warn validator when no validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { level: 'warning' }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + await validate(); + sinon.assert.callCount(validator, 1); + } +); + +Extenders( + 'adds warn validator when validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { level: 'warning' }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + warn: validator, + }); + await validate(); + sinon.assert.callCount(validator, 3); + } +); + +// DEBOUNCED +Extenders( + 'adds debounced validator when no validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { debounced: true }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + await validate(); + sinon.assert.callCount(validator, 1); + } +); + +Extenders( + 'adds debounced validator when validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { debounced: true }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + validate: validator, + }); + await validate(); + sinon.assert.callCount(validator, 3); + } +); + +Extenders( + 'adds debounced warn validator when no validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { + level: 'warning', + debounced: true, + }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + await validate(); + sinon.assert.callCount(validator, 1); + } +); + +Extenders( + 'adds debounced warn validator when validators are present with addValidator', + async () => { + const validator = sinon.fake(); + function extender(currentForm: CurrentForm) { + currentForm.addValidator(validator, { + level: 'warning', + debounced: true, + }); + return {}; + } + const { validate } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + warn: validator, + }); + await validate(); + sinon.assert.callCount(validator, 3); + } +); + +Extenders( + 'adds transformer when no validators are present with addTransformer', + async () => { + const transformer = sinon.fake((v) => v); + function extender(currentForm: CurrentForm) { + currentForm.addTransformer(transformer); + return {}; + } + const { data } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + }); + data.set({}); + sinon.assert.callCount(transformer, 1); + } +); + +Extenders( + 'adds transformer when validators are present with addTransformer', + async () => { + const transformer = sinon.fake((v) => v); + function extender(currentForm: CurrentForm) { + currentForm.addTransformer(transformer); + return {}; + } + const { data } = createForm({ + onSubmit: sinon.fake(), + extend: extender, + transform: transformer, + }); + data.set({}); + sinon.assert.callCount(transformer, 2); + } +); + +Extenders.run(); diff --git a/packages/felte/tests/extenders.test.ts b/packages/felte/tests/extenders.test.ts deleted file mode 100644 index 87176c08..00000000 --- a/packages/felte/tests/extenders.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen, waitFor } from '@testing-library/dom'; -import { createForm } from '../src'; -import { cleanupDOM, createDOM, createInputElement } from './common'; -import { get } from 'svelte/store'; -import type { CurrentForm } from '@felte/core'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn })); - -describe('Extenders', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('calls extender', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const mockExtenderHandler = { - destroy: jest.fn(), - }; - const mockExtender = jest.fn(() => mockExtenderHandler); - const { - form, - data: { set, ...data }, - errors, - touched, - } = createForm({ - onSubmit: jest.fn(), - extend: mockExtender, - }); - - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(1); - - form(formElement); - - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(2); - - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - - formElement.appendChild(inputElement); - - await waitFor(() => { - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([inputElement]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(3); - - expect(mockExtenderHandler.destroy).toHaveBeenCalledTimes(1); - }); - - formElement.removeChild(inputElement); - - await waitFor(() => { - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(4); - - expect(mockExtenderHandler.destroy).toHaveBeenCalledTimes(2); - }); - }); - - test('calls multiple extenders', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const mockExtenderHandler = { - destroy: jest.fn(), - }; - const mockExtender = jest.fn(() => mockExtenderHandler); - const { - form, - data: { set, ...data }, - errors, - touched, - } = createForm({ - onSubmit: jest.fn(), - extend: [mockExtender, mockExtender], - }); - - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(2); - - form(formElement); - - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(4); - - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - - formElement.appendChild(inputElement); - - await waitFor(() => { - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([inputElement]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(6); - - expect(mockExtenderHandler.destroy).toHaveBeenCalledTimes(2); - }); - - formElement.removeChild(inputElement); - - await waitFor(() => { - expect(mockExtender).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: expect.objectContaining(data), - errors, - touched, - form: formElement, - controls: expect.arrayContaining([]), - }) - ); - - expect(mockExtender).toHaveBeenCalledTimes(8); - - expect(mockExtenderHandler.destroy).toHaveBeenCalledTimes(4); - }); - }); - - test('calls onSubmitError', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const mockExtenderHandler = { - onSubmitError: jest.fn(), - }; - const mockExtender = jest.fn(() => mockExtenderHandler); - const mockErrors = { account: { email: 'Not email' } }; - - const { form, data } = createForm({ - onSubmit: jest.fn(() => { - throw mockErrors; - }), - onError: () => mockErrors, - extend: mockExtender, - }); - - form(formElement); - - formElement.submit(); - - await waitFor(() => { - expect(mockExtenderHandler.onSubmitError).toHaveBeenCalledWith( - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - }); - }); - - test('calls onSubmitError on multiple extenders', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const mockExtenderHandler = { - onSubmitError: jest.fn(), - }; - const mockExtender = jest.fn(() => mockExtenderHandler); - const validate = jest.fn(); - const mockErrors = { account: { email: 'Not email' } }; - const onSubmit = jest.fn(() => { - throw mockErrors; - }); - - const { form, data } = createForm({ - onSubmit, - onError: () => mockErrors, - validate, - extend: [mockExtender, mockExtender], - }); - - form(formElement); - - formElement.submit(); - - await waitFor(() => { - expect(mockExtenderHandler.onSubmitError).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - expect(mockExtenderHandler.onSubmitError).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - expect(mockExtenderHandler.onSubmitError).toHaveBeenCalledTimes(2); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - - validate.mockImplementation(() => mockErrors); - - formElement.submit(); - - await waitFor(() => { - expect(mockExtenderHandler.onSubmitError).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - expect(mockExtenderHandler.onSubmitError).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - data: get(data), - errors: mockErrors, - }) - ); - expect(mockExtenderHandler.onSubmitError).toHaveBeenCalledTimes(4); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - }); - - test('adds validator when no validators are present with addValidator', async () => { - const validator = jest.fn(); - function extender(currentForm: CurrentForm) { - currentForm.addValidator(validator); - return {}; - } - const { validate } = createForm({ - onSubmit: jest.fn(), - extend: extender, - }); - await validate(); - expect(validator).toHaveBeenCalledTimes(1); - }); - - test('adds validator when validators are present with addValidator', async () => { - const validator = jest.fn(); - function extender(currentForm: CurrentForm) { - currentForm.addValidator(validator); - return {}; - } - const { validate } = createForm({ - onSubmit: jest.fn(), - extend: extender, - validate: validator, - }); - await validate(); - expect(validator).toHaveBeenCalledTimes(3); - }); -}); diff --git a/packages/felte/tests/helpers.spec.ts b/packages/felte/tests/helpers.spec.ts new file mode 100644 index 00000000..4d9afe11 --- /dev/null +++ b/packages/felte/tests/helpers.spec.ts @@ -0,0 +1,668 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { waitFor, screen } from '@testing-library/dom'; +import { writable, get } from 'svelte/store'; +import userEvent from '@testing-library/user-event'; +import { + createInputElement, + createMultipleInputElements, + createDOM, + cleanupDOM, +} from './common'; +import { createForm } from '../src'; + +const Helpers = suite('Helpers'); + +Helpers.before.each(createDOM); + +Helpers.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +Helpers('setFields should update and touch field', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const fieldsetElement = document.createElement('fieldset'); + const inputElement = createInputElement({ + name: 'account.email', + value: '', + type: 'text', + }); + fieldsetElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + type Data = { + account: { + email: string; + }; + }; + const { form, data, touched, setFields } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + setFields('account.email', 'jacek@soplica.com', true); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + }, + }); + + form(formElement); + + expect(get(data).account.email).to.equal(''); + expect(inputElement.value).to.equal(''); + + setFields('account.email', 'jacek@soplica.com', true); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(inputElement.value).to.equal('jacek@soplica.com'); +}); + +Helpers('setField should update without touching field', () => { + type Data = { + account: { + email: string; + }; + }; + const { data, touched, setFields } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + setFields('account.email', 'jacek@soplica.com', false); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(get(touched)).to.deep.equal({ + account: { + email: false, + }, + }); +}); + +Helpers('setFields should set all fields', () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const fieldsetElement = document.createElement('fieldset'); + const inputElement = createInputElement({ + name: 'account.email', + value: '', + type: 'text', + }); + fieldsetElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + type Data = { + account: { + email: string; + }; + }; + const { form, data, touched, setFields } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + setFields({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + + form(formElement); + + expect(get(data).account.email).to.equal(''); + expect(inputElement.value).to.equal(''); + + setFields({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(get(data)).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + }, + }); + expect(inputElement.value).to.equal('jacek@soplica.com'); +}); + +Helpers('setTouched should touch field', () => { + type Data = { + account: { + email: string; + }; + }; + const { touched, setTouched } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(touched).account.email).to.equal(false); + setTouched('account.email', true); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + }, + }); +}); + +Helpers('setError should set a field error when touched', () => { + type Data = { + account: { + email: string; + }; + }; + const { errors, touched, setErrors, setTouched } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(errors)?.account?.email).to.be.null; + setErrors('account.email', 'Not an email'); + expect(get(errors)).to.deep.equal({ + account: { + email: null, + }, + }); + setTouched('account.email', () => true); + expect(get(touched).account.email).to.equal(true); + expect(get(errors)).to.deep.equal({ + account: { + email: ['Not an email'], + }, + }); +}); + +Helpers('setWarning should set a field warning', () => { + type Data = { + account: { + email: string; + }; + }; + const { warnings, setWarnings } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(warnings)?.account?.email).to.be.null; + setWarnings('account.email', 'Not an email'); + expect(get(warnings)).to.deep.equal({ + account: { + email: ['Not an email'], + }, + }); +}); + +Helpers('validate should force validation', async () => { + type Data = { + account: { + email: string; + }; + }; + const mockErrors = { account: { email: 'Not email' } }; + const mockValidate = sinon.stub().returns(mockErrors); + const { errors, touched, validate } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + validate: mockValidate, + onSubmit: sinon.fake(), + }); + + sinon.assert.calledOnce(mockValidate); + validate(); + sinon.assert.calledTwice(mockValidate); + await waitFor(() => { + expect(get(errors)).to.deep.equal({ account: { email: ['Not email'] } }); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + }, + }); + }); + + mockValidate.returns({}); + validate(); + sinon.assert.calledThrice(mockValidate); + await waitFor(() => { + expect(get(errors)).to.deep.equal({ account: { email: null } }); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + }, + }); + }); +}); + +Helpers('reset should reset form to default values', () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const accountFieldset = document.createElement('fieldset'); + const emailInput = createInputElement({ + name: 'account.email', + type: 'text', + value: '', + }); + accountFieldset.appendChild(emailInput); + formElement.appendChild(accountFieldset); + type Data = { + account: { + email: string; + }; + }; + const { data, touched, reset, form, isDirty, setFields } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + + setFields('account.email', 'jacek@soplica.com', true); + + expect(get(data).account.email).to.equal('jacek@soplica.com'); + + expect(get(touched).account.email).to.equal(true); + + reset(); + + expect(get(data)).to.deep.equal({ + account: { + email: '', + }, + }); + + expect(get(touched)).to.deep.equal({ + account: { + email: false, + }, + }); + + expect(get(isDirty)).to.equal(false); + + form(formElement); + + expect(get(data)).to.deep.equal({ + account: { + email: '', + }, + }); + + expect(get(isDirty)).to.equal(false); + + userEvent.click(emailInput); + userEvent.click(formElement); + + expect(get(isDirty)).to.equal(false); + + userEvent.type(emailInput, 'jacek@soplica.com'); + expect(get(data).account.email).to.equal('jacek@soplica.com'); + + expect(get(isDirty)).to.equal(true); + + reset(); + + expect(get(data)).to.deep.equal({ + account: { + email: '', + }, + }); + + expect(get(touched)).to.deep.equal({ + account: { + email: false, + }, + }); + + expect(get(isDirty)).to.equal(false); + + userEvent.click(emailInput); + userEvent.click(formElement); + + expect(get(isDirty)).to.equal(false); + + userEvent.type(emailInput, 'jacek@soplica.com'); + expect(get(data).account.email).to.equal('jacek@soplica.com'); + + expect(get(isDirty)).to.equal(true); + + formElement.reset(); + + expect(get(data)).to.deep.equal({ + account: { + email: '', + }, + }); + + expect(get(touched)).to.deep.equal({ + account: { + email: false, + }, + }); + + expect(get(isDirty)).to.equal(false); +}); + +Helpers('setInitialValues sets new initial values', () => { + type Data = { + account: { + email: string; + }; + }; + const { + data, + setInitialValues, + touched, + setFields, + reset, + } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + + setInitialValues({ account: { email: 'zaphod@beeblebrox.com' } }); + + expect(get(data).account.email).to.equal(''); + expect(get(touched).account.email).to.equal(false); + + setFields('account.email', 'jacek@soplica.com', true); + + expect(get(data).account.email).to.equal('jacek@soplica.com'); + expect(get(touched).account.email).to.equal(true); + + reset(); + + expect(get(data).account.email).to.equal('zaphod@beeblebrox.com'); + expect(get(touched).account.email).to.equal(false); +}); + +Helpers('get gets current value of store', () => { + const store = writable(true); + + expect(get(store)).to.equal(true); + + const originalSubscribe = store.subscribe; + const rxStore = { + subscribe(subscriber: any) { + const unsubscribe = originalSubscribe(subscriber); + return { unsubscribe }; + }, + }; + expect(get(rxStore as any)).to.equal(true); +}); + +Helpers('unsetField removes a field from all stores', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const fieldsetElement = document.createElement('fieldset'); + const inputElement = createInputElement({ + name: 'account.email', + value: '', + type: 'text', + }); + fieldsetElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + type Data = { + account: { + email: string; + }; + }; + const { + form, + data, + touched, + errors, + warnings, + unsetField, + } = createForm({ + initialValues: { + account: { + email: '', + }, + }, + onSubmit: sinon.fake(), + }); + + form(formElement); + + userEvent.type(inputElement, 'zaphod@beeblebrox.com'); + + await waitFor(() => { + expect(get(data).account.email).to.equal('zaphod@beeblebrox.com'); + }); + + unsetField('account.email'); + + await waitFor(() => { + expect(get(data)).to.deep.equal({ account: {} }); + expect(get(touched)).to.deep.equal({ account: {} }); + expect(get(errors)).to.deep.equal({ account: {} }); + expect(get(warnings)).to.deep.equal({ account: {} }); + expect(inputElement).to.not.have.a.value; + }); +}); + +Helpers('resetField resets a field to its initial value', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const fieldsetElement = document.createElement('fieldset'); + const inputElement = createInputElement({ + name: 'account.email', + value: '', + type: 'text', + }); + fieldsetElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + type Data = { + account: { + email: string; + }; + }; + const { form, data, touched, errors, resetField } = createForm({ + initialValues: { + account: { + email: 'zaphod@beeblebrox.com', + }, + }, + onSubmit: sinon.fake(), + }); + + form(formElement); + + userEvent.clear(inputElement); + userEvent.type(inputElement, 'jacek@soplica.com'); + userEvent.click(formElement); + + errors.set({ account: { email: 'Error' } }); + + await waitFor(() => { + expect(get(data).account.email).to.equal('jacek@soplica.com'); + expect(get(touched).account.email).to.equal(true); + expect(get(errors).account?.email).to.deep.equal(['Error']); + }); + + resetField('account.email'); + + await waitFor(() => { + expect(get(data).account.email).to.equal('zaphod@beeblebrox.com'); + expect(get(touched).account.email).to.equal(false); + expect(get(errors).account?.email).to.equal(null); + expect(inputElement).to.have.value.that.equals('zaphod@beeblebrox.com'); + }); +}); + +Helpers( + 'addField and unsetField add and remove fields accordingly', + async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const multipleInputs = createMultipleInputElements( + { + name: 'todos', + }, + 3 + ); + formElement.append(...multipleInputs); + type Data = { + todos: { + value: string; + }[]; + }; + const { + form, + data, + touched, + errors, + addField, + unsetField, + swapFields, + moveField, + } = createForm({ + initialValues: { + todos: new Array(3).fill({ value: '' }), + }, + onSubmit: sinon.fake(), + }); + + form(formElement); + + userEvent.type(multipleInputs[0], 'First todo'); + userEvent.type(multipleInputs[1], 'Third todo'); + userEvent.type(multipleInputs[2], 'Fourth todo'); + + errors.set({ + todos: [ + { + value: '', + }, + { + value: 'Invalid', + }, + { + value: '', + }, + ], + }); + + await waitFor(() => { + expect(get(data).todos[1].value).to.equal('Third todo'); + expect(get(touched).todos[1].value).to.equal(true); + expect(get(errors).todos?.[1].value).to.deep.equal(['Invalid']); + }); + + addField('todos', { value: 'Second todo' }, 1); + addField('todos.1', 'ignored'); + + await waitFor(() => { + expect(get(data).todos[1].value).to.equal('Second todo'); + expect(get(touched).todos[1].value).to.equal(false); + expect(get(errors).todos?.[1].value).to.equal(null); + expect(multipleInputs[1]).to.have.value.that.equals('Second todo'); + expect(get(data).todos[2].value).to.equal('Third todo'); + expect(get(touched).todos[2].value).to.equal(true); + expect(get(errors).todos?.[2].value).to.deep.equal(['Invalid']); + expect(multipleInputs[2]).to.have.value.that.equals('Third todo'); + }); + + unsetField('todos.2.'); + + await waitFor(() => { + expect(get(data).todos[1].value).to.equal('Second todo'); + expect(get(touched).todos[1].value).to.equal(false); + expect(get(errors).todos?.[1].value).to.equal(null); + expect(multipleInputs[1]).to.have.value.that.equals('Second todo'); + expect(get(data).todos[2].value).to.equal('Fourth todo'); + expect(get(touched).todos[2].value).to.equal(false); + expect(get(errors).todos?.[2].value).to.equal(null); + expect(multipleInputs[2]).to.have.value.that.equals('Fourth todo'); + }); + + addField('todos', { value: 'Fifth todo' }); + + await waitFor(() => { + expect(get(data).todos[2].value).to.equal('Fourth todo'); + expect(get(touched).todos[2].value).to.equal(false); + expect(get(errors).todos[2].value).to.equal(null); + expect(multipleInputs[2]).to.have.value.that.equals('Fourth todo'); + expect(get(data).todos[3].value).to.equal('Fifth todo'); + expect(get(touched).todos[3].value).to.equal(false); + expect(get(errors).todos[3].value).to.equal(null); + }); + + swapFields('todos', 1, 3); + + await waitFor(() => { + expect(get(data).todos[3].value).to.equal('Second todo'); + expect(get(touched).todos[3].value).to.equal(false); + expect(get(errors).todos?.[3].value).to.equal(null); + expect(get(data).todos[1].value).to.equal('Fifth todo'); + expect(get(touched).todos[1].value).to.equal(false); + expect(get(errors).todos[1].value).to.equal(null); + }); + + moveField('todos', 3, 0); + + await waitFor(() => { + expect(get(data).todos[0].value).to.equal('Second todo'); + expect(get(touched).todos[0].value).to.equal(false); + expect(get(errors).todos?.[0].value).to.equal(null); + expect(get(data).todos[1].value).to.equal('First todo'); + expect(get(touched).todos[1].value).to.equal(true); + expect(get(errors).todos?.[1].value).to.equal(null); + }); + } +); + +Helpers.run(); diff --git a/packages/felte/tests/helpers.test.ts b/packages/felte/tests/helpers.test.ts deleted file mode 100644 index 85b5b885..00000000 --- a/packages/felte/tests/helpers.test.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { waitFor, screen } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import { createForm } from '../src'; -import { createInputElement, createDOM, cleanupDOM } from './common'; -import { get } from 'svelte/store'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn })); - -describe('Helpers', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('setField should update and touch field', () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'account'; - const inputElement = createInputElement({ - name: 'email', - value: '', - type: 'text', - }); - fieldsetElement.appendChild(inputElement); - formElement.appendChild(fieldsetElement); - type Data = { - account: { - email: string; - }; - }; - const { form, data, touched, setField } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - setField('account.email', 'jacek@soplica.com'); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(get(touched)).toEqual({ - account: { - email: true, - }, - }); - - form(formElement); - - expect(get(data).account.email).toBe(''); - expect(inputElement.value).toBe(''); - - setField('account.email', 'jacek@soplica.com'); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(inputElement.value).toBe('jacek@soplica.com'); - }); - - test('setField should update without touching field', () => { - type Data = { - account: { - email: string; - }; - }; - const { data, touched, setField } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - setField('account.email', 'jacek@soplica.com', false); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(get(touched)).toEqual({ - account: { - email: false, - }, - }); - }); - - test('setFields should set all fields', () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'account'; - const inputElement = createInputElement({ - name: 'email', - value: '', - type: 'text', - }); - fieldsetElement.appendChild(inputElement); - formElement.appendChild(fieldsetElement); - type Data = { - account: { - email: string; - }; - }; - const { form, data, touched, setFields } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - setFields({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - - form(formElement); - - expect(get(data).account.email).toBe(''); - expect(inputElement.value).toBe(''); - - setFields({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(get(data)).toEqual({ - account: { - email: 'jacek@soplica.com', - }, - }); - expect(inputElement.value).toBe('jacek@soplica.com'); - }); - - test('setTouched should touch field', () => { - type Data = { - account: { - email: string; - }; - }; - const { touched, setTouched } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(touched).account.email).toBe(false); - setTouched('account.email'); - expect(get(touched)).toEqual({ - account: { - email: true, - }, - }); - }); - - test('setError should set a field error when touched', () => { - type Data = { - account: { - email: string; - }; - }; - const { errors, touched, setError, setTouched } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(errors)?.account?.email).toBeFalsy(); - setError('account.email', 'Not an email'); - expect(get(errors)).toEqual({ - account: { - email: null, - }, - }); - setTouched('account.email'); - expect(get(touched).account.email).toBe(true); - expect(get(errors)).toEqual({ - account: { - email: 'Not an email', - }, - }); - }); - - test('validate should force validation', async () => { - type Data = { - account: { - email: string; - }; - }; - const mockErrors = { account: { email: 'Not email' } }; - const mockValidate = jest.fn(() => mockErrors); - const { errors, touched, validate } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - validate: mockValidate, - onSubmit: jest.fn(), - }); - - expect(mockValidate).toHaveBeenCalledTimes(1); - validate(); - expect(mockValidate).toHaveBeenCalledTimes(2); - await waitFor(() => { - expect(get(errors)).toEqual(mockErrors); - expect(get(touched)).toEqual({ - account: { - email: true, - }, - }); - }); - - mockValidate.mockImplementation(() => ({} as any)); - validate(); - expect(mockValidate).toHaveBeenCalledTimes(3); - await waitFor(() => { - expect(get(errors)).toEqual({ account: { email: null } }); - expect(get(touched)).toEqual({ - account: { - email: true, - }, - }); - }); - }); - - test('setting directly to data should touch value', () => { - type Data = { - account: { - email: string; - password: string; - }; - }; - const { data, touched } = createForm({ - initialValues: { - account: { - email: '', - password: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(data).account.password).toBe(''); - - data.set({ - account: { email: 'jacek@soplica.com', password: '' }, - }); - - expect(get(data).account.email).toBe('jacek@soplica.com'); - expect(get(data).account.password).toBe(''); - - expect(get(touched)).toEqual({ - account: { - email: true, - password: false, - }, - }); - }); - - test('reset should reset form to default values', () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const accountFieldset = document.createElement('fieldset'); - accountFieldset.name = 'account'; - const emailInput = createInputElement({ - name: 'email', - type: 'text', - value: '', - }); - accountFieldset.appendChild(emailInput); - formElement.appendChild(accountFieldset); - type Data = { - account: { - email: string; - }; - }; - const { data, touched, reset, form, isDirty } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - - expect(get(isDirty)).toBe(false); - - data.set({ - account: { email: 'jacek@soplica.com' }, - }); - - expect(get(data).account.email).toBe('jacek@soplica.com'); - - expect(get(isDirty)).toBe(true); - - reset(); - - expect(get(data)).toEqual({ - account: { - email: '', - }, - }); - - expect(get(touched)).toEqual({ - account: { - email: false, - }, - }); - - expect(get(isDirty)).toBe(false); - - form(formElement); - - expect(get(data)).toEqual({ - account: { - email: '', - }, - }); - - expect(get(isDirty)).toBe(false); - - userEvent.click(emailInput); - userEvent.click(formElement); - - expect(get(isDirty)).toBe(false); - - userEvent.type(emailInput, 'jacek@soplica.com'); - expect(get(data).account.email).toBe('jacek@soplica.com'); - - expect(get(isDirty)).toBe(true); - - reset(); - - expect(get(data)).toEqual({ - account: { - email: '', - }, - }); - - expect(get(touched)).toEqual({ - account: { - email: false, - }, - }); - - expect(get(isDirty)).toBe(false); - }); - - test('getField should get the value of a field', () => { - type Data = { - account: { - email: string; - }; - }; - const { data, getField } = createForm({ - initialValues: { - account: { - email: 'jacek@soplica.com', - }, - }, - onSubmit: jest.fn(), - }); - expect(get(data).account.email).toBe(getField('account.email')); - }); - - test('setInitialValues sets new initial values', () => { - type Data = { - account: { - email: string; - }; - }; - const { - data, - setInitialValues, - touched, - isDirty, - reset, - } = createForm({ - initialValues: { - account: { - email: '', - }, - }, - onSubmit: jest.fn(), - }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - expect(get(isDirty)).toBe(false); - - setInitialValues({ account: { email: 'zaphod@beeblebrox.com' } }); - - expect(get(data).account.email).toBe(''); - expect(get(touched).account.email).toBe(false); - expect(get(isDirty)).toBe(false); - - data.set({ account: { email: 'jacek@soplica.com' } }); - - expect(get(data).account.email).toBe('jacek@soplica.com'); - expect(get(touched).account.email).toBe(true); - expect(get(isDirty)).toBe(true); - - reset(); - - expect(get(data).account.email).toBe('zaphod@beeblebrox.com'); - expect(get(touched).account.email).toBe(false); - expect(get(isDirty)).toBe(false); - }); -}); diff --git a/packages/felte/tests/mocks/svelte.js b/packages/felte/tests/mocks/svelte.js new file mode 100644 index 00000000..069b2a6f --- /dev/null +++ b/packages/felte/tests/mocks/svelte.js @@ -0,0 +1,3 @@ +import * as sinon from 'sinon'; + +export const onDestroy = sinon.fake(); diff --git a/packages/felte/tests/user-interactions.spec.ts b/packages/felte/tests/user-interactions.spec.ts new file mode 100644 index 00000000..c93dfe37 --- /dev/null +++ b/packages/felte/tests/user-interactions.spec.ts @@ -0,0 +1,1144 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { waitFor, screen } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { + createInputElement, + createDOM, + cleanupDOM, + createMultipleInputElements, +} from './common'; +import { get } from 'svelte/store'; +import { isFormControl } from '@felte/core'; +import { FelteSubmitError, createForm } from '../src'; + +function createSelectElement({ + name, + options, +}: { + name: string; + options: string[]; +}) { + const selectElement = document.createElement('select'); + selectElement.name = name; + const optionElements = options.map((option) => { + const element = document.createElement('option'); + element.value = option; + return element; + }); + selectElement.append(...optionElements); + return selectElement; +} + +function createLoginForm() { + const formElement = screen.getByRole('form') as HTMLFormElement; + const emailInput = createInputElement({ + name: 'account.email', + type: 'email', + }); + const passwordInput = createInputElement({ + name: 'account.password', + type: 'password', + }); + const submitInput = createInputElement({ type: 'submit' }); + const accountFieldset = document.createElement('fieldset'); + accountFieldset.append(emailInput, passwordInput); + formElement.append(accountFieldset, submitInput); + return { formElement, emailInput, passwordInput, submitInput }; +} + +function createSignupForm() { + const formElement = screen.getByRole('form') as HTMLFormElement; + const emailInput = createInputElement({ + name: 'account.email', + type: 'email', + }); + const passwordInput = createInputElement({ + name: 'account.password', + type: 'password', + }); + const showPasswordInput = createInputElement({ + name: 'account.showPassword', + type: 'checkbox', + }); + const confirmPasswordInput = createInputElement({ + name: 'account.confirmPassword', + type: 'password', + }); + const publicEmailYesRadio = createInputElement({ + name: 'account.publicEmail', + value: 'yes', + type: 'radio', + }); + const publicEmailNoRadio = createInputElement({ + name: 'account.publicEmail', + value: 'no', + type: 'radio', + }); + const accountTypeElement = createSelectElement({ + name: 'account.accountType', + options: ['user', 'admin'], + }); + const accountFieldset = document.createElement('fieldset'); + accountFieldset.append( + emailInput, + passwordInput, + showPasswordInput, + publicEmailYesRadio, + publicEmailNoRadio, + confirmPasswordInput, + accountTypeElement + ); + formElement.appendChild(accountFieldset); + const profileFieldset = document.createElement('fieldset'); + const firstNameInput = createInputElement({ name: 'profile.firstName' }); + const lastNameInput = createInputElement({ name: 'profile.lastName' }); + const bioInput = createInputElement({ name: 'profile.bio' }); + profileFieldset.append(firstNameInput, lastNameInput, bioInput); + formElement.appendChild(profileFieldset); + const pictureInput = createInputElement({ + name: 'profile.picture', + type: 'file', + }); + formElement.appendChild(pictureInput); + const extraPicsInput = createInputElement({ + name: 'extra.pictures', + type: 'file', + }); + extraPicsInput.multiple = true; + formElement.appendChild(extraPicsInput); + const submitInput = createInputElement({ type: 'submit' }); + const techCheckbox = createInputElement({ + type: 'checkbox', + name: 'preferences', + value: 'technology', + }); + const filmsCheckbox = createInputElement({ + type: 'checkbox', + name: 'preferences', + value: 'films', + }); + formElement.append(techCheckbox, filmsCheckbox, submitInput); + const multipleFieldsetElement = document.createElement('fieldset'); + const extraTextInputs = createMultipleInputElements({ + type: 'text', + name: 'multiple.extraText', + }); + const extraNumberInputs = createMultipleInputElements({ + type: 'number', + name: 'multiple.extraNumber', + }); + const extraFileInputs = createMultipleInputElements({ + type: 'file', + name: 'multiple.extraFiles', + }); + const extraCheckboxes = createMultipleInputElements({ + type: 'checkbox', + name: 'multiple.extraCheckbox', + }); + const extraPreferences1 = createMultipleInputElements({ + type: 'checkbox', + name: 'multiple.extraPreference', + value: 'preference1', + }); + const extraPreferences2 = createMultipleInputElements({ + type: 'checkbox', + name: 'multiple.extraPreference', + value: 'preference2', + }); + multipleFieldsetElement.append( + ...extraTextInputs, + ...extraNumberInputs, + ...extraFileInputs, + ...extraCheckboxes, + ...extraPreferences1, + ...extraPreferences2 + ); + formElement.appendChild(multipleFieldsetElement); + + return { + formElement, + emailInput, + passwordInput, + confirmPasswordInput, + showPasswordInput, + publicEmailYesRadio, + publicEmailNoRadio, + firstNameInput, + lastNameInput, + bioInput, + pictureInput, + extraPicsInput, + techCheckbox, + filmsCheckbox, + submitInput, + extraTextInputs, + extraNumberInputs, + extraFileInputs, + extraCheckboxes, + extraPreferences1, + extraPreferences2, + accountFieldset, + accountTypeElement, + }; +} + +const UserInteractions = suite('User interactions with form'); + +UserInteractions.before.each(() => { + createDOM(); +}); +UserInteractions.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +UserInteractions('Sets default data correctly', () => { + const { form, data } = createForm({ + onSubmit: sinon.fake(), + }); + const { formElement } = createSignupForm(); + form(formElement); + const $data = get(data); + expect($data).to.deep.include({ + account: { + email: '', + password: '', + confirmPassword: '', + showPassword: false, + publicEmail: undefined, + accountType: 'user', + }, + profile: { + firstName: '', + lastName: '', + bio: '', + picture: undefined, + }, + extra: { + pictures: [], + }, + preferences: [], + }); +}); + +UserInteractions('Validates default data correctly', async () => { + type Data = { + account: { + email: string; + password: string; + }; + multiple: Record[]>; + }; + const { form, data, errors, warnings, setTouched } = createForm({ + onSubmit: sinon.fake(), + validate: (values) => { + const errors: { + account: { password?: string; email?: string }; + } = { account: {} }; + if (!values.account?.email) errors.account.email = 'Must not be empty'; + if (!values.account?.password) + errors.account.password = 'Must not be empty'; + return errors; + }, + warn: (values: any) => { + const warnings: { + account: { password?: string; email?: string }; + } = { account: {} }; + if (!values.account?.password) + warnings.account.password = 'Should be safer'; + return warnings; + }, + }); + const { formElement } = createSignupForm(); + form(formElement); + const $data = get(data); + expect($data).to.have.nested.property('multiple.extraText'); + Object.keys($data.multiple).forEach((key) => { + expect($data.multiple[key]).to.be.an('array'); + $data.multiple[key].forEach((obj) => { + expect(obj).to.have.a.property('key').that.is.a('string'); + }); + }); + expect($data).to.deep.include({ + account: { + email: '', + password: '', + confirmPassword: '', + showPassword: false, + publicEmail: undefined, + accountType: 'user', + }, + profile: { + firstName: '', + lastName: '', + bio: '', + picture: undefined, + }, + extra: { + pictures: [], + }, + preferences: [], + }); + expect(get(errors)).to.have.property('account').that.includes({ + email: null, + password: null, + }); + setTouched('account.email', true); + await waitFor(() => { + expect(get(warnings)) + .to.have.property('account') + .that.deep.includes({ + password: ['Should be safer'], + }); + expect(get(errors)) + .to.have.property('account') + .that.deep.include({ + email: ['Must not be empty'], + password: null, + }); + }); + setTouched('account.password', true); + await waitFor(() => { + expect(get(errors)) + .to.have.property('account') + .that.deep.include({ + email: ['Must not be empty'], + password: ['Must not be empty'], + }); + }); +}); + +UserInteractions('Sets custom default data correctly', () => { + const { form, data, isValid } = createForm({ + onSubmit: sinon.fake(), + }); + const { + formElement, + emailInput, + bioInput, + publicEmailYesRadio, + showPasswordInput, + techCheckbox, + extraTextInputs, + extraNumberInputs, + extraCheckboxes, + extraPreferences1, + accountTypeElement, + } = createSignupForm(); + emailInput.value = 'jacek@soplica.com'; + const bioTest = 'Litwo! Ojczyzno moja! ty jesteś jak zdrowie'; + bioInput.value = bioTest; + publicEmailYesRadio.checked = true; + showPasswordInput.checked = true; + techCheckbox.checked = true; + extraTextInputs[1].value = 'demo text'; + extraNumberInputs[1].value = '1'; + extraCheckboxes[1].checked = true; + extraPreferences1[1].checked = true; + accountTypeElement.value = 'admin'; + form(formElement); + const $data = get(data); + expect($data) + .to.have.a.nested.property('multiple.extraText.1.value') + .that.equals('demo text'); + expect($data) + .to.have.a.nested.property('multiple.extraNumber.1.value') + .that.equals(1); + expect($data).to.have.a.nested.property('multiple.extraCheckbox.1.value').that + .true; + expect($data) + .to.have.a.nested.property('multiple.extraPreference.1.value') + .that.is.an('array') + .that.deep.equals(['preference1']); + expect($data).to.deep.include({ + account: { + email: 'jacek@soplica.com', + password: '', + confirmPassword: '', + showPassword: true, + publicEmail: 'yes', + accountType: 'admin', + }, + profile: { + firstName: '', + lastName: '', + bio: bioTest, + picture: undefined, + }, + extra: { + pictures: [], + }, + preferences: ['technology'], + }); + expect(get(isValid)).to.be.ok; +}); + +UserInteractions('Input and data object get same value', () => { + const { form, data } = createForm({ + onSubmit: sinon.fake(), + }); + const { formElement, emailInput, passwordInput } = createLoginForm(); + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + userEvent.type(passwordInput, 'password'); + const $data = get(data); + expect($data).to.deep.equal({ + account: { + email: 'jacek@soplica.com', + password: 'password', + }, + }); +}); + +UserInteractions('Calls validation function on submit', async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isSubmitting } = createForm({ + onSubmit, + validate, + warn, + }); + const { formElement } = createLoginForm(); + form(formElement); + formElement.submit(); + sinon.assert.called(validate); + sinon.assert.called(warn); + await waitFor(() => { + sinon.assert.calledWith( + onSubmit, + sinon.match({ + account: { + email: '', + password: '', + }, + }), + sinon.match({ + form: formElement, + controls: sinon.match( + Array.from(formElement.elements).filter(isFormControl) + ), + }) + ); + expect(get(isSubmitting)).not.to.be.ok; + }); +}); + +UserInteractions( + 'Calls validation function on submit without calling onSubmit', + async () => { + type Data = { + account: { + email: string; + password: string; + }; + }; + const validate = sinon.fake(() => ({ account: { email: 'Not email' } })); + const warn = sinon.fake(() => ({ account: { email: 'Not email' } })); + const onSubmit = sinon.fake(); + const { form, isValid, isSubmitting } = createForm({ + onSubmit, + validate, + warn, + }); + const { formElement } = createLoginForm(); + form(formElement); + formElement.submit(); + sinon.assert.called(validate); + sinon.assert.called(warn); + await waitFor(() => { + sinon.assert.notCalled(onSubmit); + }); + expect(get(isValid)).not.to.be.ok; + await waitFor(() => { + expect(get(isSubmitting)).not.to.be.ok; + }); + } +); + +UserInteractions('Calls validate on input', async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isValid } = createForm({ + onSubmit, + validate, + warn, + }); + const { formElement, emailInput } = createLoginForm(); + expect(get(isValid)).to.be.false; + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + await waitFor(() => { + sinon.assert.called(validate); + sinon.assert.called(warn); + expect(get(isValid)).to.be.ok; + }); +}); + +UserInteractions('Calls debounced validate on input', async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isValid } = createForm({ + onSubmit, + debounced: { + timeout: 0, + validate, + warn, + }, + }); + const { formElement, emailInput } = createLoginForm(); + expect(get(isValid)).to.be.false; + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.'); + await waitFor(() => { + sinon.assert.calledOnce(validate); + sinon.assert.calledOnce(warn); + expect(get(isValid)).to.be.ok; + }); + userEvent.type(emailInput, 'c'); + userEvent.type(emailInput, 'o'); + userEvent.type(emailInput, 'm'); + await waitFor(() => { + sinon.assert.calledTwice(validate); + sinon.assert.calledTwice(warn); + expect(get(isValid)).to.be.ok; + }); +}); + +UserInteractions( + 'Calls debounced validate on input with custom timeout', + async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isValid } = createForm({ + onSubmit, + debounced: { + timeout: 100, + validate, + warn, + }, + }); + const { formElement, emailInput } = createLoginForm(); + expect(get(isValid)).to.be.false; + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + await waitFor(() => { + sinon.assert.called(validate); + sinon.assert.called(warn); + expect(get(isValid)).to.be.ok; + }); + } +); + +UserInteractions( + 'Calls debounced validate on input with validate and warn timeout', + async () => { + const validate = sinon.fake(() => ({})); + const warn = sinon.fake(() => ({})); + const onSubmit = sinon.fake(); + const { form, isValid } = createForm({ + onSubmit, + debounced: { + validateTimeout: 100, + validate, + warn, + warnTimeout: 100, + }, + }); + const { formElement, emailInput } = createLoginForm(); + expect(get(isValid)).to.be.false; + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + await waitFor(() => { + sinon.assert.called(validate); + sinon.assert.called(warn); + expect(get(isValid)).to.be.ok; + }); + } +); + +UserInteractions('Handles user events', () => { + type Data = { + account: { + email: string; + password: string; + confirmPassword: string; + showPassword: boolean; + publicEmail?: 'yes' | 'no'; + accountType: 'user' | 'admin'; + }; + profile: { + firstName: string; + lastName: string; + bio: string; + picture: any; + }; + extra: { + pictures: any[]; + }; + preferences: any[]; + }; + const { form, touched, data, interacted, isDirty } = createForm({ + onSubmit: sinon.fake(), + }); + const { + formElement, + emailInput, + passwordInput, + confirmPasswordInput, + showPasswordInput, + publicEmailYesRadio, + firstNameInput, + lastNameInput, + bioInput, + techCheckbox, + pictureInput, + extraPicsInput, + extraTextInputs, + extraNumberInputs, + extraCheckboxes, + extraPreferences1, + extraFileInputs, + accountTypeElement, + } = createSignupForm(); + + form(formElement); + + expect(get(data)).to.deep.include({ + account: { + email: '', + password: '', + confirmPassword: '', + showPassword: false, + publicEmail: undefined, + accountType: 'user', + }, + profile: { + firstName: '', + lastName: '', + bio: '', + picture: undefined, + }, + extra: { + pictures: [], + }, + preferences: [], + }); + + const mockFile = new File(['test file'], 'test.png', { type: 'image/png' }); + expect(get(isDirty)).to.be.false; + expect(get(interacted)).to.be.null; + userEvent.type(emailInput, 'jacek@soplica.com'); + expect(get(touched).account.email).to.be.false; + expect(get(isDirty)).to.be.true; + expect(get(interacted)).to.equal(emailInput.name); + userEvent.type(passwordInput, 'password'); + expect(get(touched).account.email).to.be.true; + userEvent.type(confirmPasswordInput, 'password'); + userEvent.click(showPasswordInput); + userEvent.click(publicEmailYesRadio); + userEvent.type(firstNameInput, 'Jacek'); + userEvent.type(lastNameInput, 'Soplica'); + const bioTest = 'Litwo! Ojczyzno moja! ty jesteś jak zdrowie'; + userEvent.type(bioInput, bioTest); + userEvent.click(techCheckbox); + userEvent.upload(pictureInput, mockFile); + userEvent.upload(extraPicsInput, [mockFile, mockFile]); + userEvent.type(extraTextInputs[1], 'demo text'); + userEvent.type(extraNumberInputs[1], '1'); + userEvent.click(extraCheckboxes[1]); + userEvent.click(extraPreferences1[1]); + userEvent.upload(extraFileInputs[1], mockFile); + userEvent.selectOptions(accountTypeElement, ['admin']); + + expect(get(data)).to.deep.include({ + account: { + email: 'jacek@soplica.com', + password: 'password', + confirmPassword: 'password', + showPassword: true, + publicEmail: 'yes', + accountType: 'admin', + }, + profile: { + firstName: 'Jacek', + lastName: 'Soplica', + bio: bioTest, + picture: mockFile, + }, + extra: { + pictures: [mockFile, mockFile], + }, + preferences: ['technology'], + }); +}); + +UserInteractions('Sets default data with initialValues', () => { + const { emailInput, passwordInput, formElement } = createLoginForm(); + const { data, form } = createForm({ + onSubmit: sinon.fake(), + initialValues: { + account: { + email: 'jacek@soplica.com', + password: 'password', + }, + }, + }); + expect(get(data)).to.deep.include({ + account: { + email: 'jacek@soplica.com', + password: 'password', + }, + }); + + form(formElement); + + expect(emailInput.value).to.equal('jacek@soplica.com'); + expect(passwordInput.value).to.equal('password'); +}); + +UserInteractions('Validates initial values correctly', async () => { + type Data = { + account: { + email: string; + password: string; + }; + }; + const { data, errors, setTouched, touched } = createForm({ + onSubmit: sinon.fake(), + validate: (values: any) => { + const errors: any = { account: {} }; + if (!values.account.email) errors.account.email = 'Must not be empty'; + if (!values.account.password) + errors.account.password = 'Must not be empty'; + return errors; + }, + initialValues: { + account: { + email: 'jacek@soplica.com', + password: '', + }, + }, + }); + expect(get(errors)).to.deep.equal({ + account: { + email: null, + password: null, + }, + }); + setTouched('account.email', true); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + password: false, + }, + }); + expect(get(errors)).to.deep.equal({ + account: { + email: null, + password: null, + }, + }); + setTouched('account.password', true); + expect(get(touched)).to.deep.equal({ + account: { + email: true, + password: true, + }, + }); + await waitFor(() => { + expect(get(errors)).to.deep.equal({ + account: { + email: null, + password: ['Must not be empty'], + }, + }); + }); + expect(get(data)).to.deep.include({ + account: { + email: 'jacek@soplica.com', + password: '', + }, + }); +}); + +UserInteractions('calls onError', async () => { + const formElement = screen.getByRole('form') as HTMLFormElement; + const onError = sinon.fake(); + const mockErrors = { account: { email: 'Not email' } }; + const onSubmit = sinon.fake(() => { + throw mockErrors; + }); + + const { form, isSubmitting } = createForm({ + onSubmit, + onError, + }); + + form(formElement); + + sinon.assert.notCalled(onError); + + formElement.submit(); + + await waitFor(() => { + sinon.assert.called(onSubmit); + sinon.assert.calledWith(onError, mockErrors); + expect(get(isSubmitting)).not.to.be.ok; + }); +}); + +UserInteractions('use createSubmitHandler to override submit', async () => { + const mockOnSubmit = sinon.stub(); + const mockValidate = sinon.fake(); + const mockOnError = sinon.fake(); + const formElement = screen.getByRole('form') as HTMLFormElement; + const defaultConfig = { + onSubmit: sinon.fake(), + validate: sinon.fake(), + onError: sinon.fake(), + }; + const { form, createSubmitHandler, isSubmitting } = createForm(defaultConfig); + const altOnSubmit = createSubmitHandler({ + onSubmit: mockOnSubmit, + onError: mockOnError, + validate: mockValidate, + }); + + form(formElement); + + const submitInput = createInputElement({ + type: 'submit', + value: 'Alt Submit', + }); + + submitInput.addEventListener('click', altOnSubmit); + + formElement.appendChild(submitInput); + + userEvent.click(submitInput); + + await waitFor(() => { + sinon.assert.calledOnce(mockValidate); + sinon.assert.notCalled(defaultConfig.onSubmit); + sinon.assert.calledOnce(mockOnSubmit); + sinon.assert.notCalled(defaultConfig.onError); + sinon.assert.notCalled(mockOnError); + expect(get(isSubmitting)).not.to.be.ok; + }); + + const mockErrors = { account: { email: 'Not email' } }; + mockOnSubmit.resetHistory(); + mockOnSubmit.onFirstCall().throws(mockErrors); + + userEvent.click(submitInput); + + await waitFor(() => { + sinon.assert.called(mockOnError); + sinon.assert.calledTwice(mockValidate); + sinon.assert.calledOnce(mockOnSubmit); + expect(get(isSubmitting)).not.to.be.ok; + }); +}); + +UserInteractions('calls submit handler without event', async () => { + const { createSubmitHandler, isSubmitting } = createForm({ + onSubmit: sinon.fake(), + }); + const mockOnSubmit = sinon.fake(); + const altOnSubmit = createSubmitHandler({ onSubmit: mockOnSubmit }); + altOnSubmit(); + await waitFor(() => { + sinon.assert.called(mockOnSubmit); + expect(get(isSubmitting)).not.to.be.ok; + }); +}); + +UserInteractions('ignores inputs with data-felte-ignore', async () => { + type Data = { + account: { + email: string; + password: string; + confirmPassword: string; + showPassword: boolean; + publicEmail?: 'yes' | 'no'; + }; + profile: { + firstName: string; + lastName: string; + bio: string; + picture: any; + }; + extra: { + pictures: any[]; + }; + preferences: any[]; + }; + const { + formElement, + accountFieldset, + emailInput, + passwordInput, + firstNameInput, + lastNameInput, + publicEmailYesRadio, + } = createSignupForm(); + accountFieldset.setAttribute('data-felte-ignore', ''); + firstNameInput.setAttribute('data-felte-ignore', ''); + const { data, form } = createForm({ + onSubmit: sinon.fake(), + }); + form(formElement); + userEvent.type(emailInput, 'jacek@soplica.com'); + userEvent.type(passwordInput, 'password'); + userEvent.type(firstNameInput, 'Jacek'); + userEvent.type(lastNameInput, 'Soplica'); + userEvent.click(publicEmailYesRadio); + await waitFor(() => { + expect(get(data).profile.lastName).to.equal('Soplica'); + expect(get(data).profile.firstName).to.equal(''); + expect(get(data).account.email).to.equal(''); + expect(get(data).account.password).to.equal(''); + expect(get(data).account.publicEmail).to.equal(undefined); + }); +}); + +UserInteractions('transforms data', async () => { + type Data = { + account: { + email: string; + password: string; + confirmPassword: string; + showPassword: boolean; + publicEmail?: boolean; + }; + profile: { + firstName: string; + lastName: string; + bio: string; + picture: any; + }; + extra: { + pictures: any[]; + }; + preferences: any[]; + }; + const { + formElement, + publicEmailYesRadio, + publicEmailNoRadio, + } = createSignupForm(); + const { data, form } = createForm({ + onSubmit: sinon.fake(), + transform: (values: any) => { + if (values.account.publicEmail === 'yes') { + values.account.publicEmail = true; + } else { + values.account.publicEmail = false; + } + return values; + }, + }); + + form(formElement); + + userEvent.click(publicEmailYesRadio); + await waitFor(() => { + expect(get(data).account.publicEmail).to.be.true; + }); + userEvent.click(publicEmailNoRadio); + await waitFor(() => { + expect(get(data).account.publicEmail).to.be.false; + }); +}); + +UserInteractions('submits without requestSubmit', async () => { + const onSubmit = sinon.fake(); + const { form } = createForm({ onSubmit }); + const { formElement } = createLoginForm(); + formElement.requestSubmit = undefined as any; + form(formElement); + formElement.submit(); + + await waitFor(() => { + sinon.assert.called(onSubmit); + }); +}); + +UserInteractions('submits post request with default action', async () => { + window.fetch = sinon.stub().resolves({ ok: true }); + const onSuccess = sinon.fake(); + const eventOnSuccess = sinon.fake(); + const { form } = createForm({ onSuccess }); + const { formElement } = createLoginForm(); + formElement.action = '/example'; + formElement.method = 'post'; + formElement.addEventListener('feltesuccess', eventOnSuccess); + form(formElement); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match('/example'), + sinon.match({ + body: sinon.match.instanceOf(URLSearchParams), + method: 'post', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + ); + sinon.assert.calledWith( + onSuccess, + sinon.match({ + ok: true, + }), + sinon.match.any + ); + }); +}); + +UserInteractions('submits get request with default action', async () => { + window.fetch = sinon.stub().resolves({ ok: true }); + const onSuccess = sinon.fake(); + const eventOnSuccess = sinon.fake(); + const { form } = createForm({ onSuccess }); + const { formElement, emailInput } = createLoginForm(); + formElement.action = '/example'; + formElement.method = 'get'; + formElement.addEventListener('feltesuccess', eventOnSuccess); + form(formElement); + + userEvent.type(emailInput, 'zaphod@beeblebrox.com'); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match( + '/example?account.email=zaphod%40beeblebrox.com&account.password=' + ), + sinon.match({ + method: 'get', + }) + ); + sinon.assert.calledWith( + onSuccess, + sinon.match({ + ok: true, + }), + sinon.match.any + ); + sinon.assert.calledWith( + eventOnSuccess, + sinon.match({ + detail: sinon.match({ + response: sinon.match({ + ok: true, + }), + }), + }) + ); + }); +}); + +UserInteractions( + 'submits with default action and overriden method', + async () => { + window.fetch = sinon.stub().resolves({ ok: true }); + const { form } = createForm(); + const { formElement } = createLoginForm(); + formElement.action = '/example?_method=put'; + formElement.method = 'post'; + form(formElement); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match('/example'), + sinon.match({ + body: sinon.match.instanceOf(URLSearchParams), + method: 'put', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + ); + }); + } +); + +UserInteractions('submits with default action and file input', async () => { + window.fetch = sinon.stub().resolves({ ok: true }); + const { form } = createForm(); + const { formElement } = createLoginForm(); + formElement.action = '/example'; + formElement.method = 'post'; + const fileInput = createInputElement({ name: 'profilePic', type: 'file' }); + formElement.appendChild(fileInput); + form(formElement); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match('/example'), + sinon.match({ + body: sinon.match.instanceOf(FormData), + method: 'post', + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + ); + }); +}); + +UserInteractions('submits with default action and throws', async () => { + window.fetch = sinon.stub().resolves({ ok: false }); + const onError = sinon.fake(); + const eventOnError = sinon.fake(); + const { form } = createForm({ onError }); + const { formElement } = createLoginForm(); + formElement.action = '/example'; + formElement.method = 'post'; + formElement.addEventListener('felteerror', eventOnError); + form(formElement); + formElement.submit(); + + await waitFor(() => { + sinon.assert.calledWith( + window.fetch as any, + sinon.match('/example'), + sinon.match({ + body: sinon.match.instanceOf(URLSearchParams), + method: 'post', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + ); + sinon.assert.calledWith( + onError, + sinon.match.instanceOf(FelteSubmitError), + sinon.match.any + ); + sinon.assert.calledWith( + eventOnError, + sinon.match({ + detail: sinon.match({ + error: sinon.match.instanceOf(FelteSubmitError), + }), + }) + ); + }); +}); + +UserInteractions.run(); diff --git a/packages/felte/tests/user-interactions.test.ts b/packages/felte/tests/user-interactions.test.ts deleted file mode 100644 index f54f5388..00000000 --- a/packages/felte/tests/user-interactions.test.ts +++ /dev/null @@ -1,831 +0,0 @@ -import { screen, waitFor } from '@testing-library/dom'; -import { - cleanupDOM, - createInputElement, - createDOM, - createMultipleInputElements, -} from './common'; -import { createForm } from '../src'; -import userEvent from '@testing-library/user-event'; -import { get } from 'svelte/store'; -import { isFormControl } from '@felte/core'; -import { onDestroy } from 'svelte'; - -jest.mock('svelte'); - -function createLoginForm() { - const formElement = screen.getByRole('form') as HTMLFormElement; - const emailInput = createInputElement({ name: 'email', type: 'email' }); - const passwordInput = createInputElement({ - name: 'password', - type: 'password', - }); - const submitInput = createInputElement({ type: 'submit' }); - const accountFieldset = document.createElement('fieldset'); - accountFieldset.name = 'account'; - accountFieldset.append(emailInput, passwordInput); - formElement.append(accountFieldset, submitInput); - return { formElement, emailInput, passwordInput, submitInput }; -} - -function createSignupForm() { - const formElement = screen.getByRole('form') as HTMLFormElement; - const emailInput = createInputElement({ name: 'email', type: 'email' }); - const passwordInput = createInputElement({ - name: 'password', - type: 'password', - }); - const showPasswordInput = createInputElement({ - name: 'showPassword', - type: 'checkbox', - }); - const confirmPasswordInput = createInputElement({ - name: 'confirmPassword', - type: 'password', - }); - const publicEmailYesRadio = createInputElement({ - name: 'publicEmail', - value: 'yes', - type: 'radio', - }); - const publicEmailNoRadio = createInputElement({ - name: 'publicEmail', - value: 'no', - type: 'radio', - }); - const accountFieldset = document.createElement('fieldset'); - accountFieldset.name = 'account'; - accountFieldset.append( - emailInput, - passwordInput, - showPasswordInput, - publicEmailYesRadio, - publicEmailNoRadio, - confirmPasswordInput - ); - formElement.appendChild(accountFieldset); - const profileFieldset = document.createElement('fieldset'); - profileFieldset.name = 'profile'; - const firstNameInput = createInputElement({ name: 'firstName' }); - const lastNameInput = createInputElement({ name: 'lastName' }); - const bioInput = createInputElement({ name: 'bio' }); - profileFieldset.append(firstNameInput, lastNameInput, bioInput); - formElement.appendChild(profileFieldset); - const pictureInput = createInputElement({ - name: 'profile.picture', - type: 'file', - }); - formElement.appendChild(pictureInput); - const extraPicsInput = createInputElement({ - name: 'extra.pictures', - type: 'file', - }); - extraPicsInput.multiple = true; - formElement.appendChild(extraPicsInput); - const submitInput = createInputElement({ type: 'submit' }); - const techCheckbox = createInputElement({ - type: 'checkbox', - name: 'preferences', - value: 'technology', - }); - const filmsCheckbox = createInputElement({ - type: 'checkbox', - name: 'preferences', - value: 'films', - }); - formElement.append(techCheckbox, filmsCheckbox, submitInput); - const multipleFieldsetElement = document.createElement('fieldset'); - multipleFieldsetElement.name = 'multiple'; - const extraTextInputs = createMultipleInputElements({ - type: 'text', - name: 'extraText', - }); - const extraNumberInputs = createMultipleInputElements({ - type: 'number', - name: 'extraNumber', - }); - const extraFileInputs = createMultipleInputElements({ - type: 'file', - name: 'extraFiles', - }); - const extraCheckboxes = createMultipleInputElements({ - type: 'checkbox', - name: 'extraCheckbox', - }); - const extraPreferences1 = createMultipleInputElements({ - type: 'checkbox', - name: 'extraPreference', - value: 'preference1', - }); - const extraPreferences2 = createMultipleInputElements({ - type: 'checkbox', - name: 'extraPreference', - value: 'preference2', - }); - multipleFieldsetElement.append( - ...extraTextInputs, - ...extraNumberInputs, - ...extraFileInputs, - ...extraCheckboxes, - ...extraPreferences1, - ...extraPreferences2 - ); - formElement.appendChild(multipleFieldsetElement); - - return { - formElement, - emailInput, - passwordInput, - confirmPasswordInput, - showPasswordInput, - publicEmailYesRadio, - publicEmailNoRadio, - firstNameInput, - lastNameInput, - bioInput, - pictureInput, - extraPicsInput, - techCheckbox, - filmsCheckbox, - submitInput, - extraTextInputs, - extraNumberInputs, - extraFileInputs, - extraCheckboxes, - extraPreferences1, - extraPreferences2, - accountFieldset, - }; -} - -describe('User interactions with form', () => { - beforeAll(() => { - (onDestroy as jest.Mock).mockImplementation(jest.fn()); - }); - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('Sets default data correctly', () => { - const { form, data } = createForm({ - onSubmit: jest.fn(), - }); - const { formElement } = createSignupForm(); - form(formElement); - const $data = get(data); - expect($data).toEqual( - expect.objectContaining({ - account: { - email: '', - password: '', - confirmPassword: '', - showPassword: false, - publicEmail: undefined, - }, - profile: { - firstName: '', - lastName: '', - bio: '', - picture: undefined, - }, - extra: { - pictures: expect.arrayContaining([]), - }, - preferences: expect.arrayContaining([]), - }) - ); - }); - - test('Validates default data correctly', async () => { - const { form, data, errors, setTouched } = createForm({ - onSubmit: jest.fn(), - validate: (values: any) => { - const errors: { - account: { password?: string; email?: string }; - } = { account: {} }; - if (!values.account?.email) errors.account.email = 'Must not be empty'; - if (!values.account?.password) - errors.account.password = 'Must not be empty'; - return errors; - }, - }); - const { formElement } = createSignupForm(); - form(formElement); - const $data = get(data); - expect($data).toEqual( - expect.objectContaining({ - account: { - email: '', - password: '', - confirmPassword: '', - showPassword: false, - publicEmail: undefined, - }, - profile: { - firstName: '', - lastName: '', - bio: '', - picture: undefined, - }, - extra: { - pictures: expect.arrayContaining([]), - }, - preferences: expect.arrayContaining([]), - multiple: { - extraText: expect.arrayContaining(['', '', '']), - extraNumber: expect.arrayContaining([ - undefined, - undefined, - undefined, - ]), - extraFiles: expect.arrayContaining([undefined, undefined, undefined]), - extraCheckbox: expect.arrayContaining([false, false, false]), - extraPreference: expect.arrayContaining([[], [], []]), - }, - }) - ); - expect(get(errors)).toMatchObject({ - account: { - email: null, - password: null, - }, - }); - setTouched('account.email'); - await waitFor(() => { - expect(get(errors)).toMatchObject({ - account: { - email: 'Must not be empty', - password: null, - }, - }); - }); - setTouched('account.password'); - await waitFor(() => { - expect(get(errors)).toMatchObject({ - account: { - email: 'Must not be empty', - password: 'Must not be empty', - }, - }); - }); - }); - - test('Sets custom default data correctly', () => { - const { form, data, isValid } = createForm({ - onSubmit: jest.fn(), - }); - const { - formElement, - emailInput, - bioInput, - publicEmailYesRadio, - showPasswordInput, - techCheckbox, - extraTextInputs, - extraNumberInputs, - extraCheckboxes, - extraPreferences1, - } = createSignupForm(); - emailInput.value = 'jacek@soplica.com'; - const bioTest = 'Litwo! Ojczyzno moja! ty jesteś jak zdrowie'; - bioInput.value = bioTest; - publicEmailYesRadio.checked = true; - showPasswordInput.checked = true; - techCheckbox.checked = true; - extraTextInputs[1].value = 'demo text'; - extraNumberInputs[1].value = '1'; - extraCheckboxes[1].checked = true; - extraPreferences1[1].checked = true; - form(formElement); - const $data = get(data); - expect($data).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: '', - confirmPassword: '', - showPassword: true, - publicEmail: 'yes', - }, - profile: { - firstName: '', - lastName: '', - bio: bioTest, - picture: undefined, - }, - extra: { - pictures: expect.arrayContaining([]), - }, - preferences: expect.arrayContaining(['technology']), - multiple: { - extraText: expect.arrayContaining(['', 'demo text', '']), - extraNumber: expect.arrayContaining([undefined, 1, undefined]), - extraFiles: expect.arrayContaining([undefined, undefined, undefined]), - extraCheckbox: expect.arrayContaining([false, true, false]), - extraPreference: expect.arrayContaining([[], ['preference1'], []]), - }, - }) - ); - expect(get(isValid)).toBeTruthy(); - }); - - test('Input and data object get same value', () => { - const { form, data } = createForm({ - onSubmit: jest.fn(), - }); - const { formElement, emailInput, passwordInput } = createLoginForm(); - form(formElement); - userEvent.type(emailInput, 'jacek@soplica.com'); - userEvent.type(passwordInput, 'password'); - const $data = get(data); - expect($data).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: 'password', - }, - }) - ); - }); - - test('Calls validation function on submit', async () => { - const validate = jest.fn(() => ({})); - const onSubmit = jest.fn(); - const { form, isSubmitting } = createForm({ - onSubmit, - validate, - }); - const { formElement } = createLoginForm(); - form(formElement); - formElement.submit(); - expect(validate).toHaveBeenCalled(); - await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - account: { - email: '', - password: '', - }, - }), - expect.objectContaining({ - form: formElement, - controls: Array.from(formElement.elements).filter(isFormControl), - }) - ); - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('Calls validation function on submit without calling onSubmit', async () => { - const validate = jest.fn(() => ({ account: { email: 'Not email' } })); - const onSubmit = jest.fn(); - const { form, isValid, isSubmitting } = createForm({ - onSubmit, - validate, - }); - const { formElement } = createLoginForm(); - form(formElement); - formElement.submit(); - expect(validate).toHaveBeenCalled(); - await waitFor(() => { - expect(onSubmit).not.toHaveBeenCalled(); - }); - expect(get(isValid)).toBeFalsy(); - await waitFor(() => { - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('Calls validate on input', async () => { - const validate = jest.fn(() => ({})); - const onSubmit = jest.fn(); - const { form, isValid } = createForm({ - onSubmit, - validate, - }); - const { formElement, emailInput } = createLoginForm(); - form(formElement); - userEvent.type(emailInput, 'jacek@soplica.com'); - await waitFor(() => { - expect(validate).toHaveBeenCalled(); - expect(get(isValid)).toBeTruthy(); - }); - }); - - test('Handles user events', () => { - type Data = { - account: { - email: string; - password: string; - confirmPassword: string; - showPassword: boolean; - publicEmail?: 'yes' | 'no'; - }; - profile: { - firstName: string; - lastName: string; - bio: string; - picture: any; - }; - extra: { - pictures: any[]; - }; - preferences: any[]; - }; - const { form, touched, data } = createForm({ - onSubmit: jest.fn(), - }); - const { - formElement, - emailInput, - passwordInput, - confirmPasswordInput, - showPasswordInput, - publicEmailYesRadio, - firstNameInput, - lastNameInput, - bioInput, - techCheckbox, - pictureInput, - extraPicsInput, - extraTextInputs, - extraNumberInputs, - extraCheckboxes, - extraPreferences1, - extraFileInputs, - } = createSignupForm(); - - form(formElement); - - expect(get(data)).toEqual( - expect.objectContaining({ - account: { - email: '', - password: '', - confirmPassword: '', - showPassword: false, - publicEmail: undefined, - }, - profile: { - firstName: '', - lastName: '', - bio: '', - picture: undefined, - }, - extra: { - pictures: expect.arrayContaining([]), - }, - preferences: expect.arrayContaining([]), - multiple: { - extraText: expect.arrayContaining(['', '', '']), - extraNumber: expect.arrayContaining([ - undefined, - undefined, - undefined, - ]), - extraFiles: expect.arrayContaining([undefined, undefined, undefined]), - extraCheckbox: expect.arrayContaining([false, false, false]), - extraPreference: expect.arrayContaining([[], [], []]), - }, - }) - ); - - const mockFile = new File(['test file'], 'test.png', { type: 'image/png' }); - userEvent.type(emailInput, 'jacek@soplica.com'); - expect(get(touched).account.email).toBe(false); - userEvent.type(passwordInput, 'password'); - expect(get(touched).account.email).toBe(true); - userEvent.type(confirmPasswordInput, 'password'); - userEvent.click(showPasswordInput); - userEvent.click(publicEmailYesRadio); - userEvent.type(firstNameInput, 'Jacek'); - userEvent.type(lastNameInput, 'Soplica'); - const bioTest = 'Litwo! Ojczyzno moja! ty jesteś jak zdrowie'; - userEvent.type(bioInput, bioTest); - userEvent.click(techCheckbox); - userEvent.upload(pictureInput, mockFile); - userEvent.upload(extraPicsInput, [mockFile, mockFile]); - userEvent.type(extraTextInputs[1], 'demo text'); - userEvent.type(extraNumberInputs[1], '1'); - userEvent.click(extraCheckboxes[1]); - userEvent.click(extraPreferences1[1]); - userEvent.upload(extraFileInputs[1], mockFile); - - expect(get(data)).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: 'password', - confirmPassword: 'password', - showPassword: true, - publicEmail: 'yes', - }, - profile: { - firstName: 'Jacek', - lastName: 'Soplica', - bio: bioTest, - picture: mockFile, - }, - extra: { - pictures: expect.arrayContaining([mockFile, mockFile]), - }, - preferences: expect.arrayContaining(['technology']), - multiple: { - extraText: expect.arrayContaining(['', 'demo text', '']), - extraNumber: expect.arrayContaining([undefined, 1, undefined]), - extraFiles: expect.arrayContaining([undefined, mockFile, undefined]), - extraCheckbox: expect.arrayContaining([false, true, false]), - extraPreference: expect.arrayContaining([[], ['preference1'], []]), - }, - }) - ); - }); - - test('Sets default data with initialValues', () => { - const { emailInput, passwordInput, formElement } = createLoginForm(); - const { data, form } = createForm({ - onSubmit: jest.fn(), - initialValues: { - account: { - email: 'jacek@soplica.com', - password: 'password', - }, - }, - }); - expect(get(data)).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: 'password', - }, - }) - ); - - form(formElement); - - expect(emailInput.value).toBe('jacek@soplica.com'); - expect(passwordInput.value).toBe('password'); - }); - - test('Validates initial values correctly', async () => { - const { data, errors, setTouched, touched } = createForm({ - onSubmit: jest.fn(), - validate: (values: any) => { - const errors: { - account: { password?: string; email?: string }; - } = { account: {} }; - if (!values.account.email) errors.account.email = 'Must not be empty'; - if (!values.account.password) - errors.account.password = 'Must not be empty'; - return errors; - }, - initialValues: { - account: { - email: 'jacek@soplica.com', - password: '', - }, - }, - }); - expect(get(errors)).toEqual({ - account: { - email: null, - password: null, - }, - }); - setTouched('account.email'); - expect(get(touched)).toEqual({ - account: { - email: true, - password: false, - }, - }); - expect(get(errors)).toEqual({ - account: { - email: null, - password: null, - }, - }); - setTouched('account.password'); - expect(get(touched)).toEqual({ - account: { - email: true, - password: true, - }, - }); - await waitFor(() => { - expect(get(errors)).toEqual({ - account: { - email: null, - password: 'Must not be empty', - }, - }); - }); - expect(get(data)).toEqual( - expect.objectContaining({ - account: { - email: 'jacek@soplica.com', - password: '', - }, - }) - ); - }); - - test('calls onError', async () => { - const formElement = screen.getByRole('form') as HTMLFormElement; - const onError = jest.fn(); - const mockErrors = { account: { email: 'Not email' } }; - const onSubmit = jest.fn(() => { - throw mockErrors; - }); - - const { form, isSubmitting } = createForm({ - onSubmit, - onError, - }); - - form(formElement); - - expect(onError).not.toHaveBeenCalled(); - - formElement.submit(); - - await waitFor(() => { - expect(onSubmit).toHaveBeenCalled(); - expect(onError).toHaveBeenCalledWith(mockErrors); - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('use createSubmitHandler to override submit', async () => { - const mockOnSubmit = jest.fn(); - const mockValidate = jest.fn(); - const mockOnError = jest.fn(); - const formElement = screen.getByRole('form') as HTMLFormElement; - const defaultConfig = { - onSubmit: jest.fn(), - validate: jest.fn(), - onError: jest.fn(), - }; - const { form, createSubmitHandler, isSubmitting } = createForm( - defaultConfig - ); - const altOnSubmit = createSubmitHandler({ - onSubmit: mockOnSubmit, - onError: mockOnError, - validate: mockValidate, - }); - - form(formElement); - - const submitInput = createInputElement({ - type: 'submit', - value: 'Alt Submit', - }); - - submitInput.addEventListener('click', altOnSubmit); - - formElement.appendChild(submitInput); - - userEvent.click(submitInput); - - await waitFor(() => { - expect(mockValidate).toHaveBeenCalledTimes(1); - expect(defaultConfig.onSubmit).not.toHaveBeenCalled(); - expect(mockOnSubmit).toHaveBeenCalledTimes(1); - expect(defaultConfig.onError).not.toHaveBeenCalled(); - expect(mockOnError).not.toHaveBeenCalled(); - expect(get(isSubmitting)).toBeFalsy(); - }); - - const mockErrors = { account: { email: 'Not email' } }; - mockOnSubmit.mockImplementationOnce(() => { - throw mockErrors; - }); - - userEvent.click(submitInput); - - await waitFor(() => { - expect(mockOnError).toHaveBeenCalled(); - expect(mockValidate).toHaveBeenCalledTimes(2); - expect(mockOnSubmit).toHaveBeenCalledTimes(2); - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('calls submit handler without event', async () => { - const { createSubmitHandler, isSubmitting } = createForm({ - onSubmit: jest.fn(), - }); - const mockOnSubmit = jest.fn(); - const altOnSubmit = createSubmitHandler({ onSubmit: mockOnSubmit }); - altOnSubmit(); - await waitFor(() => { - expect(mockOnSubmit).toHaveBeenCalled(); - expect(get(isSubmitting)).toBeFalsy(); - }); - }); - - test('ignores inputs with data-felte-ignore', async () => { - type Data = { - account: { - email: string; - password: string; - confirmPassword: string; - showPassword: boolean; - publicEmail?: 'yes' | 'no'; - }; - profile: { - firstName: string; - lastName: string; - bio: string; - picture: any; - }; - extra: { - pictures: any[]; - }; - preferences: any[]; - }; - const { - formElement, - accountFieldset, - emailInput, - passwordInput, - firstNameInput, - lastNameInput, - publicEmailYesRadio, - } = createSignupForm(); - accountFieldset.setAttribute('data-felte-ignore', ''); - firstNameInput.setAttribute('data-felte-ignore', ''); - const { data, form } = createForm({ - onSubmit: jest.fn(), - }); - form(formElement); - userEvent.type(emailInput, 'jacek@soplica.com'); - userEvent.type(passwordInput, 'password'); - userEvent.type(firstNameInput, 'Jacek'); - userEvent.type(lastNameInput, 'Soplica'); - userEvent.click(publicEmailYesRadio); - await waitFor(() => { - expect(get(data).profile.lastName).toBe('Soplica'); - expect(get(data).profile.firstName).toBe(''); - expect(get(data).account.email).toBe(''); - expect(get(data).account.password).toBe(''); - expect(get(data).account.publicEmail).toBe(undefined); - }); - }); - - test('transforms data', async () => { - type Data = { - account: { - email: string; - password: string; - confirmPassword: string; - showPassword: boolean; - publicEmail?: boolean; - }; - profile: { - firstName: string; - lastName: string; - bio: string; - picture: any; - }; - extra: { - pictures: any[]; - }; - preferences: any[]; - }; - const { - formElement, - publicEmailYesRadio, - publicEmailNoRadio, - } = createSignupForm(); - const { data, form } = createForm({ - onSubmit: jest.fn(), - transform: (values: any) => { - if (values.account.publicEmail === 'yes') { - values.account.publicEmail = true; - } else { - values.account.publicEmail = false; - } - return values; - }, - }); - - form(formElement); - - userEvent.click(publicEmailYesRadio); - await waitFor(() => { - expect(get(data).account.publicEmail).toBe(true); - }); - userEvent.click(publicEmailNoRadio); - await waitFor(() => { - expect(get(data).account.publicEmail).toBe(false); - }); - }); - - test('calls cleanup on destroy', () => { - (onDestroy as jest.Mock).mockImplementation((cb) => cb()); - createForm({ onSubmit: jest.fn() }); - expect(onDestroy).toHaveBeenCalled(); - }); -}); diff --git a/packages/felte/tsconfig.json b/packages/felte/tsconfig.json index 37029954..ef3e034f 100644 --- a/packages/felte/tsconfig.json +++ b/packages/felte/tsconfig.json @@ -10,7 +10,7 @@ "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, - "declarationDir": "./dist" + "declarationDir": "./dist/types" }, "typedocOptions": { "entryPoints": ["src/index.ts"], diff --git a/packages/multi-step/CHANGELOG.md b/packages/multi-step/CHANGELOG.md deleted file mode 100644 index 7c40c7fc..00000000 --- a/packages/multi-step/CHANGELOG.md +++ /dev/null @@ -1,33 +0,0 @@ -# @felte/multi-step - -## 2.0.0 - -### Patch Changes - -- 6fe19bf: Change build output from umd to cjs, since Felte is not planned to be used as a global import, a umd build is not necessary. -- Updated dependencies [6fe19bf] -- Updated dependencies [6fe19bf] -- Updated dependencies [6fe19bf] -- Updated dependencies [6fe19bf] - - felte@0.9.0 - -## 1.0.0 - -### Patch Changes - -- Updated dependencies [2d3b213] - - felte@0.8.0 - -## 0.1.2 - -### Patch Changes - -- 16ff018: Export ES module as default -- Updated dependencies [16ff018] - - felte@0.7.11 - -## 0.1.1 - -### Patch Changes - -- 22e1b79: Add felte as external on rollup to reduce bundle size diff --git a/packages/multi-step/README.md b/packages/multi-step/README.md deleted file mode 100644 index 34a56f0c..00000000 --- a/packages/multi-step/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# **NOT RECOMMENDED** - -Due to the complex nature of multi page forms, and the changes that have been occuring within the core of `Felte` (e.g. supporting `SolidJS`), many issues have arised with this package so its usage is not recommended. A custom solution for your own use-case will most likely be simpler than the API of this package. For now, [refer to the updated documentation](https://felte.dev/docs/svelte/multi-page-forms) for an example on how to handle multi page forms without using a package. - -# @felte/multi-step - -[![Bundle size](https://img.shields.io/bundlephobia/min/@felte/multi-step)](https://bundlephobia.com/result?p=@felte/multi-step) -[![NPM Version](https://img.shields.io/npm/v/@felte/multi-step)](https://www.npmjs.com/package/@felte/multi-step) - -A package to help you handle multi step forms. This is a simple helper whose API might change a lot and might end up being part of the core package eventually. I would not consider it as a _best practice_ yet. - -## Installation - -```sh -npm install --save @felte/multi-step - -# Or, if you use yarn - -yarn add @felte/multi-step -``` - -## Usage - -Instead of importing `createForm` from Felte, import `createForms` from `@felte/multi-step` (you still need Felte as part of your dependencies). This function accepts an object with two properties, an `initialStep` value if you wish to initialize the form in a different step than 0 (the default), and a `pages` property which is an array of objects. Each of them being an individual form's configuration. - -```javascript -import { createForms } from '@felte/multi-step'; - -const { step, pages, increaseStep, decreaseStep, totalSteps } = createForms({ - initialStep: 1, - pages: [ - { - onSubmit: ({ increaseStep }) => increaseStep(), - }, - { - onSubmit: ({ allValues }) => console.log(allValues), - }, - ], -}); -``` - -As you may have noticed, the signature for the `onSubmit` function differs to the one used in Felte. It passes an object to you with the following properties: - -- `values`: The values submitted on the current step. -- `allValues`: The current value of all the forms on submission. -- `increaseStep`: A helper function to go to the next step. -- `decreaseStep`: A helper function to go to the previous step. -- `step`: A writable store that contains the current step. -- `currentStep`: The current step the form is in. - -The object returned by `createForms` contains the following properties: - -- `totalSteps`: A number that represents the total steps of the form. -- `increaseStep`: A helper function to go to the next step. -- `decreaseStep`: A helper function to go to the previous step. -- `step`: A writable store that contains the current step. -- `pages`: An array of objects, the same object returned by Felte's `createForm` for each form. - -## Typescript - -`createForms` accepts a generic argument, it should be a tuple that contains the `Data` shape for each form. Typescript support is still spotty, though. And there's no way to add an "extended" signature as you can do with Felte's `createForm`. - - -```typescript -import { createForms } from '@felte/multi-step'; - -type Page1 = { - name: string; -} - -type Page2 = { - address: string; -} - -const { pages } = createForms<[Page1, Page2]>({ - initialStep: 1, - pages: [ - { - onSubmit: ({ increaseStep }) => increaseStep(), - }, - { - onSubmit: ({ allValues }) => console.log(allValues), - }, - ], -}); -``` diff --git a/packages/multi-step/jest.config.js b/packages/multi-step/jest.config.js deleted file mode 100644 index 553799a5..00000000 --- a/packages/multi-step/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], -}; diff --git a/packages/multi-step/package.json b/packages/multi-step/package.json deleted file mode 100644 index 11fa26b5..00000000 --- a/packages/multi-step/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "@felte/multi-step", - "version": "2.0.0", - "description": "A helper package to handle multistep forms", - "main": "dist/index.js", - "browser": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "sideEffects": false, - "author": "Pablo Berganza ", - "repository": "github:pablo-abc/felte", - "homepage": "https://github.com/pablo-abc/felte/tree/main/packages/multi-step", - "keywords": [ - "svelte", - "forms", - "felte" - ], - "scripts": { - "prebuild": "rimraf ./dist", - "build": "cross-env NODE_ENV=production rollup -c", - "dev": "rollup -cw", - "prepublishOnly": "pnpm build && pnpm test", - "test": "jest", - "test:ci": "jest --ci --coverage" - }, - "license": "MIT", - "devDependencies": { - "@felte/common": "^0.6.0", - "felte": "^0.9.0" - }, - "peerDependencies": { - "felte": "^0.9.0", - "svelte": "^3.31.0" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.js", - "default": "./dist/index.mjs" - }, - "./package.json": "./package.json" - } -} diff --git a/packages/multi-step/src/index.ts b/packages/multi-step/src/index.ts deleted file mode 100644 index 2f8d6a91..00000000 --- a/packages/multi-step/src/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { Writable } from 'svelte/store'; -import type { Obj, FormConfig, Form } from '@felte/common'; -import { createForm } from 'felte'; -import { writable, get } from 'svelte/store'; - -type FormSubmitValues = { - values: Data; - allValues: Datas; - currentStep: number; - increaseStep: () => void; - decreaseStep: () => void; - step: Writable; -}; - -type MultiFormConfig = { - onSubmit: (forms: FormSubmitValues) => Promise | void; - [key: string]: unknown; -} & Pick, 'initialValues' | 'extend' | 'validate' | 'onError'>; - -type FormConfigs = { - [key in keyof Datas]: Datas[key] extends Obj - ? MultiFormConfig - : never; -}; - -type Forms = { - [key in keyof Datas]: Datas[key] extends Obj ? Form : never; -}; - -type MultiStepForm = { - step: Writable; - totalSteps: number; - pages: Forms; - increaseStep: () => void; - decreaseStep: () => void; -}; - -type Config = { - initialStep?: number; - pages: FormConfigs; -}; - -export function createForms({ - pages: configs, - initialStep = 0, -}: Config): MultiStepForm { - const step = writable(initialStep); - const increaseStep = () => step.update(($step) => $step + 1); - const decreaseStep = () => step.update(($step) => $step - 1); - let pageDataStores: Writable[] = configs.map((config) => { - return writable((config?.initialValues as Obj) ?? {}); - }); - const formConfigs = configs.map((config) => ({ - ...config, - onSubmit: (values: Obj) => { - return config.onSubmit({ - step, - values, - increaseStep, - decreaseStep, - currentStep: get(step), - allValues: pageDataStores.map(get), - }); - }, - })); - const pages = formConfigs.map((config) => createForm(config)) as Forms; - pageDataStores = pages.map((page) => page.data); - return { - step, - increaseStep, - decreaseStep, - totalSteps: configs.length, - pages, - }; -} diff --git a/packages/multi-step/tests/common.ts b/packages/multi-step/tests/common.ts deleted file mode 100644 index fb9df293..00000000 --- a/packages/multi-step/tests/common.ts +++ /dev/null @@ -1,40 +0,0 @@ -export function cleanupDOM(): void { - removeAllChildren(document.body); -} - -export type InputAttributes = { - type?: string; - required?: boolean; - name?: string; - value?: string; - checked?: boolean; -}; - -export function createInputElement(attrs: InputAttributes): HTMLInputElement { - const inputElement = document.createElement('input'); - if (attrs.name) inputElement.name = attrs.name; - if (attrs.type) inputElement.type = attrs.type; - if (attrs.value) inputElement.value = attrs.value; - if (attrs.checked) inputElement.checked = attrs.checked; - inputElement.required = !!attrs.required; - return inputElement; -} - -export function removeAllChildren(parent: Node): void { - while (parent.firstChild) { - parent.removeChild(parent.firstChild); - } -} - -export function createMultipleInputElements( - attr: InputAttributes, - amount = 3 -): HTMLInputElement[] { - const inputs = []; - for (let i = 0; i < amount; i++) { - const input = createInputElement(attr); - input.dataset.felteIndex = String(i); - inputs.push(input); - } - return inputs; -} diff --git a/packages/multi-step/tests/index.test.ts b/packages/multi-step/tests/index.test.ts deleted file mode 100644 index daabc9c6..00000000 --- a/packages/multi-step/tests/index.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { waitFor } from '@testing-library/dom'; -import { cleanupDOM, createInputElement } from './common'; -import { createForms } from '../src'; -import { get } from 'svelte/store'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn() })); - -function createLoginForm() { - const formElement = document.createElement('form'); - const emailInput = createInputElement({ name: 'email', type: 'email' }); - const passwordInput = createInputElement({ - name: 'password', - type: 'password', - }); - const submitInput = createInputElement({ type: 'submit' }); - const accountFieldset = document.createElement('fieldset'); - accountFieldset.name = 'account'; - accountFieldset.append(emailInput, passwordInput); - formElement.append(accountFieldset, submitInput); - document.body.appendChild(formElement); - return { formElement, emailInput, passwordInput, submitInput }; -} - -type Data = { - account: { - email: string; - password: string; - }; -}; - -describe('Multi Step', () => { - afterEach(cleanupDOM); - - test('handles step changes on submit', async () => { - const { - step, - pages: [page1, page2], - } = createForms<[Data, Data]>({ - pages: [ - { onSubmit: ({ increaseStep }) => increaseStep() }, - { onSubmit: ({ decreaseStep }) => decreaseStep() }, - ], - }); - - const form1 = createLoginForm(); - const form2 = createLoginForm(); - - page1.form(form1.formElement); - page2.form(form2.formElement); - - expect(get(step)).toBe(0); - - form1.formElement.submit(); - - await waitFor(() => { - expect(get(step)).toBe(1); - }); - - form2.formElement.submit(); - - await waitFor(() => { - expect(get(step)).toBe(0); - }); - }); - - test('passes correct data on submit', async () => { - const onSubmit1 = jest.fn(); - const onSubmit2 = jest.fn(); - const { - pages: [page1, page2], - } = createForms<[Data, Data]>({ - pages: [ - { - onSubmit: onSubmit1, - initialValues: { account: { email: '', password: '' } }, - }, - { - onSubmit: onSubmit2, - initialValues: { account: { email: '', password: '' } }, - }, - ], - }); - - const form1 = createLoginForm(); - const form2 = createLoginForm(); - - page1.form(form1.formElement); - page2.form(form2.formElement); - - const data1 = { - account: { - email: 'first@email.com', - password: 'password', - }, - }; - - const data2 = { - account: { - email: 'second@email.com', - password: 'password', - }, - }; - - page1.data.set(data1); - form1.formElement.submit(); - - await waitFor(() => { - expect(onSubmit1).toHaveBeenCalledWith( - expect.objectContaining({ - values: data1, - }) - ); - }); - - page2.data.set(data2); - form2.formElement.submit(); - - await waitFor(() => { - expect(onSubmit2).toHaveBeenCalledWith( - expect.objectContaining({ - values: data2, - allValues: [data1, data2], - }) - ); - }); - }); -}); diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md new file mode 100644 index 00000000..3f6ae6ed --- /dev/null +++ b/packages/react/CHANGELOG.md @@ -0,0 +1,360 @@ +# @felte/react + +## 1.0.0-next.30 + +### Patch Changes + +- @felte/core@1.0.0-next.27 + +## 1.0.0-next.29 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/core@1.0.0-next.26 + +## 1.0.0-next.28 + +### Minor Changes + +- fcbdaed: Add `swapFields` and `moveField` helper functions + +### Patch Changes + +- Updated dependencies [fcbdaed] + - @felte/core@1.0.0-next.25 + +## 1.0.0-next.27 + +### Minor Changes + +- 990034e: Add `interacted` store to show which is the last field the user has interacted with +- 0faaa8f: Add isValidating store + +### Patch Changes + +- Updated dependencies [990034e] +- Updated dependencies [5c71750] +- Updated dependencies [0faaa8f] + - @felte/core@1.0.0-next.24 + +## 1.0.0-next.26 + +### Patch Changes + +- Updated dependencies [8282a70] + - @felte/core@1.0.0-next.23 + +## 1.0.0-next.25 + +### Minor Changes + +- c412050: Add support for custom controls with `createField`/`useField` + +### Patch Changes + +- a174e87: Check for strict equality on value change +- Updated dependencies [b9ea48d] + - @felte/core@1.0.0-next.22 + +## 1.0.0-next.24 + +### Patch Changes + +- Updated dependencies [0b38b98] + - @felte/core@1.0.0-next.21 + +## 1.0.0-next.23 + +### Patch Changes + +- 2e7aad3: Add type for keyed Data +- Updated dependencies [2e7aad3] +- Updated dependencies [2e7aad3] + - @felte/core@1.0.0-next.20 + +## 1.0.0-next.22 + +### Minor Changes + +- c8c1511: Add unique key to field arrays + +### Patch Changes + +- Updated dependencies [c8c1511] + - @felte/core@1.0.0-next.19 + +## 1.0.0-next.21 + +### Major Changes + +- 093482a: BREAKING: Setting directly to `data` using `data.set` no longer touches the field. The `setFields` helper should be used instead if this behaviour is desired. + +### Minor Changes + +- 093482a: Add isValidating store + +### Patch Changes + +- Updated dependencies [093482a] +- Updated dependencies [093482a] + - @felte/core@1.0.0-next.18 + +## 1.0.0-next.20 + +### Patch Changes + +- Updated dependencies [dd52c94] + - @felte/core@1.0.0-next.17 + +## 1.0.0-next.19 + +### Major Changes + +- a45d56c: BREAKING: `errors` and `warning` stores will either have `null` or an array of strings as errors + +### Patch Changes + +- Updated dependencies [a45d56c] + - @felte/core@1.0.0-next.16 + +## 1.0.0-next.18 + +### Major Changes + +- 452fe5a: BREAKING: Remove `data-felte-index` attribute support. + + This means that you should replace this: + + ```html + + ``` + + To this: + + ```html + + ``` + + This was done in order to allow for future improvements of the type system for TypeScript users, and to also follow the same behaviour the browser would do if JavaScript is disabled + +- 15d0ce2: BREAKING: Stop grabbing nested names from fieldset + + This means that this won't work anymore: + + ```html +
+ +
+ ``` + + So it needs to be changed to this: + + ```html +
+ +
+ ``` + + This was done to allow for future improvements on type-safety, as well to keep consistency with the browser's behaviour when JavaScript is disabled. + +### Patch Changes + +- Updated dependencies [452fe5a] +- Updated dependencies [15d0ce2] + - @felte/core@1.0.0-next.15 + +## 1.0.0-next.17 + +### Major Changes + +- b7ef442: BREAKING: Remove `addWarnValidator` in favour of options to `addValidator`. + + This gives a smaller and more unified API, as well as opening to add more options in the future. + + If you have an extender using `addWarnValidator`, you must update it by calling `addValidator` instead with the following options: + + ```javascript + addValidator(yourValidationFunction, { level: 'warning' }); + ``` + +### Minor Changes + +- a1dbc28: Improve types +- 34e0393: Make string paths for accessors type safe + +### Patch Changes + +- Updated dependencies [a1dbc28] +- Updated dependencies [ec740a0] +- Updated dependencies [34e0393] +- Updated dependencies [b7ef442] +- Updated dependencies [477bb45] + - @felte/core@1.0.0-next.14 + +## 1.0.0-next.16 + +### Patch Changes + +- f315439: Export events as types +- Updated dependencies [f315439] + - @felte/core@1.0.0-next.13 + +## 1.0.0-next.15 + +### Minor Changes + +- dc1f21a: Add helper functions to context passed to `onSuccess`, `onSubmit` and `onError` +- eea3afa: Pass context data to `onError` and `onSuccess` + +### Patch Changes + +- Updated dependencies [dc1f21a] +- Updated dependencies [eea3afa] + - @felte/core@1.0.0-next.12 + +## 1.0.0-next.14 + +### Patch Changes + +- 38fbb49: Point "browser" field to esm bundle +- Updated dependencies [38fbb49] + - @felte/core@1.0.0-next.11 + +## 1.0.0-next.13 + +### Patch Changes + +- e91a298: Update peer dependencies + - @felte/core@1.0.0-next.10 + +## 1.0.0-next.12 + +### Patch Changes + +- 46b05e3: Fix when publishing as modules +- Updated dependencies [46b05e3] + - @felte/core@1.0.0-next.9 + +## 1.0.0-next.11 + +### Patch Changes + +- e49c094: Use `preserveModules` for better tree-shaking +- Updated dependencies [e49c094] + - @felte/core@1.0.0-next.8 + +## 1.0.0-next.10 + +### Patch Changes + +- 0d98ecf: Set initial value on first subscription to prevent re-renders + +## 1.0.0-next.9 + +### Patch Changes + +- 47500c9: Use ref for form instead of state callback + +## 1.0.0-next.8 + +### Patch Changes + +- 62ceb3f: Fix hot module reloading +- Updated dependencies [62ceb3f] + - @felte/core@1.0.0-next.7 + +## 1.0.0-next.7 + +### Patch Changes + +- 2e1e006: Fix `stop is not a function` when using hmr + +## 1.0.0-next.6 + +### Minor Changes + +- f9b9125: Add `feltesuccess` and `felteerror` events +- 96c3c18: Add default submit handler + +### Patch Changes + +- f71f2c5: Fix equality checker for files +- Updated dependencies [f9b9125] +- Updated dependencies [96c3c18] +- Updated dependencies [d1b62bf] + - @felte/core@1.0.0-next.6 + +## 1.0.0-next.5 + +### Patch Changes + +- @felte/core@1.0.0-next.5 + +## 1.0.0-next.4 + +### Patch Changes + +- 8c29b4a: Fix unset on Safari +- Updated dependencies [8c29b4a] + - @felte/core@1.0.0-next.4 + +## 1.0.0-next.3 + +### Minor Changes + +- 6f48123: Add `addField` helper function + +### Patch Changes + +- Updated dependencies [6f48123] + - @felte/core@1.0.0-next.3 + +## 1.0.0-next.2 + +### Major Changes + +- 77de471: BREAKING: Stop proxying inputs. This was causing all sorts of race conditions which were a headache to solve. Instead we're going to keep a single recommendation: If you wish to programatically set the value of an input, use the `setFields` helper. +- 02a77e3: BREAKING: When removing an input from an array of inputs, Felte now splices the array instead of setting the value to `null`/`undefined`. This means that an `index` on an array of inputs is no longer a _unique_ identifier and the value can move around if fields are added/removed. + +### Patch Changes + +- Updated dependencies [77de471] +- Updated dependencies [02a77e3] + - @felte/core@1.0.0-next.2 + +## 1.0.0-next.0 + +### Major Changes + +- a2ea0b2: BREAKING: `setFields` no longer touches a field by default. It needs to be explicit and it's only possible when passing a string path. E.g. `setField(‘email’ , 'zaphod@beeblebrox.com')` now is `setFields('email', 'zaphod@beeblebrox.com', true)`. +- 1dd68e7: BREAKING: Remove `data-felte-unset-on-remove` in favour of `data-felte-keep-on-remove`. Felte will now remove fields by default if removed from the DOM. + + To keep the same behaviour as before, add `data-felte-keep-on-remove` to any dynamic inputs you had that didn't have `data-felte-unset-on-remove` previously. And remove `data-felte-unset-on-remove` from the inputs that had it, or replace it for `data-felte-keep-on-remove="false"` if it was used to override a parent's attribute. + +- 6109533: BREAKING: apply transforms to initialValues +- 0d22bc6: BREAKING: Helpers have been completely reworked. + `setField` and `setFields` have been unified in a single `setFields` helper. + Others such as `setError` and `setWarning` have been pluralized to `setErrors` and `setWarnings` since now they can accept the whole object. + `setTouched` now requires to be passed the value to assign. E.g. `setTouched('path')` is now `setTouched('path', true)`. It no longer accepts an index as an argument since that can be assigned in the path itself using `[]`. +- 2c0f874: Make type of helpers and stores looser when using a transform function + +### Minor Changes + +- bee83f1: Export `useAccessor` +- c1f32a0: Add `unsetField` and `resetField` helper functions + +### Patch Changes + +- Updated dependencies [1bc036e] +- Updated dependencies [6431ee4] +- Updated dependencies [a2ea0b2] +- Updated dependencies [1dd68e7] +- Updated dependencies [6109533] +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/core@1.0.0-next.0 diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 00000000..e3eaf1a2 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,54 @@ +# @felte/react + +[![Bundle size](https://img.shields.io/bundlephobia/min/@felte/react)](https://bundlephobia.com/result?p=@felte/react) +[![NPM Version](https://img.shields.io/npm/v/@felte/react)](https://www.npmjs.com/package/@felte/react) + +Felte is an extensible form library originally built for Svelte but easily integrated with React using this package. Felte, on its most simple form, only requires you to set a `ref` to your form element to work. No custom `Field`or `Form` components are needed, making custom styles really easy to do. You can see it in action in this [CodeSandbox demo](https://codesandbox.io/s/felte-react-demo-q2xxw?file=/src/App.js) + +## Features + +- Single action to make your form reactive. +- Use HTML5 native elements to create your form. (Only the `name` attribute is necessary). +- No re-renders at all unless you need to use a specific field's value within your component. +- Provides stores and helper functions to handle more complex use cases. +- No assumptions on your validation strategy. Use any validation library you want or write your own strategy. +- Handles addition and removal of form controls during runtime. +- Official solutions for error reporting using `reporter` packages. +- Well tested. Currently at [99% code coverage](https://app.codecov.io/gh/pablo-abc/felte) and constantly working on improving test quality. +- Supports validation with [yup](./packages/validator-yup/README.md), [zod](./packages/validator-zod/README.md) and [superstruct](./packages/validator-superstruct/README.md). +- Easily [extend its functionality](https://felte.dev/docs/react/extending-felte). + +## Simple ussage example + +```jsx +import React, { useEffect } from 'react'; +import { useForm } from '@felte/react'; + +function Form() { + const { form } = useForm({ + onSubmit: (values) => console.log(values), + }); + + return ( +
+ + + +
+ ); +} +``` + +## Installation + +```sh +npm install --save @felte/react + +# Or, if you use yarn + +yarn add @felte/react +``` + +## Usage + +To learn more about how to use `@felte/react` to handle your forms, check the [official documentation](https://felte.dev/docs/react/getting-started). diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000..6d58854f --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,62 @@ +{ + "name": "@felte/react", + "version": "1.0.0-next.30", + "description": "An extensible form library for ReactJS", + "main": "dist/cjs/index.cjs", + "browser": "dist/esm/index.js", + "module": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "sideEffects": false, + "author": "Pablo Berganza ", + "license": "MIT", + "homepage": "https://felte.dev", + "repository": "github:pablo-abc/felte", + "funding": "https://www.buymeacoffee.com/pablo.abc", + "keywords": [ + "reactjs", + "react", + "forms", + "validation" + ], + "scripts": { + "prebuild": "rimraf ./dist", + "build": "pnpm prebuild && cross-env NODE_ENV=production rollup -c", + "dev": "rollup -cw", + "prepublishOnly": "pnpm build && pnpm test", + "test": "uvu -r tsm -r global-jsdom/register tests -i common", + "test:ci": "nyc -n src pnpm test" + }, + "files": [ + "dist" + ], + "dependencies": { + "@felte/core": "workspace:*" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "react": "^16.8.0 || >=17.0.0" + }, + "devDependencies": { + "@felte/common": "workspace:*", + "@testing-library/react": "^12.1.2", + "@testing-library/react-hooks": "^7.0.2", + "@types/react": "^17.0.37", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-test-renderer": "^17.0.2" + }, + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs", + "default": "./dist/esm/index.js" + }, + "./package.json": "./package.json" + } +} diff --git a/packages/multi-step/rollup.config.js b/packages/react/rollup.config.js similarity index 58% rename from packages/multi-step/rollup.config.js rename to packages/react/rollup.config.js index 09824224..75b26090 100644 --- a/packages/multi-step/rollup.config.js +++ b/packages/react/rollup.config.js @@ -2,28 +2,28 @@ import typescript from 'rollup-plugin-ts'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; -import { terser } from 'rollup-plugin-terser'; -import bundleSize from 'rollup-plugin-bundle-size'; +import renameNodeModules from 'rollup-plugin-rename-node-modules'; import pkg from './package.json'; const prod = process.env.NODE_ENV === 'production'; -const name = pkg.name - .replace(/^(@\S+\/)?(svelte-)?(\S+)/, '$3') - .replace(/^\w/, (m) => m.toUpperCase()) - .replace(/-\w/g, (m) => m[1].toUpperCase()); export default { input: './src/index.ts', - external: ['felte'], + external: ['react'], output: [ { - file: pkg.browser, + file: pkg.main, format: 'cjs', sourcemap: prod, + }, + { + dir: 'dist/esm', + format: 'esm', + sourcemap: prod, exports: 'named', - name, + preserveModules: true, + preserveModulesRoot: 'src', }, - { file: pkg.module, format: 'esm', sourcemap: prod, exports: 'named' }, ], plugins: [ replace({ @@ -34,8 +34,7 @@ export default { }), resolve({ browser: true }), commonjs(), - typescript(), - prod && terser(), - prod && bundleSize(), + typescript({ browserslist: false }), + renameNodeModules('external', prod), ], }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 00000000..925cf846 --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,11 @@ +export type { + FelteSuccessDetail, + FelteErrorDetail, + FelteSuccessEvent, + FelteErrorEvent, +} from '@felte/core'; +export { FelteSubmitError } from '@felte/core'; +export { useAccessor } from './use-accessor'; +export { useForm } from './use-form'; +export type { Field, FieldConfig } from './use-field'; +export { useField } from './use-field'; diff --git a/packages/react/src/stores.ts b/packages/react/src/stores.ts new file mode 100644 index 00000000..cc986b14 --- /dev/null +++ b/packages/react/src/stores.ts @@ -0,0 +1,112 @@ +// Taken from https://github.com/sveltejs/svelte/blob/master/src/runtime/store/index.ts +/** Callback to inform of a value updates. */ +export type Subscriber = (value: T) => void; + +/** Unsubscribes from value updates. */ +export type Unsubscriber = () => void; + +/** Callback to update a value. */ +export type Updater = (value: T) => T; + +/** Cleanup logic callback. */ +type Invalidator = (value?: T) => void; + +/** Start and stop notification callbacks. */ +export type StartStopNotifier = (set: Subscriber) => Unsubscriber | void; + +/** Pair of subscriber and invalidator. */ +type SubscribeInvalidateTuple = [Subscriber, Invalidator]; + +/** Writable interface for both updating and subscribing. */ +export interface Writable { + /** + * Subscribe on value changes. + * @param run subscription callback + * @param invalidate cleanup callback + */ + subscribe( + this: void, + run: Subscriber, + invalidate?: Invalidator + ): Unsubscriber; + /** + * Set value and inform subscribers. + * @param value to set + */ + set(this: void, value: T): void; + + /** + * Update value using callback and inform subscribers. + * @param updater callback + */ + update(this: void, updater: Updater): void; +} + +const subscriber_queue: any[] = []; + +const noop = () => undefined; + +export function safe_not_equal(a: unknown, b: unknown) { + return a != a + ? b == b + : a !== b || (a && typeof a === 'object') || typeof a === 'function'; +} + +/** + * Create a `Writable` store that allows both updating and reading by subscription. + * @param {*=}value initial value + * @param {StartStopNotifier=}start start and stop notifications for subscriptions + */ +export function writable( + value?: T, + start: StartStopNotifier = noop +): Writable { + let stop: Unsubscriber | null; + const subscribers: Set> = new Set(); + + function set(new_value: T): void { + if (safe_not_equal(value, new_value)) { + value = new_value; + if (stop) { + // store is ready + const run_queue = !subscriber_queue.length; + for (const subscriber of subscribers) { + subscriber[1](); + subscriber_queue.push(subscriber, value); + } + if (run_queue) { + for (let i = 0; i < subscriber_queue.length; i += 2) { + subscriber_queue[i][0](subscriber_queue[i + 1]); + } + subscriber_queue.length = 0; + } + } + } + } + + function update(fn: Updater): void { + set(fn(value as any)); + } + + function subscribe( + run: Subscriber, + invalidate: Invalidator = noop + ): Unsubscriber { + const subscriber: SubscribeInvalidateTuple = [run, invalidate]; + subscribers.add(subscriber); + if (subscribers.size === 1) { + stop = start(set) || noop; + } + run(value as any); + + return () => { + subscribers.delete(subscriber); + if (stop && subscribers.size === 0) { + stop(); + stop = null; + } + }; + } + + return { set, update, subscribe }; +} diff --git a/packages/react/src/use-accessor.ts b/packages/react/src/use-accessor.ts new file mode 100644 index 00000000..130f4027 --- /dev/null +++ b/packages/react/src/use-accessor.ts @@ -0,0 +1,120 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import type { + Obj, + Errors, + Touched, + TransWritable, + Traverse, + Paths, + Keyed, + KeyedWritable, +} from '@felte/core'; +import type { Readable, Writable } from 'svelte/store'; +import { getValue, getValueFromStore, isEqual } from '@felte/core'; + +export type Accessor = T extends Obj + ? ((selector: (storeValue: T) => R) => R) & + (< + P extends Paths = Paths, + V extends Traverse = Traverse + >( + path: P + ) => V) & + (() => T) + : T extends string | boolean | null + ? ((deriveFn: (storeValue: T) => R) => R) & (() => T) + : ((selector: (storeValue: any) => any) => any) & + ((path: string) => any) & + (() => any); + +export type UnknownStores = Omit, 'data'> & { + data: Accessor> & TransWritable; +}; + +export type KnownStores = Omit, 'data'> & { + data: Accessor> & KeyedWritable; +}; + +export type Stores = { + data: Accessor> & KeyedWritable; + errors: Accessor> & Writable>; + warnings: Accessor> & Writable>; + touched: Accessor> & Writable>; + isSubmitting: Accessor & Writable; + isValid: Accessor & Readable; + isDirty: Accessor & Writable; + isValidating: Accessor & Readable; + interacted: Accessor & Writable; +}; + +type SelectorOrPath = string | ((value: T) => R); + +function isWritable(store: Readable): store is Writable { + return !!(store as any).set; +} + +export function useAccessor(store: Writable): Accessor & Writable; +export function useAccessor(store: Readable): Accessor & Readable; +export function useAccessor( + store: Readable | Writable +): Accessor & (Readable | Writable) { + const [, setUpdate] = useState({}); + const storeValue = useRef(getValueFromStore(store)); + const values = useRef>({}); + const subscribedRef = useRef> | boolean>( + false + ); + + const accessor = useCallback( + (selectorOrPath?: ((value: T) => R) | string) => { + const subscribed = subscribedRef.current; + if (!selectorOrPath) { + subscribedRef.current = true; + return storeValue.current; + } + if (typeof subscribed === 'boolean') { + subscribedRef.current ||= { + [selectorOrPath.toString()]: selectorOrPath, + }; + } else { + subscribed[selectorOrPath.toString()] = selectorOrPath; + } + let value = values.current[selectorOrPath.toString()]; + if (value == null) { + value = values.current[selectorOrPath.toString()] = getValue( + storeValue.current, + selectorOrPath + ); + } + return value; + }, + [] + ) as Accessor & Writable; + + accessor.subscribe = store.subscribe; + if (isWritable(store)) { + accessor.set = store.set; + accessor.update = store.update; + } + + useEffect(() => { + return store.subscribe(($store) => { + storeValue.current = $store; + if (!subscribedRef.current) return; + if (subscribedRef.current === true) return setUpdate({}); + let hasChanged = false; + const keys = Object.keys(subscribedRef.current); + for (const key of keys) { + const selector = subscribedRef.current[key]; + const newValue = getValue($store, selector); + if (!isEqual(newValue, values.current[selector.toString()])) { + values.current[selector.toString()] = newValue; + hasChanged = true; + } + } + if (hasChanged) setUpdate({}); + }); + }, []); + + return accessor; +} diff --git a/packages/react/src/use-const.ts b/packages/react/src/use-const.ts new file mode 100644 index 00000000..d0974305 --- /dev/null +++ b/packages/react/src/use-const.ts @@ -0,0 +1,9 @@ +import { useRef } from 'react'; + +export function useConst(setup: () => T): T { + const ref = useRef(); + if (ref.current === undefined) { + ref.current = setup(); + } + return ref.current; +} diff --git a/packages/react/src/use-field.ts b/packages/react/src/use-field.ts new file mode 100644 index 00000000..7a26818e --- /dev/null +++ b/packages/react/src/use-field.ts @@ -0,0 +1,43 @@ +import type { Field as CoreField, FieldConfig } from '@felte/core'; +export type { FieldConfig } from '@felte/core'; +import type { Ref } from 'react'; +import { createField as coreCreateField } from '@felte/core'; +import { useRef, useEffect } from 'react'; +import { useConst } from './use-const'; + +export type Field = Omit & { + field: Ref; +}; + +export function useField( + name: string, + config?: Omit +): Field; +export function useField(config: FieldConfig): Field; +export function useField( + nameOrConfig: FieldConfig | string, + config?: Omit +): Field { + const fieldRef = useRef(null); + + const { field, ...rest } = useConst(() => { + const { field: coreField, ...rest } = coreCreateField(nameOrConfig, config); + + function field(node?: HTMLElement | null) { + if (!node) return; + const { destroy } = coreField(node); + return () => destroy?.(); + } + + return { field, ...rest }; + }); + + useEffect(() => { + return field(fieldRef.current); + }, []); + + return { + field: fieldRef, + ...rest, + }; +} diff --git a/packages/react/src/use-form.ts b/packages/react/src/use-form.ts new file mode 100644 index 00000000..fb08b5f4 --- /dev/null +++ b/packages/react/src/use-form.ts @@ -0,0 +1,107 @@ +import type { Ref } from 'react'; +import { useRef, useEffect } from 'react'; +import type { + Paths, + FormConfig, + Obj, + Errors, + Touched, + CreateSubmitHandlerConfig, + Helpers, + KnownHelpers, + UnknownHelpers, + FormConfigWithTransformFn, + FormConfigWithoutTransformFn, + Keyed, + KeyedWritable, +} from '@felte/core'; +import type { Writable } from 'svelte/store'; +import { createForm as coreCreateForm } from '@felte/core'; +import { writable } from './stores'; +import type { + Stores, + UnknownStores, + KnownStores, + Accessor, +} from './use-accessor'; +import { useAccessor } from './use-accessor'; +import { useConst } from './use-const'; + +/** The return type for the `createForm` function. */ +export type Form = { + /** Action function to be used with the `use` directive on your `form` elements. */ + form: Ref; + /** Function to handle submit to be passed to the on:submit event. Not necessary if using the `form` action. */ + handleSubmit(e?: Event): void; + /** Function that creates a submit handler. If a function is passed as first argument it overrides the default `onSubmit` function set in the `createForm` config object. */ + createSubmitHandler( + altConfig?: CreateSubmitHandlerConfig + ): (e?: Event) => void; +}; + +export function useForm( + config: FormConfigWithTransformFn & Ext +): Form & UnknownHelpers> & UnknownStores; +export function useForm( + config?: FormConfigWithoutTransformFn & Ext +): Form & KnownHelpers> & KnownStores; +export function useForm( + config?: FormConfig +): Form & Helpers> & Stores { + const formRef = useRef(null); + const destroyRef = useRef<() => void>(); + + const { startStores, form, ...rest } = useConst(() => { + const coreConfig = { + ...config, + preventStoreStart: true, + }; + const { form: coreForm, ...rest } = coreCreateForm(coreConfig, { + storeFactory: writable, + }); + const form = (node?: HTMLFormElement | null) => { + if (!node) return; + const { destroy } = coreForm(node); + destroyRef.current = destroy; + }; + return { form, ...rest }; + }); + + const data = useAccessor>(rest.data) as Accessor> & + KeyedWritable; + const errors = useAccessor>( + rest.errors as Writable> + ); + const touched = useAccessor>(rest.touched); + const warnings = useAccessor>( + rest.warnings as Writable> + ); + const isSubmitting = useAccessor(rest.isSubmitting); + const isDirty = useAccessor(rest.isDirty); + const isValid = useAccessor(rest.isValid); + const isValidating = useAccessor(rest.isValidating); + const interacted = useAccessor(rest.interacted); + + useEffect(() => { + const cleanup = startStores(); + form(formRef.current); + return () => { + cleanup(); + destroyRef.current?.(); + }; + }, []); + + return { + ...rest, + form: formRef, + data, + errors, + warnings, + touched, + isSubmitting, + isDirty, + isValid, + isValidating, + interacted, + }; +} diff --git a/packages/react/tests/use-accessor.spec.ts b/packages/react/tests/use-accessor.spec.ts new file mode 100644 index 00000000..1c4ed44b --- /dev/null +++ b/packages/react/tests/use-accessor.spec.ts @@ -0,0 +1,57 @@ +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { writable } from 'svelte/store'; +import { useAccessor } from '../src/use-accessor'; + +const Accessor = suite('useAccessor'); + +Accessor('subscribes to primitive accessor', () => { + const store = writable(true); + const { result } = renderHook(() => useAccessor(store)); + expect(result.current()).to.be.true; + act(() => store.set(false)); + expect(result.current()).to.be.false; +}); + +Accessor('subscribes to accessor without selector', () => { + const store = writable({ email: '' }); + const { result } = renderHook(() => useAccessor(store)); + expect(result.current()).to.deep.equal({ email: '' }); + act(() => store.set({ email: 'zaphod@beeblebrox.com' })); + expect(result.current()).to.deep.equal({ email: 'zaphod@beeblebrox.com' }); +}); + +Accessor('subscribes to an accessor with a selector', () => { + const store = writable({ email: '' }); + const { result } = renderHook(() => useAccessor(store)); + expect(result.current((data) => data.email)).to.equal(''); + act(() => store.set({ email: 'zaphod@beeblebrox.com' })); + expect(result.current((data) => data.email)).to.equal( + 'zaphod@beeblebrox.com' + ); + act(() => store.set({ email: 'jacek@soplica.com' })); + expect(result.current((data) => data.email)).to.equal('jacek@soplica.com'); +}); + +Accessor('subscribes to an accessor with a path', () => { + const store = writable({ email: '' }); + const { result } = renderHook(() => useAccessor(store)); + expect(result.current('email')).to.equal(''); + act(() => store.set({ email: 'zaphod@beeblebrox.com' })); + expect(result.current('email')).to.equal('zaphod@beeblebrox.com'); +}); + +Accessor('subscribes to an accessor with multiple selectors', () => { + const store = writable({ email: '' }); + const { result } = renderHook(() => useAccessor(store)); + expect(result.current((data) => data.email)).to.equal(''); + expect(result.current('email')).to.equal(''); + act(() => store.set({ email: 'zaphod@beeblebrox.com' })); + expect(result.current((data) => data.email)).to.equal( + 'zaphod@beeblebrox.com' + ); + expect(result.current('email')).to.equal('zaphod@beeblebrox.com'); +}); + +Accessor.run(); diff --git a/packages/react/tests/use-field.spec.tsx b/packages/react/tests/use-field.spec.tsx new file mode 100644 index 00000000..a398148e --- /dev/null +++ b/packages/react/tests/use-field.spec.tsx @@ -0,0 +1,33 @@ +import 'uvu-expect-dom/extend'; +import React from 'react'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { render, waitFor } from '@testing-library/react'; +import { useField } from '../src'; + +const Field = suite('Correctly uses useField'); + +Field('adds hidden input', async () => { + function CustomInput() { + const { field, onChange, onBlur } = useField('test'); + + return ( +
onChange(e.currentTarget.innerText)} + onBlur={onBlur} + /> + ); + } + const { unmount } = render(); + + await waitFor(() => { + expect(document.querySelector('[name="test"]')).to.not.be.in.document; + }); + unmount(); +}); + +Field.run(); diff --git a/packages/react/tests/use-form.spec.tsx b/packages/react/tests/use-form.spec.tsx new file mode 100644 index 00000000..42a241ba --- /dev/null +++ b/packages/react/tests/use-form.spec.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { render, screen, waitFor } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { useForm } from '../src'; + +const UseForm = suite('useForm'); + +UseForm('calls onSubmit without a form ref', async () => { + const mockSubmit = sinon.fake(); + const { result } = renderHook(() => useForm({ onSubmit: mockSubmit })); + const submit = result.current.createSubmitHandler(); + sinon.assert.notCalled(mockSubmit); + submit(); + await waitFor(() => { + sinon.assert.called(mockSubmit); + }); +}); + +UseForm('calls onSubmit with a form ref', async () => { + const mockSubmit = sinon.fake(); + function Form() { + const { form } = useForm({ onSubmit: mockSubmit }); + return
; + } + render(); + const formElement = screen.getByRole('form') as HTMLFormElement; + sinon.assert.notCalled(mockSubmit); + formElement.submit(); + await waitFor(() => { + sinon.assert.called(mockSubmit); + }); +}); + +UseForm('sets value with helper', () => { + const mockSubmit = sinon.fake(); + const { result, unmount } = renderHook(() => + useForm({ onSubmit: mockSubmit, initialValues: { email: '' } }) + ); + act(() => result.current.setTouched('email', true)); + expect(result.current.errors()).to.deep.equal({ email: null }); + act(() => result.current.setErrors({ email: ['not an email'] })); + expect(result.current.errors()).to.deep.equal({ email: ['not an email'] }); + unmount(); +}); + +UseForm('updates value with helper', () => { + type Data = { + email: string; + }; + const mockSubmit = sinon.fake(); + const { result } = renderHook(() => + useForm({ onSubmit: mockSubmit, initialValues: { email: '' } }) + ); + act(() => result.current.setTouched('email', true)); + expect(result.current.errors()).to.deep.equal({ email: null }); + act(() => result.current.setErrors({ email: ['not an email'] })); + expect(result.current.errors()).to.deep.equal({ email: ['not an email'] }); + act(() => { + result.current.setErrors((oldErrors) => ({ + ...oldErrors, + email: oldErrors.email?.[0].toUpperCase(), + })); + }); + expect(result.current.errors()).to.deep.equal({ email: ['NOT AN EMAIL'] }); + act(() => { + result.current.setErrors('email', (email) => + (email as string).toLowerCase() + ); + }); + expect(result.current.errors()).to.deep.equal({ email: ['not an email'] }); +}); + +UseForm.run(); diff --git a/packages/multi-step/tsconfig.json b/packages/react/tsconfig.json similarity index 79% rename from packages/multi-step/tsconfig.json rename to packages/react/tsconfig.json index 71f82fd9..9add1941 100644 --- a/packages/multi-step/tsconfig.json +++ b/packages/react/tsconfig.json @@ -10,7 +10,8 @@ "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, - "declarationDir": "./dist" + "jsx": "react", + "declarationDir": "./dist/types" }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/packages/react/tsconfig.test.json b/packages/react/tsconfig.test.json new file mode 100644 index 00000000..8dd7b176 --- /dev/null +++ b/packages/react/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declaration": true, + "target": "es2017", + "newLine": "LF", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "jsx": "react", + "outDir": "./dist", + "module": "esnext" + }, + "include": ["./src", "./tests"], + "exclude": ["node_modules/"] +} diff --git a/packages/reporter-cvapi/CHANGELOG.md b/packages/reporter-cvapi/CHANGELOG.md index 001213fe..11d50e9c 100644 --- a/packages/reporter-cvapi/CHANGELOG.md +++ b/packages/reporter-cvapi/CHANGELOG.md @@ -1,5 +1,60 @@ # @felte/reporter-cvapi +## 1.0.0-next.5 + +### Patch Changes + +- Updated dependencies [7f3d8b8] + - @felte/common@1.0.0-next.23 + +## 1.0.0-next.4 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/common@1.0.0-next.22 + +## 1.0.0-next.3 + +### Patch Changes + +- Updated dependencies [fcbdaed] + - @felte/common@1.0.0-next.21 + +## 1.0.0-next.2 + +### Patch Changes + +- Updated dependencies [990034e] + - @felte/common@1.0.0-next.20 + +## 1.0.0-next.1 + +### Minor Changes + +- 02fd56e: Ensure good behaviour with controls created by `useField`/`createField` by only focusing non-hidden inputs + +### Patch Changes + +- Updated dependencies [a174e87] + - @felte/common@1.0.0-next.19 + +## 1.0.0-next.0 + +### Major Changes + +- 9a48a40: Pass a new property `stage` to extenders to distinguish between setup, mount and update stages + +### Patch Changes + +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/common@1.0.0-next.0 + ## 0.1.16 ### Patch Changes diff --git a/packages/reporter-cvapi/jest.config.js b/packages/reporter-cvapi/jest.config.js deleted file mode 100644 index 553799a5..00000000 --- a/packages/reporter-cvapi/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], -}; diff --git a/packages/reporter-cvapi/package.json b/packages/reporter-cvapi/package.json index 11599627..a97df1ea 100644 --- a/packages/reporter-cvapi/package.json +++ b/packages/reporter-cvapi/package.json @@ -1,11 +1,15 @@ { "name": "@felte/reporter-cvapi", - "version": "0.1.16", + "version": "1.0.0-next.5", "description": "An error reporter for Felte using the browser's Constraint Validation API", - "main": "dist/index.js", - "browser": "dist/index.js", + "main": "dist/index.cjs", + "browser": "dist/index.mjs", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "sideEffects": false, "author": "Pablo Berganza ", "repository": "github:pablo-abc/felte", @@ -18,18 +22,19 @@ ], "scripts": { "prebuild": "rimraf ./dist", - "build": "cross-env NODE_ENV=production rollup -c", + "build": "pnpm prebuild && cross-env NODE_ENV=production rollup -c", "dev": "rollup -cw", "prepublishOnly": "pnpm build && pnpm test", - "test": "jest", - "test:ci": "jest --ci --coverage" + "test": "uvu -r tsm -r global-jsdom/register -r module-alias/register tests -i common -i mocks", + "test:ci": "nyc -n src pnpm test" }, "license": "MIT", "dependencies": { - "@felte/common": "^0.6.0" + "@felte/common": "workspace:*" }, "devDependencies": { - "felte": "^0.9.0" + "felte": "workspace:*", + "svelte": "^3.46.4" }, "publishConfig": { "access": "public" @@ -37,9 +42,13 @@ "exports": { ".": { "import": "./dist/index.mjs", - "require": "./dist/index.js", + "require": "./dist/index.cjs", "default": "./dist/index.mjs" }, "./package.json": "./package.json" + }, + "_moduleAliases": { + "svelte": "tests/mocks/svelte.js", + "svelte/store": "node_modules/svelte/store/index.mjs" } } diff --git a/packages/reporter-cvapi/rollup.config.js b/packages/reporter-cvapi/rollup.config.js index b5fe97aa..f013efd4 100644 --- a/packages/reporter-cvapi/rollup.config.js +++ b/packages/reporter-cvapi/rollup.config.js @@ -2,7 +2,6 @@ import typescript from 'rollup-plugin-ts'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; -import { terser } from 'rollup-plugin-terser'; import bundleSize from 'rollup-plugin-bundle-size'; import pkg from './package.json'; @@ -17,7 +16,7 @@ export default { external: ['tippy.js'], output: [ { - file: pkg.browser, + file: pkg.main, format: 'cjs', sourcemap: prod, exports: 'default', @@ -34,8 +33,7 @@ export default { }), resolve({ browser: true }), commonjs(), - typescript(), - prod && terser(), + typescript({ browserlist: false }), prod && bundleSize(), ], }; diff --git a/packages/reporter-cvapi/src/index.ts b/packages/reporter-cvapi/src/index.ts index 73104c99..875f48ee 100644 --- a/packages/reporter-cvapi/src/index.ts +++ b/packages/reporter-cvapi/src/index.ts @@ -12,7 +12,6 @@ const mutationConfig: MutationObserverInit = { function mutationCallback(mutationList: MutationRecord[]) { for (const mutation of mutationList) { - if (mutation.type !== 'attributes') continue; if (mutation.attributeName !== 'data-felte-validation-message') continue; const target = mutation.target as FormControl; const validationMessage = target.dataset.felteValidationMessage; @@ -23,9 +22,8 @@ function mutationCallback(mutationList: MutationRecord[]) { function cvapiReporter( currentForm: CurrentForm ): ExtenderHandler { + if (currentForm.stage === 'SETUP') return {}; const form = currentForm.form; - const controls = currentForm.controls; - if (!controls || !form) return {}; const mutationObserver = new MutationObserver(mutationCallback); mutationObserver.observe(form, mutationConfig); return { @@ -33,14 +31,9 @@ function cvapiReporter( mutationObserver.disconnect(); }, onSubmitError() { - let firstInvalidElement; - for (const control of controls) { - if (!control.name) continue; - const message = control.dataset.felteValidationMessage; - control.setCustomValidity(message || ''); - if (message) firstInvalidElement = control; - if (message) break; - } + const firstInvalidElement = form.querySelector( + '[aria-invalid="true"]:not([type="hidden"])' + ) as FormControl | null; form.reportValidity(); firstInvalidElement?.focus(); }, diff --git a/packages/reporter-cvapi/tests/common.ts b/packages/reporter-cvapi/tests/common.ts index ce8f1b10..7f05cee8 100644 --- a/packages/reporter-cvapi/tests/common.ts +++ b/packages/reporter-cvapi/tests/common.ts @@ -1,3 +1,5 @@ +import 'uvu-expect-dom/extend'; + export function createDOM(): void { const formElement = document.createElement('form'); formElement.name = 'test-form'; diff --git a/packages/reporter-cvapi/tests/mocks/svelte.js b/packages/reporter-cvapi/tests/mocks/svelte.js new file mode 100644 index 00000000..069b2a6f --- /dev/null +++ b/packages/reporter-cvapi/tests/mocks/svelte.js @@ -0,0 +1,3 @@ +import * as sinon from 'sinon'; + +export const onDestroy = sinon.fake(); diff --git a/packages/reporter-cvapi/tests/reporter.spec.ts b/packages/reporter-cvapi/tests/reporter.spec.ts new file mode 100644 index 00000000..add0fb19 --- /dev/null +++ b/packages/reporter-cvapi/tests/reporter.spec.ts @@ -0,0 +1,83 @@ +import * as sinon from 'sinon'; +import { createForm } from 'felte'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from '@testing-library/dom'; +import { createDOM, cleanupDOM, createInputElement } from './common'; +import reporter from '../src'; + +const Reporter = suite('Reporter CVAPI'); + +Reporter.before.each(createDOM); +Reporter.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +Reporter('sets input to invalid', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter, + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + id: 'test', + }); + formElement.appendChild(inputElement); + + const { destroy } = form(formElement); + + await validate(); + + await waitFor(() => { + expect(inputElement.checkValidity()).to.be.false; + expect(inputElement.validationMessage).to.equal(mockErrors.test); + }); + + mockValidate.returns({}); + + await validate(); + + await waitFor(() => { + expect(inputElement.checkValidity()).to.be.true; + }); + + destroy(); +}); + +Reporter('focuses first invalid input and sets validity', async () => { + const mockErrors = { test: 'A test error' }; + const mockValidate = sinon.fake(() => mockErrors); + const { form } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter, + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + const submitElement = createInputElement({ type: 'submit' }); + formElement.appendChild(inputElement); + formElement.appendChild(submitElement); + + form(formElement); + + userEvent.click(submitElement); + + await waitFor(() => { + expect(inputElement).to.equal(document.activeElement); + expect(inputElement.validationMessage).to.equal(mockErrors.test); + }); +}); + +Reporter.run(); diff --git a/packages/reporter-cvapi/tests/reporter.test.ts b/packages/reporter-cvapi/tests/reporter.test.ts deleted file mode 100644 index a79fd57e..00000000 --- a/packages/reporter-cvapi/tests/reporter.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen, waitFor } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import { createForm } from 'felte'; -import { createDOM, cleanupDOM, createInputElement } from './common'; -import reporter from '../src'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn() })); - -describe('Reporter CVAPI', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('sets input to invalid', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter, - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - formElement.appendChild(inputElement); - - const { destroy } = form(formElement); - - await validate(); - - await waitFor(() => { - expect(inputElement.checkValidity()).toBeFalsy(); - expect(inputElement.validationMessage).toBe(mockErrors.test); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - expect(inputElement.checkValidity()).toBeTruthy(); - }); - - destroy(); - }); - - test('focuses first invalid input and sets validity', async () => { - const mockErrors = { test: 'A test error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter, - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - const submitElement = createInputElement({ type: 'submit' }); - formElement.appendChild(inputElement); - formElement.appendChild(submitElement); - - form(formElement); - - userEvent.click(submitElement); - - await waitFor(() => { - expect(inputElement).toHaveFocus(); - expect(inputElement.validationMessage).toBe(mockErrors.test); - }); - }); -}); diff --git a/packages/reporter-dom/CHANGELOG.md b/packages/reporter-dom/CHANGELOG.md index ac97048c..f8a9f0d1 100644 --- a/packages/reporter-dom/CHANGELOG.md +++ b/packages/reporter-dom/CHANGELOG.md @@ -1,5 +1,191 @@ # @felte/reporter-dom +## 1.0.0-next.22 + +### Patch Changes + +- Updated dependencies [7f3d8b8] + - @felte/common@1.0.0-next.23 + +## 1.0.0-next.21 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/common@1.0.0-next.22 + +## 1.0.0-next.20 + +### Patch Changes + +- Updated dependencies [fcbdaed] + - @felte/common@1.0.0-next.21 + +## 1.0.0-next.19 + +### Patch Changes + +- Updated dependencies [990034e] + - @felte/common@1.0.0-next.20 + +## 1.0.0-next.18 + +### Minor Changes + +- 02fd56e: Ensure good behaviour with controls created by `useField`/`createField` by only focusing non-hidden inputs + +### Patch Changes + +- Updated dependencies [a174e87] + - @felte/common@1.0.0-next.19 + +## 1.0.0-next.17 + +### Patch Changes + +- Updated dependencies [70cfada] + - @felte/common@1.0.0-next.18 + +## 1.0.0-next.16 + +### Patch Changes + +- Updated dependencies [2e7aad3] + - @felte/common@1.0.0-next.17 + +## 1.0.0-next.15 + +### Patch Changes + +- Updated dependencies [c8c1511] + - @felte/common@1.0.0-next.16 + +## 1.0.0-next.14 + +### Patch Changes + +- Updated dependencies [093482a] + - @felte/common@1.0.0-next.15 + +## 1.0.0-next.13 + +### Patch Changes + +- Updated dependencies [dd52c94] + - @felte/common@1.0.0-next.14 + +## 1.0.0-next.12 + +### Patch Changes + +- Updated dependencies [a45d56c] + - @felte/common@1.0.0-next.13 + +## 1.0.0-next.11 + +### Patch Changes + +- Updated dependencies [452fe5a] +- Updated dependencies [15d0ce2] + - @felte/common@1.0.0-next.12 + +## 1.0.0-next.10 + +### Patch Changes + +- Updated dependencies [a1dbc28] +- Updated dependencies [ec740a0] +- Updated dependencies [34e0393] +- Updated dependencies [b7ef442] +- Updated dependencies [e1ad8cd] + - @felte/common@1.0.0-next.11 + +## 1.0.0-next.9 + +### Patch Changes + +- Updated dependencies [dc1f21a] +- Updated dependencies [eea3afa] + - @felte/common@1.0.0-next.10 + +## 1.0.0-next.8 + +### Patch Changes + +- Updated dependencies [38fbb49] + - @felte/common@1.0.0-next.9 + +## 1.0.0-next.7 + +### Patch Changes + +- Updated dependencies [c86a82a] + - @felte/common@1.0.0-next.8 + +## 1.0.0-next.6 + +### Patch Changes + +- Updated dependencies [e49c094] + - @felte/common@1.0.0-next.7 + +## 1.0.0-next.5 + +### Patch Changes + +- Updated dependencies [d1b62bf] + - @felte/common@1.0.0-next.6 + +## 1.0.0-next.4 + +### Patch Changes + +- Updated dependencies [e2f4e18] + - @felte/common@1.0.0-next.5 + +## 1.0.0-next.3 + +### Patch Changes + +- Updated dependencies [8c29b4a] + - @felte/common@1.0.0-next.3 + +## 1.0.0-next.2 + +### Patch Changes + +- Updated dependencies [6f48123] + - @felte/common@1.0.0-next.2 + +## 1.0.0-next.1 + +### Patch Changes + +- Updated dependencies [02a77e3] + - @felte/common@1.0.0-next.1 + +## 1.0.0-next.0 + +### Major Changes + +- 9a48a40: Pass a new property `stage` to extenders to distinguish between setup, mount and update stages +- 2c0f874: Make type of helpers and stores looser when using a transform function + +### Minor Changes + +- 1bc036e: Change responsibility for adding `aria-invalid` to fields to `@felte/core` +- c9f9d9f: Add support for displaying warnings. + +### Patch Changes + +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/common@1.0.0-next.0 + ## 0.3.13 ### Patch Changes diff --git a/packages/reporter-dom/README.md b/packages/reporter-dom/README.md index 25f2620a..eaca2e1b 100644 --- a/packages/reporter-dom/README.md +++ b/packages/reporter-dom/README.md @@ -51,6 +51,20 @@ In order to show the errors for a field, you'll need to add a container for each You can choose individually if you want to show errors as a `span` or a list wit the attributes `data-felte-reporter-dom-as-single` and `data-felte-reporter-dom-as-list` respectively. +### Warnings + +This reporter can help you display your `warning` messages as well. If you want this reporter to insert a warning message in a DOM element, you'll want to set the attribute `data-felte-reporter-dom-level` with the value `warning`. By default it would display errors. + +```html + + +
+``` + ## Styling This reporter will add the error messages inside of your container element. diff --git a/packages/reporter-dom/jest.config.js b/packages/reporter-dom/jest.config.js deleted file mode 100644 index 553799a5..00000000 --- a/packages/reporter-dom/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], -}; diff --git a/packages/reporter-dom/package.json b/packages/reporter-dom/package.json index ff870b00..88a97a53 100644 --- a/packages/reporter-dom/package.json +++ b/packages/reporter-dom/package.json @@ -1,11 +1,15 @@ { "name": "@felte/reporter-dom", - "version": "0.3.13", + "version": "1.0.0-next.22", "description": "An error reporter for Felte using the DOM", - "main": "dist/index.js", - "browser": "dist/index.js", + "main": "dist/index.cjs", + "browser": "dist/index.mjs", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "sideEffects": false, "author": "Pablo Berganza ", "repository": "github:pablo-abc/felte", @@ -18,18 +22,19 @@ ], "scripts": { "prebuild": "rimraf ./dist", - "build": "cross-env NODE_ENV=production rollup -c", + "build": "pnpm prebuild && cross-env NODE_ENV=production rollup -c", "dev": "rollup -cw", "prepublishOnly": "pnpm build && pnpm test", - "test": "jest", - "test:ci": "jest --ci --coverage" + "test": "uvu -r tsm -r global-jsdom/register -r module-alias/register tests -i common -i mocks", + "test:ci": "nyc -n src pnpm test" }, "license": "MIT", "dependencies": { "@felte/common": "workspace:*" }, "devDependencies": { - "felte": "workspace:*" + "felte": "workspace:*", + "svelte": "^3.46.4" }, "publishConfig": { "access": "public" @@ -37,9 +42,13 @@ "exports": { ".": { "import": "./dist/index.mjs", - "require": "./dist/index.js", + "require": "./dist/index.cjs", "default": "./dist/index.mjs" }, "./package.json": "./package.json" + }, + "_moduleAliases": { + "svelte": "tests/mocks/svelte.js", + "svelte/store": "node_modules/svelte/store/index.mjs" } } diff --git a/packages/reporter-dom/rollup.config.js b/packages/reporter-dom/rollup.config.js index b5fe97aa..f890b56e 100644 --- a/packages/reporter-dom/rollup.config.js +++ b/packages/reporter-dom/rollup.config.js @@ -2,7 +2,6 @@ import typescript from 'rollup-plugin-ts'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; -import { terser } from 'rollup-plugin-terser'; import bundleSize from 'rollup-plugin-bundle-size'; import pkg from './package.json'; @@ -17,7 +16,7 @@ export default { external: ['tippy.js'], output: [ { - file: pkg.browser, + file: pkg.main, format: 'cjs', sourcemap: prod, exports: 'default', @@ -34,8 +33,7 @@ export default { }), resolve({ browser: true }), commonjs(), - typescript(), - prod && terser(), + typescript({ browserslist: false }), prod && bundleSize(), ], }; diff --git a/packages/reporter-dom/src/index.ts b/packages/reporter-dom/src/index.ts index a8c551ba..162670b3 100644 --- a/packages/reporter-dom/src/index.ts +++ b/packages/reporter-dom/src/index.ts @@ -22,23 +22,12 @@ export interface DomReporterOptions { }; } -const mutationConfig: MutationObserverInit = { - attributes: true, - subtree: true, -}; - function removeAllChildren(parent: Node): void { while (parent.firstChild) { parent.removeChild(parent.firstChild); } } -function setInvalidState(target: FormControl) { - const validationMessage = target.dataset.felteValidationMessage; - if (!validationMessage) target.removeAttribute('aria-invalid'); - else target.setAttribute('aria-invalid', 'true'); -} - function setValidationMessage( reporterElement: HTMLElement, errors: Errors, @@ -116,37 +105,43 @@ function setValidationMessage( } } -function domReporter( +function handleSubscription( + form: HTMLFormElement, + level: 'error' | 'warning', options?: DomReporterOptions -): Extender { - function mutationCallback(mutationList: MutationRecord[]) { - for (const mutation of mutationList) { - if (mutation.type !== 'attributes') continue; - if (mutation.attributeName !== 'data-felte-validation-message') continue; - const target = mutation.target as FormControl; - setInvalidState(target); +) { + return function (messages: Errors) { + const elements: NodeListOf = form.querySelectorAll( + '[data-felte-reporter-dom-for]' + ); + for (const element of elements) { + const elementLevel = element.dataset.felteReporterDomLevel || 'error'; + if (level !== elementLevel) continue; + setValidationMessage(element, messages, options ?? {}); } - } + }; +} +function domReporter( + options?: DomReporterOptions +): Extender { return (currentForm: CurrentForm): ExtenderHandler => { - const form = currentForm.form; - if (!form) return {}; - const mutationObserver = new MutationObserver(mutationCallback); - mutationObserver.observe(form, mutationConfig); - const unsubscribe = currentForm.errors.subscribe(($errors) => { - const elements = form.querySelectorAll('[data-felte-reporter-dom-for]'); - for (const element of elements) { - setValidationMessage(element as HTMLElement, $errors, options ?? {}); - } - }); + if (currentForm.stage === 'SETUP') return {}; + const { form } = currentForm; + const unsubscribeErrors = currentForm.errors.subscribe( + handleSubscription(form, 'error', options) + ); + const unsubscribeWarnings = currentForm.warnings.subscribe( + handleSubscription(form, 'warning', options) + ); return { destroy() { - mutationObserver.disconnect(); - unsubscribe(); + unsubscribeErrors(); + unsubscribeWarnings(); }, onSubmitError() { const firstInvalidElement = form.querySelector( - '[data-felte-validation-message]' + '[data-felte-validation-message]:not([type="hidden"])' ) as FormControl; firstInvalidElement?.focus(); }, diff --git a/packages/reporter-dom/tests/common.ts b/packages/reporter-dom/tests/common.ts index 54331617..9ec8ebf8 100644 --- a/packages/reporter-dom/tests/common.ts +++ b/packages/reporter-dom/tests/common.ts @@ -1,3 +1,5 @@ +import 'uvu-expect-dom/extend'; + export function createDOM(): void { const formElement = document.createElement('form'); formElement.name = 'test-form'; @@ -15,6 +17,7 @@ export type InputAttributes = { value?: string; checked?: boolean; id?: string; + index?: number; }; export function createInputElement(attrs: InputAttributes): HTMLInputElement { @@ -24,6 +27,8 @@ export function createInputElement(attrs: InputAttributes): HTMLInputElement { if (attrs.value) inputElement.value = attrs.value; if (attrs.checked) inputElement.checked = attrs.checked; if (attrs.id) inputElement.id = attrs.id; + if (typeof attrs.index !== 'undefined') + inputElement.name = `${attrs.name}.${attrs.index}`; inputElement.required = !!attrs.required; return inputElement; } @@ -40,8 +45,7 @@ export function createMultipleInputElements( ): HTMLInputElement[] { const inputs = []; for (let i = 0; i < amount; i++) { - const input = createInputElement(attr); - input.dataset.felteIndex = String(i); + const input = createInputElement({ ...attr, index: i }); inputs.push(input); } return inputs; diff --git a/packages/reporter-dom/tests/mocks/svelte.js b/packages/reporter-dom/tests/mocks/svelte.js new file mode 100644 index 00000000..069b2a6f --- /dev/null +++ b/packages/reporter-dom/tests/mocks/svelte.js @@ -0,0 +1,3 @@ +import * as sinon from 'sinon'; + +export const onDestroy = sinon.fake(); diff --git a/packages/reporter-dom/tests/reporter.spec.ts b/packages/reporter-dom/tests/reporter.spec.ts new file mode 100644 index 00000000..a1d1ea48 --- /dev/null +++ b/packages/reporter-dom/tests/reporter.spec.ts @@ -0,0 +1,417 @@ +import * as sinon from 'sinon'; +import { createForm } from 'felte'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from '@testing-library/dom'; +import { + createDOM, + cleanupDOM, + createInputElement, + createMultipleInputElements, +} from './common'; +import reporter from '../src'; + +const Reporter = suite('Reporter DOM'); + +Reporter.before.each(createDOM); +Reporter.after.each(() => { + cleanupDOM(); + sinon.restore(); +}); + +Reporter('sets aria-invalid to input and removes if valid', async () => { + const mockErrors = { + test: 'An error', + multiple: new Array(3).fill('An error'), + }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + const multipleInputs = createMultipleInputElements({ + name: 'multiple', + type: 'text', + }); + const multipleMessages = multipleInputs.map((el) => { + const mes = document.createElement('div'); + mes.setAttribute('data-felte-reporter-dom-for', el.name); + return mes; + }); + const validationMessageElement = document.createElement('div'); + validationMessageElement.setAttribute('data-felte-reporter-dom-for', 'test'); + formElement.appendChild(inputElement); + formElement.appendChild(validationMessageElement); + formElement.append(...multipleInputs, ...multipleMessages); + + const { destroy } = form(formElement); + + await validate(); + + await waitFor(() => { + expect(inputElement).to.be.invalid; + multipleInputs.forEach((input) => expect(input).to.be.invalid); + }); + + mockValidate.returns({} as any); + + await validate(); + + await waitFor(() => { + expect(inputElement).to.be.valid; + multipleInputs.forEach((input) => expect(input).to.be.valid); + }); + + destroy(); +}); + +Reporter( + 'sets error message in list if invalid and removes it if valid', + async () => { + type Data = { + container: { + test: string; + multiple: string[]; + }; + }; + const mockErrors = { + container: { + test: 'An error', + multiple: new Array(3).fill('An error'), + }, + }; + const mockWarnings = { + container: { + test: 'A warning', + }, + }; + const mockValidate = sinon.stub().returns(mockErrors); + const mockWarn = sinon.stub().returns(mockWarnings); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + warn: mockWarn, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'container.test', + type: 'text', + id: 'test', + }); + const validationMessageElement = document.createElement('div'); + validationMessageElement.setAttribute( + 'data-felte-reporter-dom-for', + 'container.test' + ); + const warningMessageElement = document.createElement('div'); + warningMessageElement.setAttribute( + 'data-felte-reporter-dom-for', + 'container.test' + ); + warningMessageElement.setAttribute( + 'data-felte-reporter-dom-level', + 'warning' + ); + const multipleInputs = createMultipleInputElements({ + name: 'container.multiple', + type: 'text', + }); + const multipleMessages = multipleInputs.map((el) => { + const mes = document.createElement('div'); + mes.setAttribute('data-felte-reporter-dom-for', el.name); + return mes; + }); + const fieldsetElement = document.createElement('fieldset'); + + const validElement = createInputElement({ + name: 'container.valid', + type: 'text', + id: 'test', + }); + const validMessageElement = document.createElement('div'); + validMessageElement.setAttribute('data-felte-reporter-dom-for', ''); + fieldsetElement.appendChild(inputElement); + fieldsetElement.appendChild(validationMessageElement); + fieldsetElement.appendChild(warningMessageElement); + fieldsetElement.append(...multipleInputs, ...multipleMessages); + fieldsetElement.append(validElement, validMessageElement); + formElement.appendChild(fieldsetElement); + + form(formElement); + + await validate(); + userEvent.click(inputElement); + multipleInputs.forEach((input) => userEvent.click(input)); + userEvent.click(formElement); + + await waitFor(() => { + expect(validationMessageElement).to.contain.html( + '
  • An error
  • ' + ); + expect(warningMessageElement).to.contain.html( + '
  • A warning
  • ' + ); + multipleMessages.forEach((mes) => + expect(mes).to.contain.html( + '
  • An error
  • ' + ) + ); + }); + + mockValidate.returns({} as any); + + await validate(); + + await waitFor(() => { + expect(validationMessageElement).to.have.text.that.does.not.contain( + 'An error' + ); + multipleMessages.forEach((mes) => + expect(mes).not.to.contain.html( + '
  • An error
  • ' + ) + ); + }); + } +); + +Reporter( + 'sets error message in span if invalid and removes it if valid', + async () => { + const mockErrors = { + test: 'An error', + multiple: new Array(3).fill('An error'), + }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ single: true }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + id: 'test', + }); + const validationMessageElement = document.createElement('div'); + validationMessageElement.setAttribute( + 'data-felte-reporter-dom-for', + 'test' + ); + const multipleInputs = createMultipleInputElements({ + name: 'multiple', + type: 'text', + }); + const multipleMessages = multipleInputs.map((el) => { + const mes = document.createElement('div'); + mes.setAttribute('data-felte-reporter-dom-for', el.name); + return mes; + }); + formElement.appendChild(inputElement); + formElement.appendChild(validationMessageElement); + formElement.append(...multipleInputs, ...multipleMessages); + + form(formElement); + + await validate(); + userEvent.click(inputElement); + multipleInputs.forEach((input) => userEvent.click(input)); + userEvent.click(formElement); + + await waitFor(() => { + expect(validationMessageElement).to.contain.html( + 'An error' + ); + multipleMessages.forEach((mes) => + expect(mes).to.contain.html( + 'An error' + ) + ); + }); + + mockValidate.returns({} as any); + + await validate(); + + await waitFor(() => { + expect(validationMessageElement).to.have.text.that.does.not.contain( + 'An error' + ); + multipleMessages.forEach((mes) => + expect(mes).not.to.contain.html( + 'An error' + ) + ); + }); + } +); + +Reporter( + 'focuses first invalid input and shows validation message on submit', + async () => { + const mockErrors = { test: 'A test error' }; + const mockValidate = sinon.fake(() => mockErrors); + const { form } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + id: 'test', + }); + const validationMessageElement = document.createElement('div'); + validationMessageElement.setAttribute( + 'data-felte-reporter-dom-for', + 'test' + ); + formElement.appendChild(inputElement); + formElement.appendChild(validationMessageElement); + + form(formElement); + + formElement.submit(); + + await waitFor(() => { + expect(inputElement).to.equal(document.activeElement); + expect(validationMessageElement).to.have.text.that.contains( + 'A test error' + ); + }); + } +); + +Reporter('sets classes for reporter list elements', async () => { + type Data = { + container: { + test: string; + }; + }; + const mockErrors = { container: { test: 'An error' } }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ + listItemAttributes: { + class: 'li-class', + }, + listAttributes: { + class: 'ul-class', + }, + }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'container.test', + type: 'text', + id: 'test', + }); + const validationMessageElement = document.createElement('div'); + validationMessageElement.setAttribute( + 'data-felte-reporter-dom-for', + 'container.test' + ); + const fieldsetElement = document.createElement('fieldset'); + fieldsetElement.appendChild(inputElement); + fieldsetElement.appendChild(validationMessageElement); + formElement.appendChild(fieldsetElement); + + form(formElement); + + await validate(); + userEvent.click(inputElement); + userEvent.click(formElement); + + await waitFor(() => { + const listElement = validationMessageElement.querySelector('ul'); + const messageElement = validationMessageElement.querySelector('li'); + expect(listElement).to.have.class.that.contains('ul-class'); + expect(messageElement).to.have.class.that.contains('li-class'); + }); + + mockValidate.returns({}); + + await validate(); + + await waitFor(() => { + expect(validationMessageElement).to.have.text.that.does.not.contain( + 'An error' + ); + }); +}); + +Reporter('sets classes for reporter single elements', async () => { + type Data = { + container: { + test: string; + }; + }; + const mockErrors = { container: { test: 'An error' } }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ + single: true, + singleAttributes: { + class: 'span-class', + }, + }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'container.test', + type: 'text', + id: 'test', + }); + const validationMessageElement = document.createElement('div'); + validationMessageElement.setAttribute( + 'data-felte-reporter-dom-for', + 'container.test' + ); + const fieldsetElement = document.createElement('fieldset'); + fieldsetElement.appendChild(inputElement); + fieldsetElement.appendChild(validationMessageElement); + formElement.appendChild(fieldsetElement); + + form(formElement); + + await validate(); + userEvent.click(inputElement); + userEvent.click(formElement); + + await waitFor(() => { + const messageElement = validationMessageElement.querySelector('span'); + expect(messageElement).to.have.class.that.contains('span-class'); + }); + + mockValidate.returns({}); + + await validate(); + + await waitFor(() => { + expect(validationMessageElement).to.have.text.that.does.not.contain( + 'An error' + ); + }); +}); + +Reporter.run(); diff --git a/packages/reporter-dom/tests/reporter.test.ts b/packages/reporter-dom/tests/reporter.test.ts deleted file mode 100644 index 2cff5d57..00000000 --- a/packages/reporter-dom/tests/reporter.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen, waitFor } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import { createForm } from 'felte'; -import { - createDOM, - cleanupDOM, - createInputElement, - createMultipleInputElements, -} from './common'; -import reporter from '../src'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn() })); - -describe('Reporter DOM', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('sets aria-invalid to input and removes if valid', async () => { - const mockErrors = { - test: 'An error', - multiple: new Array(3).fill('An error'), - }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - const multipleInputs = createMultipleInputElements({ - name: 'multiple', - type: 'text', - }); - const multipleMessages = multipleInputs.map((el, index) => { - const mes = document.createElement('div'); - mes.setAttribute('data-felte-reporter-dom-for', el.name); - mes.setAttribute('data-felte-index', String(index)); - return mes; - }); - const validationMessageElement = document.createElement('div'); - validationMessageElement.setAttribute( - 'data-felte-reporter-dom-for', - 'test' - ); - formElement.appendChild(inputElement); - formElement.appendChild(validationMessageElement); - formElement.append(...multipleInputs, ...multipleMessages); - - const { destroy } = form(formElement); - - await validate(); - - await waitFor(() => { - expect(inputElement).toHaveAttribute('aria-invalid'); - multipleInputs.forEach((input) => - expect(input).toHaveAttribute('aria-invalid') - ); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - expect(inputElement).not.toHaveAttribute('aria-invalid'); - multipleInputs.forEach((input) => - expect(input).not.toHaveAttribute('aria-invalid') - ); - }); - - destroy(); - }); - - test('sets error message in list if invalid and removes it if valid', async () => { - type Data = { - container: { - test: string; - multiple: string[]; - }; - }; - const mockErrors = { - container: { - test: 'An error', - multiple: new Array(3).fill('An error'), - }, - }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - const validationMessageElement = document.createElement('div'); - validationMessageElement.setAttribute( - 'data-felte-reporter-dom-for', - 'test' - ); - const multipleInputs = createMultipleInputElements({ - name: 'multiple', - type: 'text', - }); - const multipleMessages = multipleInputs.map((el, index) => { - const mes = document.createElement('div'); - mes.setAttribute('data-felte-reporter-dom-for', el.name); - mes.setAttribute('data-felte-index', String(index)); - return mes; - }); - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'container'; - fieldsetElement.appendChild(inputElement); - fieldsetElement.appendChild(validationMessageElement); - fieldsetElement.append(...multipleInputs, ...multipleMessages); - formElement.appendChild(fieldsetElement); - - form(formElement); - - await validate(); - userEvent.click(inputElement); - multipleInputs.forEach((input) => userEvent.click(input)); - userEvent.click(formElement); - - await waitFor(() => { - expect(validationMessageElement).toContainHTML( - '
  • An error
  • ' - ); - multipleMessages.forEach((mes) => - expect(mes).toContainHTML( - '
  • An error
  • ' - ) - ); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - expect(validationMessageElement).not.toHaveTextContent('An error'); - multipleMessages.forEach((mes) => - expect(mes).not.toContainHTML( - '
  • An error
  • ' - ) - ); - }); - }); - - test('sets error message in span if invalid and removes it if valid', async () => { - const mockErrors = { - test: 'An error', - multiple: new Array(3).fill('An error'), - }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ single: true }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - const validationMessageElement = document.createElement('div'); - validationMessageElement.setAttribute( - 'data-felte-reporter-dom-for', - 'test' - ); - const multipleInputs = createMultipleInputElements({ - name: 'multiple', - type: 'text', - }); - const multipleMessages = multipleInputs.map((el, index) => { - const mes = document.createElement('div'); - mes.setAttribute('data-felte-reporter-dom-for', el.name); - mes.setAttribute('data-felte-index', String(index)); - return mes; - }); - formElement.appendChild(inputElement); - formElement.appendChild(validationMessageElement); - formElement.append(...multipleInputs, ...multipleMessages); - - form(formElement); - - await validate(); - userEvent.click(inputElement); - multipleInputs.forEach((input) => userEvent.click(input)); - userEvent.click(formElement); - - await waitFor(() => { - expect(validationMessageElement).toContainHTML( - 'An error' - ); - multipleMessages.forEach((mes) => - expect(mes).toContainHTML( - 'An error' - ) - ); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - expect(validationMessageElement).not.toHaveTextContent('An error'); - multipleMessages.forEach((mes) => - expect(mes).not.toContainHTML( - 'An error' - ) - ); - }); - }); - - test('focuses first invalid input and shows validation message on submit', async () => { - const mockErrors = { test: 'A test error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - const validationMessageElement = document.createElement('div'); - validationMessageElement.setAttribute( - 'data-felte-reporter-dom-for', - 'test' - ); - formElement.appendChild(inputElement); - formElement.appendChild(validationMessageElement); - - form(formElement); - - formElement.submit(); - - await waitFor(() => { - expect(inputElement).toHaveFocus(); - expect(validationMessageElement).toHaveTextContent('A test error'); - }); - }); - - test('sets classes for reporter list elements', async () => { - type Data = { - container: { - test: string; - }; - }; - const mockErrors = { container: { test: 'An error' } }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ - listItemAttributes: { - class: 'li-class', - }, - listAttributes: { - class: 'ul-class', - }, - }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - const validationMessageElement = document.createElement('div'); - validationMessageElement.setAttribute( - 'data-felte-reporter-dom-for', - 'test' - ); - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'container'; - fieldsetElement.appendChild(inputElement); - fieldsetElement.appendChild(validationMessageElement); - formElement.appendChild(fieldsetElement); - - form(formElement); - - await validate(); - userEvent.click(inputElement); - userEvent.click(formElement); - - await waitFor(() => { - const listElement = validationMessageElement.querySelector('ul'); - const messageElement = validationMessageElement.querySelector('li'); - expect(listElement).toHaveClass('ul-class'); - expect(messageElement).toHaveClass('li-class'); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - expect(validationMessageElement).not.toHaveTextContent('An error'); - }); - }); - - test('sets classes for reporter single elements', async () => { - type Data = { - container: { - test: string; - }; - }; - const mockErrors = { container: { test: 'An error' } }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ - single: true, - singleAttributes: { - class: 'span-class', - }, - }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - const validationMessageElement = document.createElement('div'); - validationMessageElement.setAttribute( - 'data-felte-reporter-dom-for', - 'test' - ); - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'container'; - fieldsetElement.appendChild(inputElement); - fieldsetElement.appendChild(validationMessageElement); - formElement.appendChild(fieldsetElement); - - form(formElement); - - await validate(); - userEvent.click(inputElement); - userEvent.click(formElement); - - await waitFor(() => { - const messageElement = validationMessageElement.querySelector('span'); - expect(messageElement).toHaveClass('span-class'); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - expect(validationMessageElement).not.toHaveTextContent('An error'); - }); - }); -}); diff --git a/packages/reporter-react/.gitignore b/packages/reporter-react/.gitignore new file mode 100644 index 00000000..22e0cc65 --- /dev/null +++ b/packages/reporter-react/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vscode/ +coverage/ \ No newline at end of file diff --git a/packages/reporter-react/.prettierrc b/packages/reporter-react/.prettierrc new file mode 100644 index 00000000..544138be --- /dev/null +++ b/packages/reporter-react/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/packages/reporter-react/CHANGELOG.md b/packages/reporter-react/CHANGELOG.md new file mode 100644 index 00000000..35763ce6 --- /dev/null +++ b/packages/reporter-react/CHANGELOG.md @@ -0,0 +1,329 @@ +# @felte/reporter-solid + +## 1.0.0-next.24 + +### Patch Changes + +- Updated dependencies [7f3d8b8] + - @felte/common@1.0.0-next.23 + +## 1.0.0-next.23 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/common@1.0.0-next.22 + +## 1.0.0-next.22 + +### Patch Changes + +- ed1cbe3: Fix types + +## 1.0.0-next.21 + +### Patch Changes + +- Updated dependencies [fcbdaed] + - @felte/common@1.0.0-next.21 + +## 1.0.0-next.20 + +### Patch Changes + +- Updated dependencies [990034e] + - @felte/common@1.0.0-next.20 + +## 1.0.0-next.19 + +### Minor Changes + +- 02fd56e: Ensure good behaviour with controls created by `useField`/`createField` by only focusing non-hidden inputs + +### Patch Changes + +- Updated dependencies [a174e87] + - @felte/common@1.0.0-next.19 + +## 1.0.0-next.18 + +### Patch Changes + +- Updated dependencies [70cfada] + - @felte/common@1.0.0-next.18 + +## 1.0.0-next.17 + +### Patch Changes + +- Updated dependencies [2e7aad3] + - @felte/common@1.0.0-next.17 + +## 1.0.0-next.16 + +### Patch Changes + +- Updated dependencies [c8c1511] + - @felte/common@1.0.0-next.16 + +## 1.0.0-next.15 + +### Patch Changes + +- Updated dependencies [093482a] + - @felte/common@1.0.0-next.15 + +## 1.0.0-next.14 + +### Patch Changes + +- Updated dependencies [dd52c94] + - @felte/common@1.0.0-next.14 + +## 1.0.0-next.13 + +### Patch Changes + +- Updated dependencies [a45d56c] + - @felte/common@1.0.0-next.13 + +## 1.0.0-next.12 + +### Major Changes + +- 998ed45: BREAKING: Remove `index` prop support + + This was done in order to allow and simplify further improvements of the type system. + + This means this: + + ```html + + ``` + + Should be changed to this: + + ```html + + ``` + +### Patch Changes + +- Updated dependencies [452fe5a] +- Updated dependencies [15d0ce2] + - @felte/common@1.0.0-next.12 + +## 1.0.0-next.11 + +### Patch Changes + +- Updated dependencies [a1dbc28] +- Updated dependencies [ec740a0] +- Updated dependencies [34e0393] +- Updated dependencies [b7ef442] +- Updated dependencies [e1ad8cd] + - @felte/common@1.0.0-next.11 + +## 1.0.0-next.10 + +### Patch Changes + +- Updated dependencies [dc1f21a] +- Updated dependencies [eea3afa] + - @felte/common@1.0.0-next.10 + +## 1.0.0-next.9 + +### Patch Changes + +- Updated dependencies [38fbb49] + - @felte/common@1.0.0-next.9 + +## 1.0.0-next.8 + +### Patch Changes + +- e91a298: Update peer dependencies +- Updated dependencies [c86a82a] + - @felte/common@1.0.0-next.8 + +## 1.0.0-next.7 + +### Patch Changes + +- Updated dependencies [e49c094] + - @felte/common@1.0.0-next.7 + +## 1.0.0-next.6 + +### Patch Changes + +- a2d39e1: Use setTimeout to guarantee DOM has rendered before retrieving values + +## 1.0.0-next.5 + +### Patch Changes + +- Updated dependencies [d1b62bf] + - @felte/common@1.0.0-next.6 + +## 1.0.0-next.4 + +### Patch Changes + +- Updated dependencies [e2f4e18] + - @felte/common@1.0.0-next.5 + +## 1.0.0-next.3 + +### Patch Changes + +- Updated dependencies [8c29b4a] + - @felte/common@1.0.0-next.3 + +## 1.0.0-next.2 + +### Patch Changes + +- Updated dependencies [6f48123] + - @felte/common@1.0.0-next.2 + +## 1.0.0-next.1 + +### Patch Changes + +- Updated dependencies [02a77e3] + - @felte/common@1.0.0-next.1 + +## 1.0.0-next.0 + +### Major Changes + +- 9a48a40: Pass a new property `stage` to extenders to distinguish between setup, mount and update stages +- 2c0f874: Make type of helpers and stores looser when using a transform function + +### Minor Changes + +- 1bc036e: Change responsibility for adding `aria-invalid` to fields to `@felte/core` +- 0c01eab: Add `level` prop to select from which store to obtain validation message. Possible values: `'error' | 'warning'` + +### Patch Changes + +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/common@1.0.0-next.0 + +## 0.1.14 + +### Patch Changes + +- Updated dependencies [6fe19bf] +- Updated dependencies [6fe19bf] +- Updated dependencies [6fe19bf] + - @felte/common@0.6.0 + +## 0.1.13 + +### Patch Changes + +- 4b637d0: Fix ValidationMessage not receiving messages +- Updated dependencies [4b637d0] +- Updated dependencies [5d7b58d] + - @felte/common@0.5.4 + +## 0.1.12 + +### Patch Changes + +- Updated dependencies [e324a45] + - @felte/common@0.5.3 + +## 0.1.11 + +### Patch Changes + +- Updated dependencies [14b3645] + - @felte/common@0.5.2 + +## 0.1.10 + +### Patch Changes + +- aaaf03f: Update build dependency +- Updated dependencies [096f9a5] + - @felte/common@0.5.1 + +## 0.1.9 + +### Patch Changes + +- Updated dependencies [a7e7e35] +- Updated dependencies [2d3b213] +- Updated dependencies [de71f43] + - @felte/common@0.5.0 + +## 0.1.8 + +### Patch Changes + +- Updated dependencies [5bb4a02] + - @felte/common@0.4.10 + +## 0.1.7 + +### Patch Changes + +- Updated dependencies [16ff018] + - @felte/common@0.4.9 + +## 0.1.6 + +### Patch Changes + +- 14bf9d8: Update to use only JS features compatible with Bundlephobia + +## 0.1.5 + +### Patch Changes + +- Updated dependencies [af4b183] +- Updated dependencies [e6034c0] + - @felte/common@0.4.8 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [8049209] + - @felte/common@0.4.7 + +## 0.1.3 + +### Patch Changes + +- cb83dee: Fix index fields + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [fc42f8d] + - @felte/common@0.4.6 + +## 0.1.1 + +### Patch Changes + +- 56ee618: Refactor to use getPath from `@felte/common` +- Updated dependencies [56ee618] + - @felte/common@0.4.5 + +## 0.1.0 + +### Minor Changes + +- 3feb1f8: Add `@felte/reporter-solid` package diff --git a/packages/reporter-react/README.md b/packages/reporter-react/README.md new file mode 100644 index 00000000..4d2bf924 --- /dev/null +++ b/packages/reporter-react/README.md @@ -0,0 +1,63 @@ +# @felte/reporter-react + +[![Bundle size](https://img.shields.io/bundlephobia/min/@felte/reporter-react)](https://bundlephobia.com/result?p=@felte/reporter-react) +[![NPM Version](https://img.shields.io/npm/v/@felte/reporter-react)](https://www.npmjs.com/package/@felte/reporter-react) + +A Felte reporter that uses a custom React component to report errors. + +## Installation + +```sh +# npm +npm i -S @felte/reporter-react + +# yarn +yarn add @felte/reporter-react +``` + +## Usage + +The package exports a `reporter` function and a `ValidationMessage` component. Pass the `reporter` function to the `extend` option of `createForm` and add the `ValidationMessage` component wherever you want your validation messages to be displayed. + +The `ValidationMessage` component needs a `for` prop set with the **name** of the input it corresponds to, the child of `ValidationMessage` is a function that takes the error messages as an argument. This can be either a `string`, an array of `strings`, or `undefined`. + +```tsx +import { reporter, ValidationMessage } from '@felte/reporter-react'; +import { createForm } from '@felte/react'; + +export function Form() { + const { form } = createForm({ + // ... + extend: reporter, + // ... + }, + }) + + return ( + + + + + + {(message) => {message?.[0]}} + + + + + {(message) => {message?.[0]}} + + + + ); +} +``` + +## Warnings + +This reporter can help you display your `warning` messages as well. If you want your `ValidationMessage` component to display the warnings for a field you'll need to set the `level` prop to the value `warning`. By default this prop has a value of `error`. + +```html + + {(message) => {message?.[0]}} + +``` diff --git a/packages/reporter-react/package.json b/packages/reporter-react/package.json new file mode 100644 index 00000000..385e2258 --- /dev/null +++ b/packages/reporter-react/package.json @@ -0,0 +1,71 @@ +{ + "name": "@felte/reporter-react", + "version": "1.0.0-next.24", + "description": "An error reporter for Felte using a React component", + "main": "dist/index.cjs", + "module": "dist/esm/index.js", + "sideEffects": false, + "author": "Pablo Berganza ", + "repository": "github:pablo-abc/felte", + "homepage": "https://github.com/pablo-abc/felte/tree/main/packages/reporter-react", + "keywords": [ + "reactjs", + "react", + "forms", + "validation", + "felte" + ], + "license": "MIT", + "types": "dist/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/index.cjs", + "default": "./dist/esm/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "prebuild": "rimraf ./dist", + "build": "pnpm prebuild && rollup -c", + "dev": "rollup -cw", + "prepublishOnly": "pnpm run build", + "test": "uvu -r tsm -r global-jsdom/register tests -i common", + "test:ci": "nyc -n src pnpm test" + }, + "dependencies": { + "@felte/common": "workspace:*" + }, + "devDependencies": { + "@babel/core": "^7.14.6", + "@babel/preset-env": "^7.16.5", + "@babel/preset-react": "^7.16.5", + "@babel/preset-typescript": "^7.14.5", + "@felte/react": "workspace:*", + "@rollup/plugin-babel": "5.3.0", + "@rollup/plugin-node-resolve": "13.0.5", + "@testing-library/react": "^12.1.2", + "@types/node": "^15.12.4", + "babel-jest": "^26.6.3", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-test-renderer": "^17.0.2", + "rollup": "^2.52.1", + "rollup-plugin-terser": "^7.0.2", + "tsc-watch": "^4.4.0", + "typescript": "~4.3.4" + }, + "peerDependencies": { + "react": "^16.8.0 || >=17.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/reporter-react/rollup.config.js b/packages/reporter-react/rollup.config.js new file mode 100644 index 00000000..fc1ecf79 --- /dev/null +++ b/packages/reporter-react/rollup.config.js @@ -0,0 +1,39 @@ +import typescript from 'rollup-plugin-ts'; +import babel from '@rollup/plugin-babel'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import pkg from './package.json'; + +export default { + input: 'src/index.tsx', + output: [ + { + dir: 'dist/esm', + format: 'es', + exports: 'named', + sourcemap: true, + preserveModules: true, + preserveModulesRoot: 'src', + }, + { + file: pkg.main, + format: 'cjs', + }, + ], + external: ['react', 'react-dom'], + plugins: [ + nodeResolve({ + extensions: ['.js', '.ts', '.tsx'], + }), + typescript({ browserlist: false }), + babel({ + extensions: ['.js', '.ts', '.tsx'], + babelHelpers: 'bundled', + presets: ['@babel/preset-env', '@babel/preset-react'], + plugins: [ + 'babel-plugin-annotate-pure-calls', + 'babel-plugin-dev-expression', + ], + exclude: 'node_modules/**', + }), + ], +}; diff --git a/packages/reporter-react/src/ValidationMessage.tsx b/packages/reporter-react/src/ValidationMessage.tsx new file mode 100644 index 00000000..1eccc3cd --- /dev/null +++ b/packages/reporter-react/src/ValidationMessage.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; +import { _get, getPath, createId } from '@felte/common'; +import { errorStores, warningStores } from './stores'; + +export type ValidationMessageProps = { + for: string; + level?: 'error' | 'warning'; + children: (messages: string[] | null) => ReactNode; +}; + +export function ValidationMessage(props: ValidationMessageProps) { + const level = props.level ?? 'error'; + const [messages, setMessages] = useState(null); + const id = useMemo(() => createId(21), []); + function getFormElement(element: HTMLDivElement) { + return element.closest('form'); + } + + useEffect(() => { + let unsubscriber; + // To guarantee the DOM has rendered we need to setTimeout + setTimeout(() => { + const element = document.getElementById(id) as HTMLDivElement; + const path = props.for; + const errorPath = getPath(element, path); + const formElement = getFormElement(element) as HTMLFormElement; + const reporterId = formElement?.dataset.felteReporterReactId; + if (!reporterId) return; + if (level === 'error') { + const errors = errorStores[reporterId]; + unsubscriber = errors.subscribe(($errors) => { + setMessages(_get($errors, errorPath) as any); + }); + } else { + const warnings = warningStores[reporterId]; + unsubscriber = warnings.subscribe(($warnings) => { + setMessages(_get($warnings, errorPath) as any); + }); + } + }); + return unsubscriber; + }, []); + + return ( + <> +
    + {props.children(messages)} + + ); +} diff --git a/packages/reporter-react/src/index.tsx b/packages/reporter-react/src/index.tsx new file mode 100644 index 00000000..eaf42e76 --- /dev/null +++ b/packages/reporter-react/src/index.tsx @@ -0,0 +1,2 @@ +export * from './reporter'; +export { ValidationMessage } from './ValidationMessage'; diff --git a/packages/reporter-react/src/reporter.ts b/packages/reporter-react/src/reporter.ts new file mode 100644 index 00000000..bf1f6a6d --- /dev/null +++ b/packages/reporter-react/src/reporter.ts @@ -0,0 +1,30 @@ +import type { CurrentForm, Obj } from '@felte/common'; +import type { ExtenderHandler } from '@felte/common'; +import { errorStores, warningStores } from './stores'; +import { createId } from '@felte/common'; + +export function reporter( + currentForm: CurrentForm +): ExtenderHandler { + const config = currentForm.config; + if (currentForm.stage === 'SETUP') { + if (!config.__felteReporterReactId) { + const id = createId(21); + config.__felteReporterReactId = id; + errorStores[id] = currentForm.errors; + warningStores[id] = currentForm.warnings; + } + return {}; + } + if (!currentForm.form.hasAttribute('data-felte-reporter-react-id')) { + currentForm.form.dataset.felteReporterReactId = config.__felteReporterReactId as string; + } + return { + onSubmitError() { + const firstInvalidElement = currentForm?.form?.querySelector( + '[data-felte-validation-message]:not([type="hidden"])' + ) as HTMLElement; + firstInvalidElement?.focus(); + }, + }; +} diff --git a/packages/reporter-react/src/stores.ts b/packages/reporter-react/src/stores.ts new file mode 100644 index 00000000..2b4078a5 --- /dev/null +++ b/packages/reporter-react/src/stores.ts @@ -0,0 +1,8 @@ +import type { PartialWritableErrors } from '@felte/common'; + +export type ErrorStores = { + [index: string]: PartialWritableErrors; +}; + +export const errorStores: ErrorStores = {}; +export const warningStores: ErrorStores = {}; diff --git a/packages/reporter-react/tests/reporter.spec.tsx b/packages/reporter-react/tests/reporter.spec.tsx new file mode 100644 index 00000000..7e408201 --- /dev/null +++ b/packages/reporter-react/tests/reporter.spec.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import 'uvu-expect-dom/extend'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import { useForm } from '@felte/react'; +import userEvent from '@testing-library/user-event'; +import { ValidationMessage, reporter } from '../src'; + +type Data = { + email: string; + password: string; +}; + +type DataErrors = { + email?: string; + password?: string[]; +}; + +type DataWarnings = { + password?: string; +}; + +function getArrayError(message: string, errorValue?: string[]) { + if (errorValue) return [...errorValue, message]; + return [message]; +} + +function Wrapper() { + const { form } = useForm({ + onSubmit: sinon.fake(), + extend: reporter, + validate(values) { + const errors: DataErrors = {}; + if (!values.email) errors.email = 'Must not be empty'; + if (!values.password) + errors.password = getArrayError('Must not be empty', errors.password); + if (values.password?.length < 8) + errors.password = getArrayError( + 'Must be at least 8 chars', + errors.password + ); + return errors; + }, + warn(values) { + const warnings: DataWarnings = {}; + if (values.password && values.password.length < 8) + warnings.password = 'Not secure enough'; + return warnings; + }, + }); + + return ( +
    +
    + + + + {(message) => {message}} + +
    +
    + + + + {(messages) => ( +
      + {Array.isArray(messages) && + messages.map((message) =>
    • {message}
    • )} +
    + )} +
    + + {(message) => {message}} + +
    +
    + ); +} + +const Reporter = suite('reporter'); + +Reporter('reports validation message', async () => { + render(); + + const formElement = screen.getByTestId('test-form') as HTMLFormElement; + const emailInput = screen.getByRole('textbox', { name: 'Email' }); + const passwordInput = screen.getByRole('textbox', { name: 'Password' }); + let emailMessage = screen.getByTestId('email-message'); + let passwordMessage = screen.getByTestId('password-message'); + let passwordWarning = screen.getByTestId('password-warning'); + + expect(emailInput).to.not.be.invalid; + expect(emailMessage).to.be.empty; + expect(passwordMessage).to.be.empty; + + act(() => formElement.submit()); + + await waitFor(() => { + expect(emailInput).to.be.invalid; + expect(passwordInput).to.to.be.invalid; + emailMessage = screen.getByTestId('email-message'); + passwordMessage = screen.getByTestId('password-message'); + passwordWarning = screen.getByTestId('password-warning'); + expect(emailMessage).to.have.text.that.contains('Must not be empty'); + expect(passwordMessage).to.have.text.that.contains('Must not be empty'); + expect(passwordMessage).to.have.text.that.contains( + 'Must be at least 8 chars' + ); + expect(passwordWarning).to.have.text.that.does.not.contain( + 'Not secure enough' + ); + }); + + act(() => { + userEvent.type(emailInput, 'zaphod@beeblebrox.com'); + userEvent.type(passwordInput, '1234'); + }); + + await waitFor(() => { + expect(emailInput).to.not.to.be.invalid; + expect(passwordInput).to.to.be.invalid; + emailMessage = screen.getByTestId('email-message'); + passwordMessage = screen.getByTestId('password-message'); + passwordWarning = screen.getByTestId('password-warning'); + expect(emailMessage).to.be.empty; + expect(passwordMessage).to.have.text.that.does.not.contain( + 'Must not be empty' + ); + expect(passwordMessage).to.have.text.that.contains( + 'Must be at least 8 chars' + ); + expect(passwordWarning).to.have.text.that.contains('Not secure enough'); + }); +}); + +Reporter.run(); diff --git a/packages/reporter-react/tsconfig.json b/packages/reporter-react/tsconfig.json new file mode 100644 index 00000000..82d2f437 --- /dev/null +++ b/packages/reporter-react/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "target": "es2017", + "newLine": "LF", + "moduleResolution": "node", + "strict": true, + "allowSyntheticDefaultImports": true, + "jsx": "react", + "outDir": "./dist", + "module": "esnext" + }, + "include": ["./src", "tests"], + "exclude": ["node_modules/"] +} diff --git a/packages/reporter-react/tsconfig.test.json b/packages/reporter-react/tsconfig.test.json new file mode 100644 index 00000000..8dd7b176 --- /dev/null +++ b/packages/reporter-react/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declaration": true, + "target": "es2017", + "newLine": "LF", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "jsx": "react", + "outDir": "./dist", + "module": "esnext" + }, + "include": ["./src", "./tests"], + "exclude": ["node_modules/"] +} diff --git a/packages/reporter-solid/CHANGELOG.md b/packages/reporter-solid/CHANGELOG.md index 73ff97fd..ea73aa3a 100644 --- a/packages/reporter-solid/CHANGELOG.md +++ b/packages/reporter-solid/CHANGELOG.md @@ -1,5 +1,221 @@ # @felte/reporter-solid +## 1.0.0-next.24 + +### Patch Changes + +- Updated dependencies [7f3d8b8] + - @felte/common@1.0.0-next.23 + +## 1.0.0-next.23 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/common@1.0.0-next.22 + +## 1.0.0-next.22 + +### Patch Changes + +- 50638a8: Apply patch from 0.1.15 + +## 1.0.0-next.21 + +### Patch Changes + +- ed1cbe3: Fix types + +## 1.0.0-next.20 + +### Patch Changes + +- Updated dependencies [fcbdaed] + - @felte/common@1.0.0-next.21 + +## 1.0.0-next.19 + +### Patch Changes + +- Updated dependencies [990034e] + - @felte/common@1.0.0-next.20 + +## 1.0.0-next.18 + +### Minor Changes + +- 02fd56e: Ensure good behaviour with controls created by `useField`/`createField` by only focusing non-hidden inputs + +### Patch Changes + +- Updated dependencies [a174e87] + - @felte/common@1.0.0-next.19 + +## 1.0.0-next.17 + +### Patch Changes + +- Updated dependencies [70cfada] + - @felte/common@1.0.0-next.18 + +## 1.0.0-next.16 + +### Patch Changes + +- Updated dependencies [2e7aad3] + - @felte/common@1.0.0-next.17 + +## 1.0.0-next.15 + +### Patch Changes + +- Updated dependencies [c8c1511] + - @felte/common@1.0.0-next.16 + +## 1.0.0-next.14 + +### Patch Changes + +- Updated dependencies [093482a] + - @felte/common@1.0.0-next.15 + +## 1.0.0-next.13 + +### Patch Changes + +- Updated dependencies [dd52c94] + - @felte/common@1.0.0-next.14 + +## 1.0.0-next.12 + +### Patch Changes + +- Updated dependencies [a45d56c] + - @felte/common@1.0.0-next.13 + +## 1.0.0-next.11 + +### Major Changes + +- 998ed45: BREAKING: Remove `index` prop support + + This was done in order to allow and simplify further improvements of the type system. + + This means this: + + ```html + + ``` + + Should be changed to this: + + ```html + + ``` + +### Patch Changes + +- Updated dependencies [452fe5a] +- Updated dependencies [15d0ce2] + - @felte/common@1.0.0-next.12 + +## 1.0.0-next.10 + +### Patch Changes + +- Updated dependencies [a1dbc28] +- Updated dependencies [ec740a0] +- Updated dependencies [34e0393] +- Updated dependencies [b7ef442] +- Updated dependencies [e1ad8cd] + - @felte/common@1.0.0-next.11 + +## 1.0.0-next.9 + +### Patch Changes + +- Updated dependencies [dc1f21a] +- Updated dependencies [eea3afa] + - @felte/common@1.0.0-next.10 + +## 1.0.0-next.8 + +### Patch Changes + +- Updated dependencies [38fbb49] + - @felte/common@1.0.0-next.9 + +## 1.0.0-next.7 + +### Patch Changes + +- Updated dependencies [c86a82a] + - @felte/common@1.0.0-next.8 + +## 1.0.0-next.6 + +### Patch Changes + +- Updated dependencies [e49c094] + - @felte/common@1.0.0-next.7 + +## 1.0.0-next.5 + +### Patch Changes + +- Updated dependencies [d1b62bf] + - @felte/common@1.0.0-next.6 + +## 1.0.0-next.4 + +### Patch Changes + +- Updated dependencies [e2f4e18] + - @felte/common@1.0.0-next.5 + +## 1.0.0-next.3 + +### Patch Changes + +- Updated dependencies [8c29b4a] + - @felte/common@1.0.0-next.3 + +## 1.0.0-next.2 + +### Patch Changes + +- Updated dependencies [6f48123] + - @felte/common@1.0.0-next.2 + +## 1.0.0-next.1 + +### Patch Changes + +- Updated dependencies [02a77e3] + - @felte/common@1.0.0-next.1 + +## 1.0.0-next.0 + +### Major Changes + +- 9a48a40: Pass a new property `stage` to extenders to distinguish between setup, mount and update stages +- 2c0f874: Make type of helpers and stores looser when using a transform function + +### Minor Changes + +- 1bc036e: Change responsibility for adding `aria-invalid` to fields to `@felte/core` +- 0c01eab: Add `level` prop to select from which store to obtain validation message. Possible values: `'error' | 'warning'` + +### Patch Changes + +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/common@1.0.0-next.0 + ## 0.1.15 ### Patch Changes diff --git a/packages/reporter-solid/README.md b/packages/reporter-solid/README.md index 3702fbc9..1d68e065 100644 --- a/packages/reporter-solid/README.md +++ b/packages/reporter-solid/README.md @@ -37,16 +37,25 @@ export function Form() {
    - - - {(message) => {message}} + + {(message) => {message?.[0]}} - {(message) => {message}} + {(message) => {message?.[0]}}
    ); } ``` + +## Warnings + +This reporter can help you display your `warning` messages as well. If you want your `ValidationMessage` component to display the warnings for a field you'll need to set the `level` prop to the value `warning`. By default this prop has a value of `error`. + +```html + + {(message) => {message?.[0]}} + +``` diff --git a/packages/reporter-solid/jest.config.js b/packages/reporter-solid/jest.config.js deleted file mode 100644 index af46740b..00000000 --- a/packages/reporter-solid/jest.config.js +++ /dev/null @@ -1,19 +0,0 @@ -export default { - preset: 'ts-jest', - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], - setupFilesAfterEnv: ['./tests/setupTests.ts'], - globals: { - 'ts-jest': { - tsconfig: 'tsconfig.json', - babelConfig: { - presets: ['babel-preset-solid', '@babel/preset-env'], - }, - }, - }, - moduleNameMapper: { - 'solid-js/store': '/node_modules/solid-js/store/dist/store.cjs', - 'solid-js/web': '/node_modules/solid-js/web/dist/web.cjs', - 'solid-js': '/node_modules/solid-js/dist/solid.cjs', - }, -}; diff --git a/packages/reporter-solid/package-lock.json b/packages/reporter-solid/package-lock.json deleted file mode 100644 index efbedee2..00000000 --- a/packages/reporter-solid/package-lock.json +++ /dev/null @@ -1,1844 +0,0 @@ -{ - "name": "solid-app-router", - "version": "0.0.50", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "version": "0.0.50", - "license": "MIT", - "devDependencies": { - "@babel/core": "^7.14.6", - "@babel/preset-typescript": "^7.14.5", - "@rollup/plugin-babel": "5.3.0", - "@rollup/plugin-node-resolve": "13.0.0", - "@types/node": "^15.12.4", - "babel-preset-solid": "^1.0.0", - "rollup": "^2.52.1", - "rollup-plugin-terser": "^7.0.2", - "solid-js": "^1.0.0", - "typescript": "~4.3.4" - }, - "peerDependencies": { - "solid-js": "^1.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", - "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.5.tgz", - "integrity": "sha512-kixrYn4JwfAVPa0f2yfzc2AWti6WRRyO3XjWW5PJAvtE11qhSayrrcrEnee05KAtNaPC+EwehE8Qt1UedEVB8w==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.6.tgz", - "integrity": "sha512-gJnOEWSqTk96qG5BoIrl5bVtc23DCycmIePPYnamY9RboYdI4nFy5vAQMSl81O5K/W0sLDWfGysnOECC+KUUCA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.14.5", - "@babel/generator": "^7.14.5", - "@babel/helper-compilation-targets": "^7.14.5", - "@babel/helper-module-transforms": "^7.14.5", - "@babel/helpers": "^7.14.6", - "@babel/parser": "^7.14.6", - "@babel/template": "^7.14.5", - "@babel/traverse": "^7.14.5", - "@babel/types": "^7.14.5", - "convert-source-map": "^1.7.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.1.2", - "semver": "^6.3.0", - "source-map": "^0.5.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.5.tgz", - "integrity": "sha512-y3rlP+/G25OIX3mYKKIOlQRcqj7YgrvHxOLbVmyLJ9bPmi5ttvUmpydVjcFjZphOktWuA7ovbx91ECloWTfjIA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.14.5", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz", - "integrity": "sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.5.tgz", - "integrity": "sha512-v+QtZqXEiOnpO6EYvlImB6zCD2Lel06RzOPzmkz/D/XgQiUu3C/Jb1LOqSt/AIA34TYi/Q+KlT8vTQrgdxkbLw==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.14.5", - "@babel/helper-validator-option": "^7.14.5", - "browserslist": "^4.16.6", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.14.6.tgz", - "integrity": "sha512-Z6gsfGofTxH/+LQXqYEK45kxmcensbzmk/oi8DmaQytlQCgqNZt9XQF8iqlI/SeXWVjaMNxvYvzaYw+kh42mDg==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.14.5", - "@babel/helper-function-name": "^7.14.5", - "@babel/helper-member-expression-to-functions": "^7.14.5", - "@babel/helper-optimise-call-expression": "^7.14.5", - "@babel/helper-replace-supers": "^7.14.5", - "@babel/helper-split-export-declaration": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz", - "integrity": "sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==", - "dev": true, - "dependencies": { - "@babel/helper-get-function-arity": "^7.14.5", - "@babel/template": "^7.14.5", - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-get-function-arity": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz", - "integrity": "sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz", - "integrity": "sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==", - "dev": true, - "dependencies": { - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.14.5.tgz", - "integrity": "sha512-UxUeEYPrqH1Q/k0yRku1JE7dyfyehNwT6SVkMHvYvPDv4+uu627VXBckVj891BO8ruKBkiDoGnZf4qPDD8abDQ==", - "dev": true, - "dependencies": { - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz", - "integrity": "sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==", - "dev": true, - "dependencies": { - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.14.5.tgz", - "integrity": "sha512-iXpX4KW8LVODuAieD7MzhNjmM6dzYY5tfRqT+R9HDXWl0jPn/djKmA+G9s/2C2T9zggw5tK1QNqZ70USfedOwA==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.14.5", - "@babel/helper-replace-supers": "^7.14.5", - "@babel/helper-simple-access": "^7.14.5", - "@babel/helper-split-export-declaration": "^7.14.5", - "@babel/helper-validator-identifier": "^7.14.5", - "@babel/template": "^7.14.5", - "@babel/traverse": "^7.14.5", - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz", - "integrity": "sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", - "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.14.5.tgz", - "integrity": "sha512-3i1Qe9/8x/hCHINujn+iuHy+mMRLoc77b2nI9TB0zjH1hvn9qGlXjWlggdwUcju36PkPCy/lpM7LLUdcTyH4Ow==", - "dev": true, - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.14.5", - "@babel/helper-optimise-call-expression": "^7.14.5", - "@babel/traverse": "^7.14.5", - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.14.5.tgz", - "integrity": "sha512-nfBN9xvmCt6nrMZjfhkl7i0oTV3yxR4/FztsbOASyTvVcoYd0TRHh7eMLdlEcCqobydC0LAF3LtC92Iwxo0wyw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz", - "integrity": "sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz", - "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz", - "integrity": "sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.14.6.tgz", - "integrity": "sha512-yesp1ENQBiLI+iYHSJdoZKUtRpfTlL1grDIX9NRlAVppljLw/4tTyYupIB7uIYmC3stW/imAv8EqaKaS/ibmeA==", - "dev": true, - "dependencies": { - "@babel/template": "^7.14.5", - "@babel/traverse": "^7.14.5", - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz", - "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.14.5", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.6.tgz", - "integrity": "sha512-oG0ej7efjEXxb4UgE+klVx+3j4MVo+A2vCzm7OUN4CLo6WhQ+vSOD2yJ8m7B+DghObxtLxt3EfgMWpq+AsWehQ==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.14.5.tgz", - "integrity": "sha512-ohuFIsOMXJnbOMRfX7/w7LocdR6R7whhuRD4ax8IipLcLPlZGJKkBxgHp++U4N/vKyU16/YDQr2f5seajD3jIw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.14.5.tgz", - "integrity": "sha512-u6OXzDaIXjEstBRRoBCQ/uKQKlbuaeE5in0RvWdA4pN6AhqxTIwUsnHPU1CFZA/amYObMsuWhYfRl3Ch90HD0Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.14.6.tgz", - "integrity": "sha512-XlTdBq7Awr4FYIzqhmYY80WN0V0azF74DMPyFqVHBvf81ZUgc4X7ZOpx6O8eLDK6iM5cCQzeyJw0ynTaefixRA==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.14.6", - "@babel/helper-plugin-utils": "^7.14.5", - "@babel/plugin-syntax-typescript": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.14.5.tgz", - "integrity": "sha512-u4zO6CdbRKbS9TypMqrlGH7sd2TAJppZwn3c/ZRLeO/wGsbddxgbPDUZVNrie3JWYLQ9vpineKlsrWFvO6Pwkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5", - "@babel/helper-validator-option": "^7.14.5", - "@babel/plugin-transform-typescript": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz", - "integrity": "sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.14.5", - "@babel/parser": "^7.14.5", - "@babel/types": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.5.tgz", - "integrity": "sha512-G3BiS15vevepdmFqmUc9X+64y0viZYygubAMO8SvBmKARuF6CPSZtH4Ng9vi/lrWlZFGe3FWdXNy835akH8Glg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.14.5", - "@babel/generator": "^7.14.5", - "@babel/helper-function-name": "^7.14.5", - "@babel/helper-hoist-variables": "^7.14.5", - "@babel/helper-split-export-declaration": "^7.14.5", - "@babel/parser": "^7.14.5", - "@babel/types": "^7.14.5", - "debug": "^4.1.0", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.5.tgz", - "integrity": "sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.14.5", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@rollup/plugin-babel": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz", - "integrity": "sha512-9uIC8HZOnVLrLHxayq/PTzw+uS25E14KPUBh5ktF+18Mjo5yK0ToMMx6epY0uEgkjwJw0aBW4x2horYXh8juWw==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "@types/babel__core": "^7.1.9", - "rollup": "^1.20.0||^2.0.0" - }, - "peerDependenciesMeta": { - "@types/babel__core": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.0.0.tgz", - "integrity": "sha512-41X411HJ3oikIDivT5OKe9EZ6ud6DXudtfNrGbC4nniaxx2esiWjkLOzgnZsWq1IM8YIeL2rzRGLZLBjlhnZtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "@types/resolve": "1.17.1", - "builtin-modules": "^3.1.0", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "rollup": "^2.42.0" - } - }, - "node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dev": true, - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "dev": true - }, - "node_modules/@types/node": { - "version": "15.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz", - "integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==", - "dev": true - }, - "node_modules/@types/resolve": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", - "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/babel-plugin-jsx-dom-expressions": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.29.0.tgz", - "integrity": "sha512-p0uI9OpgRAGvym3LLzIMMaH+ZpW0khi0JwbnZ7cARE9nOBw1dVjrtGNGt8KyYzfIg0o9nkaYZ+Jaja8SgMy3/A==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.10.4", - "@babel/plugin-syntax-jsx": "^7.10.4", - "@babel/types": "^7.11.5" - } - }, - "node_modules/babel-preset-solid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.0.0.tgz", - "integrity": "sha512-fXWDS+5Mh2PgjFvIEpfztMHORd1MutUsnpLYEl40HvwbdiM8zYaLxkHlSdAoRJ8B8HuWvEjBt8y9jzChq+SRAg==", - "dev": true, - "dependencies": { - "babel-plugin-jsx-dom-expressions": "^0.29.0" - } - }, - "node_modules/browserslist": { - "version": "4.16.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz", - "integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==", - "dev": true, - "dependencies": { - "caniuse-lite": "^1.0.30001219", - "colorette": "^1.2.2", - "electron-to-chromium": "^1.3.723", - "escalade": "^3.1.1", - "node-releases": "^1.1.71" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } - }, - "node_modules/buffer-from": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", - "dev": true - }, - "node_modules/builtin-modules": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz", - "integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001238", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001238.tgz", - "integrity": "sha512-bZGam2MxEt7YNsa2VwshqWQMwrYs5tR5WZQRYSuFxsBQunWjBuXhN4cS9nV5FFb1Z9y+DoQcQ0COyQbv6A+CKw==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } - }, - "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "node_modules/colorette": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", - "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", - "dev": true - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "node_modules/convert-source-map": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", - "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.1" - } - }, - "node_modules/debug": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", - "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.3.752", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.752.tgz", - "integrity": "sha512-2Tg+7jSl3oPxgsBsWKh5H83QazTkmWG/cnNwJplmyZc7KcN61+I10oUgaXSVk/NwfvN3BdkKDR4FYuRBQQ2v0A==", - "dev": true - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/is-core-module": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.1.0.tgz", - "integrity": "sha512-YcV7BgVMRFRua2FqQzKtTDMz8iCuLEyGKjr70q8Zm1yy2qKcurbFEd79PAdHV77oL3NrAaOVQIbMmiHQCHB7ZA==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", - "dev": true - }, - "node_modules/jest-worker": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", - "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", - "dev": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json5": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", - "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", - "dev": true, - "dependencies": { - "minimist": "^1.2.5" - }, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/node-releases": { - "version": "1.1.73", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz", - "integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==", - "dev": true - }, - "node_modules/path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", - "dev": true, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/resolve": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", - "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", - "dev": true, - "dependencies": { - "is-core-module": "^2.1.0", - "path-parse": "^1.0.6" - } - }, - "node_modules/rollup": { - "version": "2.52.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.52.1.tgz", - "integrity": "sha512-/SPqz8UGnp4P1hq6wc9gdTqA2bXQXGx13TtoL03GBm6qGRI6Hm3p4Io7GeiHNLl0BsQAne1JNYY+q/apcY933w==", - "dev": true, - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup-plugin-terser": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", - "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "jest-worker": "^26.2.1", - "serialize-javascript": "^4.0.0", - "terser": "^5.0.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/solid-js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.0.0.tgz", - "integrity": "sha512-5huTDVyMqZSjg5Sa4mwl15feK4il1cE68n4weL5NYGC8lX2wiDlcHhBdSB8trKgaJ50Q9/pwtLrQngFaDC+5Tw==", - "dev": true - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", - "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/terser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.5.0.tgz", - "integrity": "sha512-eopt1Gf7/AQyPhpygdKePTzaet31TvQxXvrf7xYUvD/d8qkCJm4SKPDzu+GHK5ZaYTn8rvttfqaZc3swK21e5g==", - "dev": true, - "dependencies": { - "commander": "^2.20.0", - "source-map": "~0.7.2", - "source-map-support": "~0.5.19" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/typescript": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.4.tgz", - "integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - } - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", - "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==", - "dev": true, - "requires": { - "@babel/highlight": "^7.14.5" - } - }, - "@babel/compat-data": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.5.tgz", - "integrity": "sha512-kixrYn4JwfAVPa0f2yfzc2AWti6WRRyO3XjWW5PJAvtE11qhSayrrcrEnee05KAtNaPC+EwehE8Qt1UedEVB8w==", - "dev": true - }, - "@babel/core": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.6.tgz", - "integrity": "sha512-gJnOEWSqTk96qG5BoIrl5bVtc23DCycmIePPYnamY9RboYdI4nFy5vAQMSl81O5K/W0sLDWfGysnOECC+KUUCA==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.14.5", - "@babel/generator": "^7.14.5", - "@babel/helper-compilation-targets": "^7.14.5", - "@babel/helper-module-transforms": "^7.14.5", - "@babel/helpers": "^7.14.6", - "@babel/parser": "^7.14.6", - "@babel/template": "^7.14.5", - "@babel/traverse": "^7.14.5", - "@babel/types": "^7.14.5", - "convert-source-map": "^1.7.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.1.2", - "semver": "^6.3.0", - "source-map": "^0.5.0" - } - }, - "@babel/generator": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.5.tgz", - "integrity": "sha512-y3rlP+/G25OIX3mYKKIOlQRcqj7YgrvHxOLbVmyLJ9bPmi5ttvUmpydVjcFjZphOktWuA7ovbx91ECloWTfjIA==", - "dev": true, - "requires": { - "@babel/types": "^7.14.5", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" - } - }, - "@babel/helper-annotate-as-pure": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz", - "integrity": "sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==", - "dev": true, - "requires": { - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-compilation-targets": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.5.tgz", - "integrity": "sha512-v+QtZqXEiOnpO6EYvlImB6zCD2Lel06RzOPzmkz/D/XgQiUu3C/Jb1LOqSt/AIA34TYi/Q+KlT8vTQrgdxkbLw==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.14.5", - "@babel/helper-validator-option": "^7.14.5", - "browserslist": "^4.16.6", - "semver": "^6.3.0" - } - }, - "@babel/helper-create-class-features-plugin": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.14.6.tgz", - "integrity": "sha512-Z6gsfGofTxH/+LQXqYEK45kxmcensbzmk/oi8DmaQytlQCgqNZt9XQF8iqlI/SeXWVjaMNxvYvzaYw+kh42mDg==", - "dev": true, - "requires": { - "@babel/helper-annotate-as-pure": "^7.14.5", - "@babel/helper-function-name": "^7.14.5", - "@babel/helper-member-expression-to-functions": "^7.14.5", - "@babel/helper-optimise-call-expression": "^7.14.5", - "@babel/helper-replace-supers": "^7.14.5", - "@babel/helper-split-export-declaration": "^7.14.5" - } - }, - "@babel/helper-function-name": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz", - "integrity": "sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==", - "dev": true, - "requires": { - "@babel/helper-get-function-arity": "^7.14.5", - "@babel/template": "^7.14.5", - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-get-function-arity": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz", - "integrity": "sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==", - "dev": true, - "requires": { - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-hoist-variables": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz", - "integrity": "sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==", - "dev": true, - "requires": { - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-member-expression-to-functions": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.14.5.tgz", - "integrity": "sha512-UxUeEYPrqH1Q/k0yRku1JE7dyfyehNwT6SVkMHvYvPDv4+uu627VXBckVj891BO8ruKBkiDoGnZf4qPDD8abDQ==", - "dev": true, - "requires": { - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-module-imports": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz", - "integrity": "sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==", - "dev": true, - "requires": { - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-module-transforms": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.14.5.tgz", - "integrity": "sha512-iXpX4KW8LVODuAieD7MzhNjmM6dzYY5tfRqT+R9HDXWl0jPn/djKmA+G9s/2C2T9zggw5tK1QNqZ70USfedOwA==", - "dev": true, - "requires": { - "@babel/helper-module-imports": "^7.14.5", - "@babel/helper-replace-supers": "^7.14.5", - "@babel/helper-simple-access": "^7.14.5", - "@babel/helper-split-export-declaration": "^7.14.5", - "@babel/helper-validator-identifier": "^7.14.5", - "@babel/template": "^7.14.5", - "@babel/traverse": "^7.14.5", - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-optimise-call-expression": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz", - "integrity": "sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==", - "dev": true, - "requires": { - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-plugin-utils": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", - "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", - "dev": true - }, - "@babel/helper-replace-supers": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.14.5.tgz", - "integrity": "sha512-3i1Qe9/8x/hCHINujn+iuHy+mMRLoc77b2nI9TB0zjH1hvn9qGlXjWlggdwUcju36PkPCy/lpM7LLUdcTyH4Ow==", - "dev": true, - "requires": { - "@babel/helper-member-expression-to-functions": "^7.14.5", - "@babel/helper-optimise-call-expression": "^7.14.5", - "@babel/traverse": "^7.14.5", - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-simple-access": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.14.5.tgz", - "integrity": "sha512-nfBN9xvmCt6nrMZjfhkl7i0oTV3yxR4/FztsbOASyTvVcoYd0TRHh7eMLdlEcCqobydC0LAF3LtC92Iwxo0wyw==", - "dev": true, - "requires": { - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-split-export-declaration": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz", - "integrity": "sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==", - "dev": true, - "requires": { - "@babel/types": "^7.14.5" - } - }, - "@babel/helper-validator-identifier": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz", - "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==", - "dev": true - }, - "@babel/helper-validator-option": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz", - "integrity": "sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==", - "dev": true - }, - "@babel/helpers": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.14.6.tgz", - "integrity": "sha512-yesp1ENQBiLI+iYHSJdoZKUtRpfTlL1grDIX9NRlAVppljLw/4tTyYupIB7uIYmC3stW/imAv8EqaKaS/ibmeA==", - "dev": true, - "requires": { - "@babel/template": "^7.14.5", - "@babel/traverse": "^7.14.5", - "@babel/types": "^7.14.5" - } - }, - "@babel/highlight": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz", - "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.14.5", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - } - }, - "@babel/parser": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.6.tgz", - "integrity": "sha512-oG0ej7efjEXxb4UgE+klVx+3j4MVo+A2vCzm7OUN4CLo6WhQ+vSOD2yJ8m7B+DghObxtLxt3EfgMWpq+AsWehQ==", - "dev": true - }, - "@babel/plugin-syntax-jsx": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.14.5.tgz", - "integrity": "sha512-ohuFIsOMXJnbOMRfX7/w7LocdR6R7whhuRD4ax8IipLcLPlZGJKkBxgHp++U4N/vKyU16/YDQr2f5seajD3jIw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.14.5" - } - }, - "@babel/plugin-syntax-typescript": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.14.5.tgz", - "integrity": "sha512-u6OXzDaIXjEstBRRoBCQ/uKQKlbuaeE5in0RvWdA4pN6AhqxTIwUsnHPU1CFZA/amYObMsuWhYfRl3Ch90HD0Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.14.5" - } - }, - "@babel/plugin-transform-typescript": { - "version": "7.14.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.14.6.tgz", - "integrity": "sha512-XlTdBq7Awr4FYIzqhmYY80WN0V0azF74DMPyFqVHBvf81ZUgc4X7ZOpx6O8eLDK6iM5cCQzeyJw0ynTaefixRA==", - "dev": true, - "requires": { - "@babel/helper-create-class-features-plugin": "^7.14.6", - "@babel/helper-plugin-utils": "^7.14.5", - "@babel/plugin-syntax-typescript": "^7.14.5" - } - }, - "@babel/preset-typescript": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.14.5.tgz", - "integrity": "sha512-u4zO6CdbRKbS9TypMqrlGH7sd2TAJppZwn3c/ZRLeO/wGsbddxgbPDUZVNrie3JWYLQ9vpineKlsrWFvO6Pwkw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.14.5", - "@babel/helper-validator-option": "^7.14.5", - "@babel/plugin-transform-typescript": "^7.14.5" - } - }, - "@babel/template": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz", - "integrity": "sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.14.5", - "@babel/parser": "^7.14.5", - "@babel/types": "^7.14.5" - } - }, - "@babel/traverse": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.5.tgz", - "integrity": "sha512-G3BiS15vevepdmFqmUc9X+64y0viZYygubAMO8SvBmKARuF6CPSZtH4Ng9vi/lrWlZFGe3FWdXNy835akH8Glg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.14.5", - "@babel/generator": "^7.14.5", - "@babel/helper-function-name": "^7.14.5", - "@babel/helper-hoist-variables": "^7.14.5", - "@babel/helper-split-export-declaration": "^7.14.5", - "@babel/parser": "^7.14.5", - "@babel/types": "^7.14.5", - "debug": "^4.1.0", - "globals": "^11.1.0" - } - }, - "@babel/types": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.5.tgz", - "integrity": "sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.14.5", - "to-fast-properties": "^2.0.0" - } - }, - "@rollup/plugin-babel": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz", - "integrity": "sha512-9uIC8HZOnVLrLHxayq/PTzw+uS25E14KPUBh5ktF+18Mjo5yK0ToMMx6epY0uEgkjwJw0aBW4x2horYXh8juWw==", - "dev": true, - "requires": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" - } - }, - "@rollup/plugin-node-resolve": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.0.0.tgz", - "integrity": "sha512-41X411HJ3oikIDivT5OKe9EZ6ud6DXudtfNrGbC4nniaxx2esiWjkLOzgnZsWq1IM8YIeL2rzRGLZLBjlhnZtQ==", - "dev": true, - "requires": { - "@rollup/pluginutils": "^3.1.0", - "@types/resolve": "1.17.1", - "builtin-modules": "^3.1.0", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.19.0" - } - }, - "@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dev": true, - "requires": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - } - }, - "@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "dev": true - }, - "@types/node": { - "version": "15.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz", - "integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==", - "dev": true - }, - "@types/resolve": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", - "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "babel-plugin-jsx-dom-expressions": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.29.0.tgz", - "integrity": "sha512-p0uI9OpgRAGvym3LLzIMMaH+ZpW0khi0JwbnZ7cARE9nOBw1dVjrtGNGt8KyYzfIg0o9nkaYZ+Jaja8SgMy3/A==", - "dev": true, - "requires": { - "@babel/helper-module-imports": "^7.10.4", - "@babel/plugin-syntax-jsx": "^7.10.4", - "@babel/types": "^7.11.5" - } - }, - "babel-preset-solid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.0.0.tgz", - "integrity": "sha512-fXWDS+5Mh2PgjFvIEpfztMHORd1MutUsnpLYEl40HvwbdiM8zYaLxkHlSdAoRJ8B8HuWvEjBt8y9jzChq+SRAg==", - "dev": true, - "requires": { - "babel-plugin-jsx-dom-expressions": "^0.29.0" - } - }, - "browserslist": { - "version": "4.16.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz", - "integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==", - "dev": true, - "requires": { - "caniuse-lite": "^1.0.30001219", - "colorette": "^1.2.2", - "electron-to-chromium": "^1.3.723", - "escalade": "^3.1.1", - "node-releases": "^1.1.71" - } - }, - "buffer-from": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", - "dev": true - }, - "builtin-modules": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz", - "integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==", - "dev": true - }, - "caniuse-lite": { - "version": "1.0.30001238", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001238.tgz", - "integrity": "sha512-bZGam2MxEt7YNsa2VwshqWQMwrYs5tR5WZQRYSuFxsBQunWjBuXhN4cS9nV5FFb1Z9y+DoQcQ0COyQbv6A+CKw==", - "dev": true - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "colorette": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", - "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", - "dev": true - }, - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "convert-source-map": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", - "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.1" - } - }, - "debug": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", - "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true - }, - "electron-to-chromium": { - "version": "1.3.752", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.752.tgz", - "integrity": "sha512-2Tg+7jSl3oPxgsBsWKh5H83QazTkmWG/cnNwJplmyZc7KcN61+I10oUgaXSVk/NwfvN3BdkKDR4FYuRBQQ2v0A==", - "dev": true - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "dev": true - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true - }, - "globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "is-core-module": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.1.0.tgz", - "integrity": "sha512-YcV7BgVMRFRua2FqQzKtTDMz8iCuLEyGKjr70q8Zm1yy2qKcurbFEd79PAdHV77oL3NrAaOVQIbMmiHQCHB7ZA==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", - "dev": true - }, - "jest-worker": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", - "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", - "dev": true, - "requires": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" - }, - "dependencies": { - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true - }, - "json5": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", - "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", - "dev": true, - "requires": { - "minimist": "^1.2.5" - } - }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node-releases": { - "version": "1.1.73", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz", - "integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==", - "dev": true - }, - "path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", - "dev": true - }, - "picomatch": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", - "dev": true - }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, - "resolve": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", - "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", - "dev": true, - "requires": { - "is-core-module": "^2.1.0", - "path-parse": "^1.0.6" - } - }, - "rollup": { - "version": "2.52.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.52.1.tgz", - "integrity": "sha512-/SPqz8UGnp4P1hq6wc9gdTqA2bXQXGx13TtoL03GBm6qGRI6Hm3p4Io7GeiHNLl0BsQAne1JNYY+q/apcY933w==", - "dev": true, - "requires": { - "fsevents": "~2.3.2" - } - }, - "rollup-plugin-terser": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", - "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.10.4", - "jest-worker": "^26.2.1", - "serialize-javascript": "^4.0.0", - "terser": "^5.0.0" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - }, - "serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, - "solid-js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.0.0.tgz", - "integrity": "sha512-5huTDVyMqZSjg5Sa4mwl15feK4il1cE68n4weL5NYGC8lX2wiDlcHhBdSB8trKgaJ50Q9/pwtLrQngFaDC+5Tw==", - "dev": true - }, - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true - }, - "source-map-support": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", - "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - }, - "terser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.5.0.tgz", - "integrity": "sha512-eopt1Gf7/AQyPhpygdKePTzaet31TvQxXvrf7xYUvD/d8qkCJm4SKPDzu+GHK5ZaYTn8rvttfqaZc3swK21e5g==", - "dev": true, - "requires": { - "commander": "^2.20.0", - "source-map": "~0.7.2", - "source-map-support": "~0.5.19" - }, - "dependencies": { - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "dev": true - } - } - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", - "dev": true - }, - "typescript": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.4.tgz", - "integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew==", - "dev": true - } - } -} diff --git a/packages/reporter-solid/package.json b/packages/reporter-solid/package.json index 535227d4..f329247c 100644 --- a/packages/reporter-solid/package.json +++ b/packages/reporter-solid/package.json @@ -1,6 +1,6 @@ { "name": "@felte/reporter-solid", - "version": "0.1.15", + "version": "1.0.0-next.24", "description": "An error reporter for Felte using a Solid component", "main": "dist/index.js", "sideEffects": false, @@ -16,6 +16,9 @@ ], "license": "MIT", "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "types": "dist/index.d.ts", "exports": { ".": { @@ -28,36 +31,57 @@ "dist" ], "scripts": { - "build": "rollup -c", + "prebuild": "rimraf ./dist", + "build": "pnpm prebuild && NODE_ENV=production rollup -c", "dev": "rollup -cw", "prepublishOnly": "pnpm run build", - "test": "jest", - "test:ci": "jest --ci --coverage" + "test": "node --conditions browser node_modules/uvu/bin.js -r solid-register tests -i common -i setup", + "test:ci": "nyc pnpm test" }, "dependencies": { "@felte/common": "workspace:*" }, "devDependencies": { - "@babel/core": "^7.14.6", + "@babel/core": "^7.17.0", + "@babel/preset-env": "^7.16.5", "@babel/preset-typescript": "^7.14.5", + "@babel/register": "^7.17.0", "@felte/solid": "workspace:*", "@rollup/plugin-babel": "5.3.0", "@rollup/plugin-node-resolve": "13.0.5", - "@testing-library/jest-dom": "^5.11.9", "@types/node": "^15.12.4", - "babel-preset-solid": "^1.0.0", + "babel-preset-solid": "^1.3.5", + "nyc": "^15.1.0", + "regenerator-runtime": "^0.13.9", "rollup": "^2.52.1", - "jest": "^26.6.3", "rollup-plugin-terser": "^7.0.2", "solid-js": "^1.2.0", + "solid-register": "^0.1.5", "solid-testing-library": "^0.2.1", "tsc-watch": "^4.4.0", - "typescript": "~4.3.4" + "typescript": "~4.3.4", + "uvu": "^0.5.3" }, "peerDependencies": { "solid-js": "^1.2.0" }, "publishConfig": { "access": "public" + }, + "solid-register": { + "compile": { + "solid": { + "engine": "babel", + "extensions": [ + ".js", + ".jsx", + ".ts", + ".tsx" + ] + } + }, + "aliases": { + "solid": "browser" + } } } diff --git a/packages/reporter-solid/rollup.config.js b/packages/reporter-solid/rollup.config.js index cd865a13..cd4a5de0 100644 --- a/packages/reporter-solid/rollup.config.js +++ b/packages/reporter-solid/rollup.config.js @@ -5,6 +5,8 @@ import jsxPlugin from '@solid-reach/rollup-plugin-jsx'; import jsx from 'acorn-jsx'; import bundleSize from 'rollup-plugin-bundle-size'; +const prod = process.env.NODE_ENV === 'production'; + export default [ { input: 'src/index.tsx', @@ -12,6 +14,7 @@ export default [ { file: 'dist/index.jsx', format: 'es', + sourcemap: prod, }, ], external: ['solid-js', 'solid-js/web', 'solid-js/store'], @@ -20,10 +23,11 @@ export default [ nodeResolve({ extensions: ['.js', '.ts', '.tsx'], }), - typescript(), + typescript({ browserlist: false }), babel({ extensions: ['.js', '.ts', '.tsx'], babelHelpers: 'bundled', + presets: [], plugins: [ '@babel/plugin-syntax-jsx', 'babel-plugin-annotate-pure-calls', @@ -40,6 +44,7 @@ export default [ { file: 'dist/index.js', format: 'es', + sourcemap: prod, }, ], external: ['solid-js', 'solid-js/web', 'solid-js/store'], @@ -47,7 +52,7 @@ export default [ nodeResolve({ extensions: ['.js', '.ts', '.tsx'], }), - typescript(), + typescript({ browserlist: false }), babel({ extensions: ['.js', '.ts', '.tsx'], babelHelpers: 'bundled', diff --git a/packages/reporter-solid/src/ValidationMessage.tsx b/packages/reporter-solid/src/ValidationMessage.tsx index aa755566..fbea35c0 100644 --- a/packages/reporter-solid/src/ValidationMessage.tsx +++ b/packages/reporter-solid/src/ValidationMessage.tsx @@ -1,43 +1,40 @@ import type { JSX } from 'solid-js'; -import { _get, getPath } from '@felte/common'; -import { onMount, createSignal, onCleanup } from 'solid-js'; -import { errorStores } from './stores'; -import { createId } from './utils'; +import { _get, createId } from '@felte/common'; +import { onMount, createSignal, onCleanup, mergeProps } from 'solid-js'; +import { errorStores, warningStores } from './stores'; export type ValidationMessageProps = { for: string; - index?: string | number; - children: (messages: string | string[] | undefined) => JSX.Element; + level?: 'error' | 'warning'; + children: (messages: string[] | null) => JSX.Element; }; export function ValidationMessage(props: ValidationMessageProps) { - const [messages, setMessages] = createSignal(); + props = mergeProps({ level: 'error' }, props); + const [messages, setMessages] = createSignal(null); function getFormElement(element: HTMLDivElement) { - let form = element.parentNode; - if (!form) return; - while (form && form.nodeName !== 'FORM') { - form = form.parentNode; - } - return form; + return element.closest('form'); } const id = createId(21); let unsubscribe: (() => void) | undefined; onMount(() => { const element = document.getElementById(id) as HTMLDivElement; - const path = getPath( - element, - typeof props.index !== 'undefined' - ? `${props.for}[${props.index}]` - : props.for - ); - const formElement = getFormElement(element) as HTMLFormElement; - const reporterId = formElement?.dataset.felteReporterSvelteId; + const path = props.for; + const formElement = getFormElement(element); + const reporterId = formElement?.dataset.felteReporterSolidId; if (!reporterId) return; - const store = errorStores[reporterId]; - unsubscribe = store?.subscribe(($errors: any) => - setMessages(_get($errors, path) as any) - ); + if (props.level === 'error') { + const errors = errorStores[reporterId]; + unsubscribe = errors.subscribe(($errors) => + setMessages(_get($errors, path) as string[] | null) + ); + } else { + const warnings = warningStores[reporterId]; + unsubscribe = warnings.subscribe(($warnings) => + setMessages(_get($warnings, path) as string[] | null) + ); + } }); onCleanup(() => unsubscribe?.()); diff --git a/packages/reporter-solid/src/reporter.ts b/packages/reporter-solid/src/reporter.ts index 44751ba6..e8f911bd 100644 --- a/packages/reporter-solid/src/reporter.ts +++ b/packages/reporter-solid/src/reporter.ts @@ -1,40 +1,28 @@ -import { CurrentForm, Obj } from '@felte/common'; -import { getPath, _get } from '@felte/common'; +import type { CurrentForm, Obj } from '@felte/common'; import type { ExtenderHandler } from '@felte/common'; -import { errorStores } from './stores'; -import { createId } from './utils'; +import { errorStores, warningStores } from './stores'; +import { createId } from '@felte/common'; export function reporter( currentForm: CurrentForm ): ExtenderHandler { const config = currentForm.config; - if (!config.__felteReporterSvelteId) { - const id = createId(21); - config.__felteReporterSvelteId = id; - errorStores[id] = currentForm.errors; + if (currentForm.stage === 'SETUP') { + if (!config.__felteReporterSolidId) { + const id = createId(21); + config.__felteReporterSolidId = id; + errorStores[id] = currentForm.errors; + warningStores[id] = currentForm.warnings; + } + return {}; } - if (!currentForm.form) return {}; - if (!currentForm.form.hasAttribute('data-felte-reporter-svelte-id')) { - currentForm.form.dataset.felteReporterSvelteId = config.__felteReporterSvelteId as string; + if (!currentForm.form.hasAttribute('data-felte-reporter-solid-id')) { + currentForm.form.dataset.felteReporterSolidId = config.__felteReporterSolidId as string; } - const unsubscribe = currentForm.errors.subscribe(($errors) => { - if (!currentForm.controls) return; - for (const control of currentForm.controls) { - const controlError = _get($errors, getPath(control)); - if (!controlError) { - control.removeAttribute('aria-invalid'); - continue; - } - control.setAttribute('aria-invalid', 'true'); - } - }); return { - destroy() { - unsubscribe(); - }, onSubmitError() { const firstInvalidElement = currentForm?.form?.querySelector( - '[data-felte-validation-message]' + '[data-felte-validation-message]:not([type="hidden"])' ) as HTMLElement; firstInvalidElement?.focus(); }, diff --git a/packages/reporter-solid/src/stores.ts b/packages/reporter-solid/src/stores.ts index d2a8df18..2b4078a5 100644 --- a/packages/reporter-solid/src/stores.ts +++ b/packages/reporter-solid/src/stores.ts @@ -1,5 +1,8 @@ +import type { PartialWritableErrors } from '@felte/common'; + export type ErrorStores = { - [index: string]: any; + [index: string]: PartialWritableErrors; }; export const errorStores: ErrorStores = {}; +export const warningStores: ErrorStores = {}; diff --git a/packages/reporter-solid/tests/reporter.spec.tsx b/packages/reporter-solid/tests/reporter.spec.tsx new file mode 100644 index 00000000..2c1a2b52 --- /dev/null +++ b/packages/reporter-solid/tests/reporter.spec.tsx @@ -0,0 +1,138 @@ +import * as sinon from 'sinon'; +import { createForm } from '@felte/solid'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import 'uvu-expect-dom/extend'; +import { screen, render, waitFor, cleanup } from 'solid-testing-library'; +import { Index } from 'solid-js'; +import userEvent from '@testing-library/user-event'; +import { ValidationMessage, reporter } from '../src'; + +type Data = { + email: string; + password: string; +}; + +type DataErrors = { + email?: string; + password?: string[]; +}; + +type DataWarnings = { + password?: string; +}; + +function getArrayError(message: string, errorValue?: string[]) { + if (errorValue) return [...errorValue, message]; + return [message]; +} + +function Wrapper() { + const { form } = createForm({ + onSubmit: sinon.fake(), + extend: reporter, + validate(values) { + const errors: DataErrors = {}; + if (!values.email) errors.email = 'Must not be empty'; + if (!values.password) + errors.password = getArrayError('Must not be empty', errors.password); + if (values.password?.length < 8) + errors.password = getArrayError( + 'Must be at least 8 chars', + errors.password + ); + return errors; + }, + warn(values) { + const warnings: DataWarnings = {}; + if (values.password && values.password.length < 8) + warnings.password = 'Not secure enough'; + return warnings; + }, + }); + + return ( +
    +
    + + + + {(message) => {message}} + +
    +
    + + + + {(messages) => ( +
      + + {(message) =>
    • {message()}
    • } +
      +
    + )} +
    + + {(message) => {message}} + +
    +
    + ); +} + +const Reporter = suite('reporter'); + +Reporter.after.each(cleanup); + +Reporter('reports validation message', async () => { + render(() => ); + + const formElement = screen.getByTestId('test-form') as HTMLFormElement; + const emailInput = screen.getByRole('textbox', { name: 'Email' }); + const passwordInput = screen.getByRole('textbox', { name: 'Password' }); + let emailMessage = screen.getByTestId('email-message'); + let passwordMessage = screen.getByTestId('password-message'); + let passwordWarning = screen.getByTestId('password-warning'); + + expect(emailInput).to.be.valid; + expect(emailMessage).to.be.empty; + expect(passwordMessage).to.be.empty; + expect(passwordWarning).to.be.empty; + + formElement.submit(); + + await waitFor(() => { + expect(emailInput).to.be.invalid; + expect(passwordInput).to.be.invalid; + emailMessage = screen.getByTestId('email-message'); + passwordMessage = screen.getByTestId('password-message'); + passwordWarning = screen.getByTestId('password-warning'); + expect(emailMessage).to.have.text.that.contains('Must not be empty'); + expect(passwordMessage).to.have.text.that.contains('Must not be empty'); + expect(passwordMessage).to.have.text.that.contains( + 'Must be at least 8 chars' + ); + expect(passwordWarning).to.be.empty; + }); + + userEvent.type(emailInput, 'zaphod@beeblebrox.com'); + userEvent.type(passwordInput, '1234'); + + await waitFor(() => { + expect(emailInput).to.be.valid; + expect(passwordInput).to.be.invalid; + emailMessage = screen.getByTestId('email-message'); + passwordMessage = screen.getByTestId('password-message'); + passwordWarning = screen.getByTestId('password-warning'); + expect(emailMessage).to.be.empty; + expect(passwordMessage).to.have.text.that.does.not.contain( + 'Must not be empty' + ); + expect(passwordMessage).to.have.text.that.contains( + 'Must be at least 8 chars' + ); + expect(passwordWarning).to.have.text.that.contains('Not secure enough'); + }); +}); + +Reporter.run(); diff --git a/packages/reporter-solid/tests/reporter.test.tsx b/packages/reporter-solid/tests/reporter.test.tsx deleted file mode 100644 index 9853ae2e..00000000 --- a/packages/reporter-solid/tests/reporter.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { createForm } from '@felte/solid'; -import { screen, render, waitFor } from 'solid-testing-library'; -import { Index } from 'solid-js'; -import userEvent from '@testing-library/user-event'; -import { ValidationMessage, reporter } from '../src'; - -type Data = { - email: string; - password: string; -}; - -type DataErrors = { - email?: string; - password?: string[]; -}; - -function getArrayError(message: string, errorValue?: string[]) { - if (errorValue) return [...errorValue, message]; - return [message]; -} - -function Wrapper() { - const { form } = createForm({ - onSubmit: jest.fn(), - extend: reporter, - validate(values) { - const errors: DataErrors = {}; - if (!values.email) errors.email = 'Must not be empty'; - if (!values.password) - errors.password = getArrayError('Must not be empty', errors.password); - if (values.password?.length < 8) - errors.password = getArrayError( - 'Must be at least 8 chars', - errors.password - ); - return errors; - }, - }); - - return ( -
    -
    - - - - {(message) => {message}} - -
    -
    - - - - {(messages) => ( -
      - - {(message) =>
    • {message()}
    • } -
      -
    - )} -
    -
    -
    - ); -} - -describe('reporter', () => { - test('reports validation message', async () => { - render(Wrapper); - - const formElement = screen.getByTestId('test-form') as HTMLFormElement; - const emailInput = screen.getByRole('textbox', { name: 'Email' }); - const passwordInput = screen.getByRole('textbox', { name: 'Password' }); - let emailMessage = screen.getByTestId('email-message'); - let passwordMessage = screen.getByTestId('password-message'); - - expect(emailInput).toBeValid(); - expect(emailMessage).toBeEmptyDOMElement(); - expect(passwordMessage).toBeEmptyDOMElement(); - - formElement.submit(); - - await waitFor(() => { - expect(emailInput).toBeInvalid(); - expect(passwordInput).toBeInvalid(); - emailMessage = screen.getByTestId('email-message'); - passwordMessage = screen.getByTestId('password-message'); - expect(emailMessage).toHaveTextContent('Must not be empty'); - expect(passwordMessage).toHaveTextContent('Must not be empty'); - expect(passwordMessage).toHaveTextContent('Must be at least 8 chars'); - }); - - userEvent.type(emailInput, 'zaphod@beeblebrox.com'); - userEvent.type(passwordInput, '1234'); - - await waitFor(() => { - expect(emailInput).toBeValid(); - expect(passwordInput).toBeInvalid(); - emailMessage = screen.getByTestId('email-message'); - passwordMessage = screen.getByTestId('password-message'); - expect(emailMessage).toBeEmptyDOMElement(); - expect(passwordMessage).not.toHaveTextContent('Must not be empty'); - expect(passwordMessage).toHaveTextContent('Must be at least 8 chars'); - }); - }); -}); diff --git a/packages/reporter-solid/tests/setupTests.ts b/packages/reporter-solid/tests/setupTests.ts index a76bca35..b535f6a2 100644 --- a/packages/reporter-solid/tests/setupTests.ts +++ b/packages/reporter-solid/tests/setupTests.ts @@ -1,2 +1 @@ import 'regenerator-runtime/runtime'; -import '@testing-library/jest-dom'; diff --git a/packages/reporter-svelte/CHANGELOG.md b/packages/reporter-svelte/CHANGELOG.md index aaaa2286..c2ae7d42 100644 --- a/packages/reporter-svelte/CHANGELOG.md +++ b/packages/reporter-svelte/CHANGELOG.md @@ -1,5 +1,217 @@ # @felte/reporter-dom +## 1.0.0-next.23 + +### Patch Changes + +- 49609d8: Show message/slot as soon as render happens +- Updated dependencies [7f3d8b8] + - @felte/common@1.0.0-next.23 + +## 1.0.0-next.22 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/common@1.0.0-next.22 + +## 1.0.0-next.21 + +### Patch Changes + +- ed1cbe3: Fix types + +## 1.0.0-next.20 + +### Patch Changes + +- Updated dependencies [fcbdaed] + - @felte/common@1.0.0-next.21 + +## 1.0.0-next.19 + +### Patch Changes + +- Updated dependencies [990034e] + - @felte/common@1.0.0-next.20 + +## 1.0.0-next.18 + +### Minor Changes + +- 02fd56e: Ensure good behaviour with controls created by `useField`/`createField` by only focusing non-hidden inputs + +### Patch Changes + +- Updated dependencies [a174e87] + - @felte/common@1.0.0-next.19 + +## 1.0.0-next.17 + +### Patch Changes + +- Updated dependencies [70cfada] + - @felte/common@1.0.0-next.18 + +## 1.0.0-next.16 + +### Patch Changes + +- Updated dependencies [2e7aad3] + - @felte/common@1.0.0-next.17 + +## 1.0.0-next.15 + +### Patch Changes + +- Updated dependencies [c8c1511] + - @felte/common@1.0.0-next.16 + +## 1.0.0-next.14 + +### Patch Changes + +- Updated dependencies [093482a] + - @felte/common@1.0.0-next.15 + +## 1.0.0-next.13 + +### Patch Changes + +- Updated dependencies [dd52c94] + - @felte/common@1.0.0-next.14 + +## 1.0.0-next.12 + +### Patch Changes + +- Updated dependencies [a45d56c] + - @felte/common@1.0.0-next.13 + +## 1.0.0-next.11 + +### Major Changes + +- 998ed45: BREAKING: Remove `index` prop support + + This was done in order to allow and simplify further improvements of the type system. + + This means this: + + ```html + + ``` + + Should be changed to this: + + ```html + + ``` + +### Patch Changes + +- Updated dependencies [452fe5a] +- Updated dependencies [15d0ce2] + - @felte/common@1.0.0-next.12 + +## 1.0.0-next.10 + +### Patch Changes + +- Updated dependencies [a1dbc28] +- Updated dependencies [ec740a0] +- Updated dependencies [34e0393] +- Updated dependencies [b7ef442] +- Updated dependencies [e1ad8cd] + - @felte/common@1.0.0-next.11 + +## 1.0.0-next.9 + +### Patch Changes + +- Updated dependencies [dc1f21a] +- Updated dependencies [eea3afa] + - @felte/common@1.0.0-next.10 + +## 1.0.0-next.8 + +### Patch Changes + +- Updated dependencies [38fbb49] + - @felte/common@1.0.0-next.9 + +## 1.0.0-next.7 + +### Patch Changes + +- Updated dependencies [c86a82a] + - @felte/common@1.0.0-next.8 + +## 1.0.0-next.6 + +### Patch Changes + +- Updated dependencies [e49c094] + - @felte/common@1.0.0-next.7 + +## 1.0.0-next.5 + +### Patch Changes + +- Updated dependencies [d1b62bf] + - @felte/common@1.0.0-next.6 + +## 1.0.0-next.4 + +### Patch Changes + +- Updated dependencies [e2f4e18] + - @felte/common@1.0.0-next.5 + +## 1.0.0-next.3 + +### Patch Changes + +- Updated dependencies [8c29b4a] + - @felte/common@1.0.0-next.3 + +## 1.0.0-next.2 + +### Patch Changes + +- Updated dependencies [6f48123] + - @felte/common@1.0.0-next.2 + +## 1.0.0-next.1 + +### Patch Changes + +- Updated dependencies [02a77e3] + - @felte/common@1.0.0-next.1 + +## 1.0.0-next.0 + +### Major Changes + +- f59f31e: BREAKING: change export name to `reporter` to be consistent with other packages +- 9a48a40: Pass a new property `stage` to extenders to distinguish between setup, mount and update stages +- 2c0f874: Make type of helpers and stores looser when using a transform function + +### Minor Changes + +- 1bc036e: Change responsibility for adding `aria-invalid` to fields to `@felte/core` +- 0c01eab: Add `level` prop to select from which store to obtain validation message. Possible values: `'error' | 'warning'` + +### Patch Changes + +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/common@1.0.0-next.0 + ## 0.3.20 ### Patch Changes diff --git a/packages/reporter-svelte/README.md b/packages/reporter-svelte/README.md index a6fc44e3..0a78ffab 100644 --- a/packages/reporter-svelte/README.md +++ b/packages/reporter-svelte/README.md @@ -19,16 +19,16 @@ If you're using sapper, you might want to add this reporter as a dev dependency. ## Usage -The package exports a reporter function `svelteReporter` and a Svelte component `ValidationMessage`. These can be used in conjunction to report errors. +The package exports a reporter function `reporter` and a Svelte component `ValidationMessage`. These can be used in conjunction to report errors. Add the reporter to the `extend` property of `createForm` configuration. ```javascript -import { svelteReporter, ValidationMessage } from '@felte/reporter-svelte'; +import { reporter, ValidationMessage } from '@felte/reporter-svelte'; const { form } = createForm({ // ... - extend: svelteReporter, + extend: reporter, // ... }); ``` @@ -39,7 +39,7 @@ In order to show the errors for a field, you'll need to use the reporter's compo - {messages || ''} + {messages?.[0] || ''} ``` @@ -47,7 +47,17 @@ The `for` property refers to the ID of the input. The `messages` prop will have ```html - {message} + {message?.[0]} Some placeholder text ``` + +## Warnings + +This reporter can help you display your `warning` messages as well. If you want your `ValidationMessage` component to display the warnings for a field you'll need to set the `level` prop to the value `warning`. By default this prop has a value of `error`. + +```html + + {messages?.[0] || ''} + +``` diff --git a/packages/reporter-svelte/jest.config.js b/packages/reporter-svelte/jest.config.js deleted file mode 100644 index eff6474c..00000000 --- a/packages/reporter-svelte/jest.config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], - transform: { - '^.+\\.svelte$': 'svelte-jester', - '^.+\\.js$': 'babel-jest', - }, - moduleFileExtensions: ['js', 'svelte'], -}; diff --git a/packages/reporter-svelte/package.json b/packages/reporter-svelte/package.json index 7fb3cf68..bdb9391a 100644 --- a/packages/reporter-svelte/package.json +++ b/packages/reporter-svelte/package.json @@ -1,12 +1,16 @@ { "name": "@felte/reporter-svelte", - "version": "0.3.20", + "version": "1.0.0-next.23", "description": "An error reporter for Felte using a Svelte component", - "main": "dist/index.js", + "main": "dist/index.cjs", "svelte": "src/index.js", - "browser": "dist/index.js", + "browser": "dist/index.mjs", "module": "dist/index.mjs", "types": "types/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "sideEffects": false, "author": "Pablo Berganza ", "repository": "github:pablo-abc/felte", @@ -19,11 +23,11 @@ ], "scripts": { "prebuild": "rimraf ./dist", - "build": "cross-env NODE_ENV=production rollup -c", + "build": "pnpm prebuild && cross-env NODE_ENV=production rollup -c", "dev": "rollup -cw", "prepublishOnly": "pnpm build && pnpm test", - "test": "jest", - "test:ci": "jest --ci --coverage" + "test": "uvu -r tsm -r ./svelte-register.js -r global-jsdom/register -r module-alias/register tests -i common -i test -i .svelte", + "test:ci": "nyc -n src pnpm test" }, "license": "MIT", "dependencies": { @@ -38,10 +42,15 @@ "types" ], "devDependencies": { + "@babel/preset-env": "^7.16.5", + "@testing-library/svelte": "^3.0.3", "felte": "workspace:*", + "module-alias": "^2.2.2", + "pirates": "^4.0.5", "rollup-plugin-svelte": "^7.1.0", - "svelte": "^3.31.0", - "svelte-preprocess": "^4.6.9" + "svelte": "^3.46.4", + "svelte-preprocess": "^4.6.9", + "uvu": "^0.5.3" }, "peerDependencies": { "svelte": "^3.31.0" @@ -49,9 +58,14 @@ "exports": { ".": { "import": "./dist/index.mjs", - "require": "./dist/index.js", + "require": "./dist/index.cjs", "default": "./dist/index.mjs" }, "./package.json": "./package.json" + }, + "_moduleAliases": { + "svelte": "node_modules/svelte/index.mjs", + "svelte/store": "node_modules/svelte/store/index.mjs", + "svelte/internal": "node_modules/svelte/internal/index.mjs" } } diff --git a/packages/reporter-svelte/rollup.config.js b/packages/reporter-svelte/rollup.config.js index 35ad3f92..b78b828e 100644 --- a/packages/reporter-svelte/rollup.config.js +++ b/packages/reporter-svelte/rollup.config.js @@ -3,7 +3,6 @@ import autoPreprocess from 'svelte-preprocess'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; -import { terser } from 'rollup-plugin-terser'; import bundleSize from 'rollup-plugin-bundle-size'; import pkg from './package.json'; @@ -17,7 +16,7 @@ export default { input: './src/index.js', external: ['svelte', 'svelte/store', 'svelte/internal'], output: [ - { file: pkg.browser, format: 'cjs', sourcemap: prod, name }, + { file: pkg.main, format: 'cjs', sourcemap: prod, name }, { file: pkg.module, format: 'esm', sourcemap: prod }, ], plugins: [ @@ -32,7 +31,6 @@ export default { }), resolve({ browser: true }), commonjs(), - prod && terser(), prod && bundleSize(), ], }; diff --git a/packages/reporter-svelte/src/ValidationMessage.svelte b/packages/reporter-svelte/src/ValidationMessage.svelte index d54e1e2d..da10e397 100644 --- a/packages/reporter-svelte/src/ValidationMessage.svelte +++ b/packages/reporter-svelte/src/ValidationMessage.svelte @@ -2,9 +2,9 @@ import { onMount } from 'svelte'; import { writable } from 'svelte/store'; import { _get, getPath } from '@felte/common'; - import { errorStores } from './stores'; + import { errorStores, warningStores } from './stores'; - export let index = undefined; + export let level = 'error'; let errorFor; export { errorFor as for }; @@ -13,27 +13,23 @@ let element; function getFormElement() { - let form = element.parentNode; - if (!form) return; - while (form && form.nodeName !== 'FORM') { - form = form.parentNode; - } - return form; + return element.closest('form'); } onMount(() => { - const path = typeof index !== 'undefined' ? `${errorFor}[${index}]` : errorFor; + const path = errorFor; errorPath = getPath(element, path); const formElement = getFormElement(); if (!formElement) errors = writable({}); - else errors = errorStores[formElement.dataset.felteReporterSvelteId]; + else if (level === 'error') errors = errorStores[formElement.dataset.felteReporterSvelteId]; + else errors = warningStores[formElement.dataset.felteReporterSvelteId]; }); $: messages = errorPath && _get($errors, errorPath)
    -{#if errorPath && (messages || !$$slots.placeholder)} +{#if !$$slots.placeholder || messages} -{:else if errorPath} +{:else} {/if} diff --git a/packages/reporter-svelte/src/reporter.js b/packages/reporter-svelte/src/reporter.js index dedcb3f4..db3e6e60 100644 --- a/packages/reporter-svelte/src/reporter.js +++ b/packages/reporter-svelte/src/reporter.js @@ -1,45 +1,28 @@ -import { getPath, _get } from '@felte/common'; -import { errorStores } from './stores'; +import { errorStores, warningStores } from './stores'; +import { createId } from '@felte/common'; -function createId(length = 8) { - let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let str = ''; - for (let i = 0; i < length; i++) { - str += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return str; -} - -export function svelteReporter(currentForm) { +export function reporter(currentForm) { const config = currentForm.config; - if (!config.__felteReporterSvelteId) { - const id = createId(21); - config.__felteReporterSvelteId = id; - errorStores[id] = currentForm.errors; + if (currentForm.stage === 'SETUP') { + if (!config.__felteReporterSvelteId) { + const id = createId(21); + config.__felteReporterSvelteId = id; + errorStores[id] = currentForm.errors; + warningStores[id] = currentForm.warnings; + } + return {}; } - if (!currentForm.form) return; if (!currentForm.form.hasAttribute('data-felte-reporter-svelte-id')) { currentForm.form.dataset.felteReporterSvelteId = config.__felteReporterSvelteId; } - const unsubscribe = currentForm.errors.subscribe(($errors) => { - for (const control of currentForm.controls) { - const controlError = _get($errors, getPath(control)); - if (!controlError) { - control.removeAttribute('aria-invalid'); - continue; - } - control.setAttribute('aria-invalid', 'true'); - } - }); return { - destroy() { - unsubscribe(); - }, onSubmitError() { const firstInvalidElement = currentForm && - currentForm.form.querySelector('[data-felte-validation-message]'); + currentForm.form.querySelector( + '[aria-invalid="true"]:not([type="hidden"])' + ); firstInvalidElement && firstInvalidElement.focus(); }, }; diff --git a/packages/reporter-svelte/src/stores.js b/packages/reporter-svelte/src/stores.js index 90b1efdb..ce754483 100644 --- a/packages/reporter-svelte/src/stores.js +++ b/packages/reporter-svelte/src/stores.js @@ -1 +1,2 @@ export const errorStores = {}; +export const warningStores = {}; diff --git a/packages/reporter-svelte/svelte-register.js b/packages/reporter-svelte/svelte-register.js new file mode 100644 index 00000000..513b736d --- /dev/null +++ b/packages/reporter-svelte/svelte-register.js @@ -0,0 +1,23 @@ +import { parse } from 'path'; +import { addHook } from 'pirates'; +import { compile } from 'svelte/compiler'; + +function transform(source, filename) { + const { name } = parse(filename); + + const { js, warnings } = compile(source, { + name: name[0].toUpperCase() + name.substring(1), + format: 'cjs', + filename, + }); + + warnings.forEach((warning) => { + console.warn(`\nSvelte Warning in ${warning.filename}:`); + console.warn(warning.message); + console.warn(warning.frame); + }); + + return js.code; +} + +addHook(transform, { exts: ['.svelte'] }); diff --git a/packages/reporter-svelte/tests/Multiple.svelte b/packages/reporter-svelte/tests/Multiple.svelte index 406ba2d6..9898a610 100644 --- a/packages/reporter-svelte/tests/Multiple.svelte +++ b/packages/reporter-svelte/tests/Multiple.svelte @@ -1,11 +1,13 @@ @@ -17,4 +22,7 @@ {message || ''} + + {message || ''} + diff --git a/packages/reporter-svelte/tests/Placeholder.svelte b/packages/reporter-svelte/tests/Placeholder.svelte index d1282746..00f8d8e7 100644 --- a/packages/reporter-svelte/tests/Placeholder.svelte +++ b/packages/reporter-svelte/tests/Placeholder.svelte @@ -1,11 +1,13 @@ diff --git a/packages/reporter-svelte/tests/reporter.spec.js b/packages/reporter-svelte/tests/reporter.spec.js new file mode 100644 index 00000000..d081553f --- /dev/null +++ b/packages/reporter-svelte/tests/reporter.spec.js @@ -0,0 +1,80 @@ +import 'uvu-expect-dom/extend'; +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import { render, screen, waitFor, cleanup } from '@testing-library/svelte'; +import NoPlaceholder from './NoPlaceholder.svelte'; +import Placeholder from './Placeholder.svelte'; +import Multiple from './Multiple.svelte'; + +const Reporter = suite('Reporter Svelte'); + +let clock; +Reporter.before.each(() => { + clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); +}); + +Reporter.after.each(() => { + clock.runAll(); + clock.restore(); + cleanup(); +}); + +Reporter('sets aria-invalid to input', async () => { + render(NoPlaceholder); + const inputElement = screen.getByRole('textbox', { name: 'test' }); + const formElement = screen.getByRole('form'); + formElement.submit(); + clock.runAllAsync(); + await waitFor(() => { + expect(inputElement).to.be.invalid; + }); +}); + +Reporter('renders error message', async () => { + render(NoPlaceholder); + const formElement = screen.getByRole('form'); + const validationMessageElement = screen.getByTestId('validation-message'); + const warningMessageElement = screen.getByTestId('warning-message'); + formElement.requestSubmit(); + clock.runAllAsync(); + await waitFor(() => { + expect(validationMessageElement).to.have.text.that.contains( + 'An error message' + ); + expect(warningMessageElement).to.have.text.that.contains( + 'A warning message' + ); + }); +}); + +Reporter('renders placeholder', async () => { + render(Placeholder); + const formElement = screen.getByRole('form'); + const placeholderElement = screen.getByTestId('placeholder'); + formElement.submit(); + clock.runAll(); + await waitFor(() => { + expect(placeholderElement).to.not.be.null; + expect(placeholderElement).to.have.text.that.contains('Placeholder text'); + }); +}); + +Reporter('renders multiple errors', async () => { + render(Multiple); + const formElement = screen.getByRole('form'); + formElement.submit(); + clock.runAll(); + for (const index of [0, 1, 2]) { + const validationMessageElement = screen.getByTestId( + `validation-message-${index}` + ); + await waitFor(() => { + expect(validationMessageElement).to.have.text.that.contains( + 'An error message' + ); + }); + } +}); + +Reporter.run(); diff --git a/packages/reporter-svelte/tests/reporter.test.js b/packages/reporter-svelte/tests/reporter.test.js deleted file mode 100644 index 603f7717..00000000 --- a/packages/reporter-svelte/tests/reporter.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { render, screen, waitFor } from '@testing-library/svelte'; -import NoPlaceholder from './NoPlaceholder.svelte'; -import Placeholder from './Placeholder.svelte'; -import Multiple from './Multiple.svelte'; - -describe('Reporter Svelte', () => { - test('sets aria-invalid to input', async () => { - render(NoPlaceholder); - const inputElement = screen.getByRole('textbox', { name: 'test' }); - const formElement = screen.getByRole('form'); - formElement.submit(); - await waitFor(() => { - expect(inputElement).toHaveAttribute('aria-invalid'); - }); - }); - - test('renders error message', async () => { - render(NoPlaceholder); - const formElement = screen.getByRole('form'); - const validationMessageElement = screen.getByTestId('validation-message'); - formElement.submit(); - await waitFor(() => { - expect(validationMessageElement.innerHTML).toContain('An error message'); - }); - }); - - test('renders placeholder', async () => { - render(Placeholder); - const formElement = screen.getByRole('form'); - const placeholderElement = screen.getByTestId('placeholder'); - formElement.submit(); - await waitFor(() => { - expect(placeholderElement).toBeInTheDocument(); - expect(placeholderElement.innerHTML).toContain('Placeholder text'); - }); - }); - - test('renders multiple errors', async () => { - render(Multiple); - const formElement = screen.getByRole('form'); - formElement.submit(); - for (const index of [0, 1, 2]) { - const validationMessageElement = screen.getByTestId( - `validation-message-${index}` - ); - await waitFor(() => { - expect(validationMessageElement.innerHTML).toContain( - 'An error message' - ); - }); - } - }); -}); diff --git a/packages/reporter-svelte/types/ValidationMessage.d.ts b/packages/reporter-svelte/types/ValidationMessage.d.ts index 7eef0092..cf662c3d 100644 --- a/packages/reporter-svelte/types/ValidationMessage.d.ts +++ b/packages/reporter-svelte/types/ValidationMessage.d.ts @@ -1,13 +1,15 @@ -/// import { SvelteComponentTyped } from 'svelte'; export interface ValidationMessageProps { - index?: string | number; + level?: 'error' | 'warning'; for: string; } export default class ValidationMessage extends SvelteComponentTyped< ValidationMessageProps, - {}, - { default: { messages: string | string[] | undefined }; placeholder: {} } + Record, + { + default: { messages: string[] | null }; + placeholder: Record; + } > {} diff --git a/packages/reporter-svelte/types/reporter.d.ts b/packages/reporter-svelte/types/reporter.d.ts index 32322056..c8bc5dae 100644 --- a/packages/reporter-svelte/types/reporter.d.ts +++ b/packages/reporter-svelte/types/reporter.d.ts @@ -1,5 +1,5 @@ import { CurrentForm, Obj, ExtenderHandler } from '@felte/common'; -export function svelteReporter( +export function reporter( currentForm: CurrentForm ): ExtenderHandler; diff --git a/packages/reporter-tippy/CHANGELOG.md b/packages/reporter-tippy/CHANGELOG.md index d0a46e09..c4a429a8 100644 --- a/packages/reporter-tippy/CHANGELOG.md +++ b/packages/reporter-tippy/CHANGELOG.md @@ -1,5 +1,198 @@ # @felte/reporter-tippy +## 1.0.0-next.23 + +### Patch Changes + +- Updated dependencies [7f3d8b8] + - @felte/common@1.0.0-next.23 + +## 1.0.0-next.22 + +### Patch Changes + +- 4853b7e: Change cjs output to have an extension of `.cjs` +- Updated dependencies [4853b7e] + - @felte/common@1.0.0-next.22 + +## 1.0.0-next.21 + +### Patch Changes + +- d2fe263: `onSubmitError` does nothing when `level !== 'error'` +- Updated dependencies [fcbdaed] + - @felte/common@1.0.0-next.21 + +## 1.0.0-next.20 + +### Patch Changes + +- Updated dependencies [990034e] + - @felte/common@1.0.0-next.20 + +## 1.0.0-next.19 + +### Minor Changes + +- 02fd56e: Ensure good behaviour with controls created by `useField`/`createField` by only focusing non-hidden inputs + +### Patch Changes + +- Updated dependencies [a174e87] + - @felte/common@1.0.0-next.19 + +## 1.0.0-next.18 + +### Patch Changes + +- Updated dependencies [70cfada] + - @felte/common@1.0.0-next.18 + +## 1.0.0-next.17 + +### Patch Changes + +- Updated dependencies [2e7aad3] + - @felte/common@1.0.0-next.17 + +## 1.0.0-next.16 + +### Patch Changes + +- Updated dependencies [c8c1511] + - @felte/common@1.0.0-next.16 + +## 1.0.0-next.15 + +### Patch Changes + +- Updated dependencies [093482a] + - @felte/common@1.0.0-next.15 + +## 1.0.0-next.14 + +### Patch Changes + +- Updated dependencies [dd52c94] + - @felte/common@1.0.0-next.14 + +## 1.0.0-next.13 + +### Patch Changes + +- Updated dependencies [a45d56c] + - @felte/common@1.0.0-next.13 + +## 1.0.0-next.12 + +### Patch Changes + +- Updated dependencies [452fe5a] +- Updated dependencies [15d0ce2] + - @felte/common@1.0.0-next.12 + +## 1.0.0-next.11 + +### Patch Changes + +- Updated dependencies [a1dbc28] +- Updated dependencies [ec740a0] +- Updated dependencies [34e0393] +- Updated dependencies [b7ef442] +- Updated dependencies [e1ad8cd] + - @felte/common@1.0.0-next.11 + +## 1.0.0-next.10 + +### Patch Changes + +- Updated dependencies [dc1f21a] +- Updated dependencies [eea3afa] + - @felte/common@1.0.0-next.10 + +## 1.0.0-next.9 + +### Patch Changes + +- Updated dependencies [38fbb49] + - @felte/common@1.0.0-next.9 + +## 1.0.0-next.8 + +### Patch Changes + +- Updated dependencies [c86a82a] + - @felte/common@1.0.0-next.8 + +## 1.0.0-next.7 + +### Patch Changes + +- Updated dependencies [e49c094] + - @felte/common@1.0.0-next.7 + +## 1.0.0-next.6 + +### Patch Changes + +- 7ccfdc8: Set appropriate store depending on level + +## 1.0.0-next.5 + +### Patch Changes + +- Updated dependencies [d1b62bf] + - @felte/common@1.0.0-next.6 + +## 1.0.0-next.4 + +### Patch Changes + +- Updated dependencies [e2f4e18] + - @felte/common@1.0.0-next.5 + +## 1.0.0-next.3 + +### Patch Changes + +- Updated dependencies [8c29b4a] + - @felte/common@1.0.0-next.3 + +## 1.0.0-next.2 + +### Patch Changes + +- Updated dependencies [6f48123] + - @felte/common@1.0.0-next.2 + +## 1.0.0-next.1 + +### Patch Changes + +- Updated dependencies [02a77e3] + - @felte/common@1.0.0-next.1 + +## 1.0.0-next.0 + +### Major Changes + +- 9a48a40: Pass a new property `stage` to extenders to distinguish between setup, mount and update stages +- 2c0f874: Make type of helpers and stores looser when using a transform function + +### Minor Changes + +- 1bc036e: Change responsibility for adding `aria-invalid` to fields to `@felte/core` +- c9f9d9f: Add support for displaying warnings. + +### Patch Changes + +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/common@1.0.0-next.0 + ## 0.3.12 ### Patch Changes diff --git a/packages/reporter-tippy/README.md b/packages/reporter-tippy/README.md index 49ad17e7..a0f5b1fb 100644 --- a/packages/reporter-tippy/README.md +++ b/packages/reporter-tippy/README.md @@ -45,7 +45,7 @@ reporter({ }) ``` -You can also pass a `setContent` function that will receive the current validation messages and the field path. Here you can modify your validation messages, which can come in useful if you want to display HTML content inside of Tippy. The `messages` argument will either by an array of strings (it can be more than one message depending on your validation strategy) or undefined. The `path` argument will be a string with the full path of your field (e.g. `email`, `account.email`, etc). +You can also pass a `setContent` function that will receive the current validation messages and the field path. Here you can modify your validation messages, which can come in useful if you want to display HTML content inside of Tippy. The `messages` argument will either be an array of strings (it can be more than one message depending on your validation strategy) or undefined. The `path` argument will be a string with the full path of your field (e.g. `email`, `account.email`, etc). ```javascript reporter({ @@ -73,6 +73,18 @@ reporter({ }) ``` +## Warnings + +This reporter can also display your warning messages. In order to do so you'll need to pass the property `level` to your reporter with a value of `warning`. + +```javascript +reporter({ + level: 'warning' +}) +``` + +> In order to avoid cluttering your UI it'd be recommended to use Tippy to report errors _OR_ warnings, not both. + ## Opting out If this package does not satisfy your needs for all cases, do know we are working on improving this, but you may as well opt-out of reporting a specific field's error by adding `data-felte-reporter-tippy-ignore` as an attribute to your input. diff --git a/packages/reporter-tippy/jest.config.js b/packages/reporter-tippy/jest.config.js deleted file mode 100644 index 553799a5..00000000 --- a/packages/reporter-tippy/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'jsdom', - collectCoverageFrom: ['./src/**'], -}; diff --git a/packages/reporter-tippy/package.json b/packages/reporter-tippy/package.json index cd6d9456..3de96174 100644 --- a/packages/reporter-tippy/package.json +++ b/packages/reporter-tippy/package.json @@ -1,11 +1,15 @@ { "name": "@felte/reporter-tippy", - "version": "0.3.12", + "version": "1.0.0-next.23", "description": "An error reporter for Felte using Tippy.js", "main": "dist/index.js", "browser": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "sideEffects": false, "author": "Pablo Berganza ", "repository": "github:pablo-abc/felte", @@ -18,11 +22,11 @@ ], "scripts": { "prebuild": "rimraf ./dist", - "build": "cross-env NODE_ENV=production rollup -c", + "build": "pnpm prebuild && cross-env NODE_ENV=production rollup -c", "dev": "rollup -cw", "prepublishOnly": "pnpm build && pnpm test", - "test": "jest", - "test:ci": "jest --ci --coverage" + "test": "uvu -r tsm -r global-jsdom/register tests -i common", + "test:ci": "nyc -n src pnpm test" }, "dependencies": { "@felte/common": "workspace:*" @@ -31,7 +35,9 @@ "tippy.js": "^6.0.0" }, "devDependencies": { - "felte": "workspace:*" + "@felte/core": "workspace:*", + "felte": "workspace:*", + "svelte": "^3.46.4" }, "license": "MIT", "publishConfig": { diff --git a/packages/reporter-tippy/rollup.config.js b/packages/reporter-tippy/rollup.config.js index b5fe97aa..47b48313 100644 --- a/packages/reporter-tippy/rollup.config.js +++ b/packages/reporter-tippy/rollup.config.js @@ -2,7 +2,6 @@ import typescript from 'rollup-plugin-ts'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; -import { terser } from 'rollup-plugin-terser'; import bundleSize from 'rollup-plugin-bundle-size'; import pkg from './package.json'; @@ -34,8 +33,7 @@ export default { }), resolve({ browser: true }), commonjs(), - typescript(), - prod && terser(), + typescript({ browserlist: false }), prod && bundleSize(), ], }; diff --git a/packages/reporter-tippy/src/index.ts b/packages/reporter-tippy/src/index.ts index 246d4e0b..da57b6bc 100644 --- a/packages/reporter-tippy/src/index.ts +++ b/packages/reporter-tippy/src/index.ts @@ -13,10 +13,6 @@ import { import { _get } from '@felte/common'; import { get } from 'svelte/store'; -function isLabelElement(node: Node): node is HTMLLabelElement { - return node.nodeName === 'LABEL'; -} - type TippyFieldProps = Partial>; type TippyPropsMap = { @@ -39,16 +35,16 @@ function getTippyInstance( return (el as any)?._tippy ?? (customPosition as any)?._tippy; } -function getControlLabel(control: FormControl): HTMLLabelElement | undefined { +function getControlLabel(control: FormControl): HTMLLabelElement[] { const labels = control.labels; - if (labels?.[0]) return labels[0]; - const parentNode = control.parentNode; - if (parentNode && isLabelElement(parentNode)) return parentNode; - if (!control.id) return; + if (labels && labels.length > 0) return Array.from(labels); + const labelNode = control.closest('label'); + if (labelNode) return [labelNode]; + if (!control.id) return []; const labelElement = document.querySelector( `label[for="${control.id}"]` ) as HTMLLabelElement | null; - return labelElement || undefined; + return labelElement ? [labelElement] : []; } export type TippyReporterOptions = { @@ -58,10 +54,12 @@ export type TippyReporterOptions = { ) => string | undefined; tippyProps?: TippyFieldProps; tippyPropsMap?: TippyPropsMap; + level?: 'error' | 'warning'; }; -function tippyReporter({ +function tippyReporter({ setContent, + level = 'error', tippyProps, tippyPropsMap = {}, }: TippyReporterOptions = {}): Extender { @@ -80,6 +78,7 @@ function tippyReporter({ ...tippyFieldProps, }); instance.popper.setAttribute('aria-live', 'polite'); + instance.popper.dataset.felteReporterTippyLevel = level; if (!content) instance.disable(); return instance; } @@ -92,23 +91,24 @@ function tippyReporter({ const tippyInstance = getTippyInstance(form, control); if (!tippyInstance) return; if (validationMessage) { - control.setAttribute('aria-invalid', 'true'); + if (!isFormControl(control)) control.setAttribute('aria-invalid', 'true'); tippyInstance.setContent(validationMessage); !tippyInstance.state.isEnabled && tippyInstance.enable(); if (document.activeElement === control && !tippyInstance.state.isShown) { tippyInstance.show(); } } else { - control.removeAttribute('aria-invalid'); + if (!isFormControl(control)) control.removeAttribute('aria-invalid'); tippyInstance.disable(); } } - return function reporter( + return function reporter( currentForm: CurrentForm ): ExtenderHandler { + if (currentForm.stage === 'SETUP') return {}; const { controls, form } = currentForm; - if (!form) return {}; + const store = level === 'error' ? currentForm.errors : currentForm.warnings; let tippyInstances: Instance[] = []; let customControls = Array.from( form.querySelectorAll('[data-felte-reporter-tippy-for]') @@ -130,7 +130,7 @@ function tippyReporter({ ) as HTMLElement | null; const triggerTarget = [ control, - getControlLabel(control), + ...getControlLabel(control), ...customTriggerTarget, ].filter(Boolean) as HTMLElement[]; if (control.hasAttribute('data-felte-reporter-tippy-ignore')) return; @@ -179,7 +179,7 @@ function tippyReporter({ .filter(Boolean) as Instance[]) : []), ...(customControls - .map(createCustomControlInstance(get(currentForm.errors))) + .map(createCustomControlInstance(get(store))) .filter(Boolean) as Instance[]), ]; } @@ -195,17 +195,20 @@ function tippyReporter({ tippyInstances = [ ...tippyInstances, ...(customControls - .map(createCustomControlInstance(get(currentForm.errors))) + .map(createCustomControlInstance(get(store))) .filter(Boolean) as Instance[]), ]; const observer = new MutationObserver(mutationCallback); observer.observe(form, { childList: true }); - const unsubscribe = currentForm.errors.subscribe(($errors) => { + const unsubscribe = store.subscribe(($messages) => { for (const control of customControls) { const elPath = getPath(control, control.dataset.felteReporterTippyFor); if (!elPath) continue; - const message = _get($errors, elPath) as string | string[] | undefined; + const message = _get($messages, elPath) as + | string + | string[] + | undefined; const transformedMessage = typeof message !== 'undefined' && !Array.isArray(message) ? [message] @@ -219,7 +222,10 @@ function tippyReporter({ for (const control of controls) { const elPath = getPath(control, control.dataset.felteReporterTippyFor); if (!elPath) continue; - const message = _get($errors, elPath) as string | string[] | undefined; + const message = _get($messages, elPath) as + | string + | string[] + | undefined; const transformedMessage = typeof message !== 'undefined' && !Array.isArray(message) ? [message] @@ -237,8 +243,9 @@ function tippyReporter({ unsubscribe(); }, onSubmitError({ errors }) { + if (level !== 'error') return; const firstInvalidElement = form.querySelector( - '[aria-invalid="true"]' + '[aria-invalid="true"]:not([type="hidden"])' ) as FormControl | null; firstInvalidElement?.focus(); const tippyInstance = firstInvalidElement diff --git a/packages/reporter-tippy/tests/common.ts b/packages/reporter-tippy/tests/common.ts index 54331617..d3ba1dca 100644 --- a/packages/reporter-tippy/tests/common.ts +++ b/packages/reporter-tippy/tests/common.ts @@ -1,3 +1,34 @@ +import 'uvu-expect-dom/extend'; +import type { CoreForm } from '@felte/core'; +import { createForm as coreCreateForm } from '@felte/core'; +import { writable } from 'svelte/store'; +import type { + FormConfig, + FormConfigWithTransformFn, + FormConfigWithoutTransformFn, + Obj, + UnknownStores, + Stores, + KnownStores, + Helpers, + UnknownHelpers, + KnownHelpers, +} from '@felte/common'; + +export function createForm( + config?: FormConfigWithTransformFn +): CoreForm & UnknownHelpers & UnknownStores; +export function createForm( + config?: FormConfigWithoutTransformFn +): CoreForm & KnownHelpers & KnownStores; +export function createForm( + config: FormConfig = {} +): CoreForm & Helpers & Stores { + return coreCreateForm(config as any, { + storeFactory: writable, + }); +} + export function createDOM(): void { const formElement = document.createElement('form'); formElement.name = 'test-form'; @@ -14,16 +45,19 @@ export type InputAttributes = { name?: string; value?: string; checked?: boolean; + index?: number; id?: string; }; export function createInputElement(attrs: InputAttributes): HTMLInputElement { const inputElement = document.createElement('input'); if (attrs.name) inputElement.name = attrs.name; + if (attrs.id) inputElement.id = attrs.id; if (attrs.type) inputElement.type = attrs.type; if (attrs.value) inputElement.value = attrs.value; if (attrs.checked) inputElement.checked = attrs.checked; - if (attrs.id) inputElement.id = attrs.id; + if (typeof attrs.index !== 'undefined') + inputElement.name = `${attrs.name}.${attrs.index}`; inputElement.required = !!attrs.required; return inputElement; } @@ -40,8 +74,7 @@ export function createMultipleInputElements( ): HTMLInputElement[] { const inputs = []; for (let i = 0; i < amount; i++) { - const input = createInputElement(attr); - input.dataset.felteIndex = String(i); + const input = createInputElement({ ...attr, index: i }); inputs.push(input); } return inputs; diff --git a/packages/reporter-tippy/tests/custom-control.spec.ts b/packages/reporter-tippy/tests/custom-control.spec.ts new file mode 100644 index 00000000..1564ef99 --- /dev/null +++ b/packages/reporter-tippy/tests/custom-control.spec.ts @@ -0,0 +1,353 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import userEvent from '@testing-library/user-event'; +import type { Instance, Props } from 'tippy.js'; +import { createForm, createDOM, cleanupDOM } from './common'; +import { screen, waitFor } from '@testing-library/dom'; +import reporter from '../src'; + +function getTippy(element: any): Instance | undefined { + return element?._tippy; +} + +type ContentEditableProps = { + name?: string; + id?: string; +}; + +function createContentEditableInput(props: ContentEditableProps = {}) { + const div = document.createElement('div'); + div.contentEditable = 'true'; + div.setAttribute('tabindex', '0'); + if (props.name) div.dataset.felteReporterTippyFor = props.name; + if (props.id) div.id = props.id; + return div; +} + +const Reporter = suite('Reporter Tippy Custom Control'); + +Reporter.before.each(createDOM); +Reporter.after.each(cleanupDOM); + +Reporter('sets aria-invalid to input and removes if valid', async () => { + type Data = { + test: string; + deep: { + value: string; + }; + }; + const mockErrors = { test: 'An error', deep: { value: 'Deep error' } }; + const mockValidate = sinon.stub().callsFake(() => mockErrors); + const { form, validate } = createForm({ + initialValues: { + test: '', + deep: { + value: '', + }, + }, + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditableInput({ + name: 'test', + }); + const valueElement = createContentEditableInput({ + name: 'deep.value', + }); + const fieldsetElement = document.createElement('fieldset'); + fieldsetElement.appendChild(valueElement); + formElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + + form(formElement); + + await validate(); + + await waitFor(() => { + const inputInstance = getTippy(inputElement); + expect(inputInstance?.popper).to.have.text.that.contains(mockErrors.test); + expect(inputElement).to.be.invalid; + const valueInstance = getTippy(valueElement); + expect(valueInstance).to.be.ok; + expect(valueInstance?.popper).to.have.text.that.contains( + mockErrors.deep.value + ); + expect(valueElement).to.be.invalid; + }); + + mockValidate.callsFake(() => ({} as any)); + + await validate(); + + await waitFor(() => { + expect(inputElement).not.to.be.invalid; + expect(valueElement).not.to.be.invalid; + }); +}); + +Reporter('show tippy on hover and hide on unhover', async () => { + const mockErrors = { test: 'A test error' }; + const mockValidate = sinon.stub().callsFake(() => mockErrors); + const { form, validate } = createForm({ + initialValues: { + test: '', + }, + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditableInput({ + name: 'test', + }); + formElement.appendChild(inputElement); + + const { destroy } = form(formElement); + + await validate(); + + expect(getTippy(inputElement)).to.be.ok; + + userEvent.hover(inputElement); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + expect(tippyInstance?.popper).to.have.text.that.contains(mockErrors.test); + }); + + userEvent.unhover(inputElement); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.not.be.ok; + }); + + mockValidate.callsFake(() => ({} as any)); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.not.be.ok; + expect(tippyInstance?.state.isVisible).to.not.be.ok; + }); + + destroy(); +}); + +Reporter('shows tippy if active element is input', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.fake(() => mockErrors); + const { form, validate } = createForm({ + initialValues: { + test: '', + }, + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditableInput({ + name: 'test', + id: 'test', + }); + formElement.appendChild(inputElement); + + inputElement.focus(); + + form(formElement); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + expect(tippyInstance?.popper).to.have.text.that.contains(mockErrors.test); + }); +}); + +Reporter('focuses first invalid input and shows tippy on submit', async () => { + type Data = { + test: string; + deep: { + value: string; + }; + }; + const mockErrors = { test: 'An error', deep: { value: 'Deep error' } }; + const mockValidate = sinon.fake(() => mockErrors); + const { form } = createForm({ + initialValues: { + test: '', + deep: { + value: '', + }, + }, + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditableInput({ + name: 'test', + }); + const valueElement = createContentEditableInput({ + name: 'deep.value', + }); + const fieldsetElement = document.createElement('fieldset'); + fieldsetElement.appendChild(valueElement); + formElement.appendChild(fieldsetElement); + formElement.appendChild(inputElement); + + form(formElement); + + formElement.submit(); + + await waitFor(() => { + expect(valueElement).to.be.focused; + let tippyInstance = getTippy(valueElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + expect(tippyInstance?.popper).to.have.text.that.contains( + mockErrors.deep.value + ); + tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.popper).to.have.text.that.contains(mockErrors.test); + }); +}); + +Reporter('sets custom content', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.fake(() => mockErrors); + const { form, validate } = createForm({ + initialValues: { + test: '', + }, + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ + setContent: (messages) => { + return messages?.map((message) => `

    ${message}

    `).join(''); + }, + }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditableInput({ + name: 'test', + id: 'test', + }); + formElement.appendChild(inputElement); + + inputElement.focus(); + + form(formElement); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + expect(tippyInstance?.popper).to.have.text.that.contains( + `

    ${mockErrors.test}

    ` + ); + }); +}); + +Reporter('sets custom props per field', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.fake(() => mockErrors); + type TestData = { + test: string; + }; + const { form, validate } = createForm({ + initialValues: { + test: '', + }, + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ + tippyPropsMap: { + test: { + hideOnClick: false, + }, + }, + }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditableInput({ + name: 'test', + id: 'test', + }); + formElement.appendChild(inputElement); + + inputElement.focus(); + + form(formElement); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + }); + + userEvent.click(formElement); + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + }); +}); + +Reporter('handles mutation of DOM', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.fake(() => mockErrors); + type TestData = { + test: string; + }; + const { form } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ + tippyPropsMap: { + test: { + hideOnClick: false, + }, + }, + }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createContentEditableInput({ + name: 'test', + id: 'test', + }); + + expect(getTippy(inputElement)).to.not.be.ok; + + form(formElement); + + formElement.appendChild(inputElement); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance).to.be.ok; + }); +}); + +Reporter.run(); diff --git a/packages/reporter-tippy/tests/custom-control.test.ts b/packages/reporter-tippy/tests/custom-control.test.ts deleted file mode 100644 index 2e315dbe..00000000 --- a/packages/reporter-tippy/tests/custom-control.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen, waitFor } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import type { Instance, Props } from 'tippy.js'; -import { createForm } from 'felte'; -import { createDOM, cleanupDOM } from './common'; -import reporter from '../src'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn() })); - -function getTippy(element: any): Instance | undefined { - return element?._tippy; -} - -type ContentEditableProps = { - name?: string; - id?: string; -}; - -function createContentEditableInput(props: ContentEditableProps = {}) { - const div = document.createElement('div'); - div.contentEditable = 'true'; - div.setAttribute('tabindex', '0'); - if (props.name) div.dataset.felteReporterTippyFor = props.name; - if (props.id) div.id = props.id; - return div; -} - -describe('Reporter Tippy Custom Control', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('sets aria-invalid to input and removes if valid', async () => { - type Data = { - test: string; - deep: { - value: string; - }; - }; - const mockErrors = { test: 'An error', deep: { value: 'Deep error' } }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createContentEditableInput({ - name: 'test', - }); - const valueElement = createContentEditableInput({ - name: 'value', - }); - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'deep'; - fieldsetElement.appendChild(valueElement); - formElement.appendChild(inputElement); - formElement.appendChild(fieldsetElement); - - form(formElement); - - await validate(); - - await waitFor(() => { - const inputInstance = getTippy(inputElement); - expect(inputInstance?.popper).toHaveTextContent(mockErrors.test); - expect(inputElement).toHaveAttribute('aria-invalid'); - const valueInstance = getTippy(valueElement); - expect(valueInstance).toBeTruthy(); - expect(valueInstance?.popper).toHaveTextContent(mockErrors.deep.value); - expect(valueElement).toHaveAttribute('aria-invalid'); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - expect(inputElement).not.toHaveAttribute('aria-invalid'); - expect(valueElement).not.toHaveAttribute('aria-invalid'); - }); - }); - - test('show tippy on hover and hide on unhover', async () => { - const mockErrors = { test: 'A test error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createContentEditableInput({ - name: 'test', - }); - formElement.appendChild(inputElement); - - const { destroy } = form(formElement); - - await validate(); - - expect(getTippy(inputElement)).toBeTruthy(); - - userEvent.hover(inputElement); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.test); - }); - - userEvent.unhover(inputElement); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeFalsy(); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeFalsy(); - expect(tippyInstance?.state.isVisible).toBeFalsy(); - }); - - destroy(); - }); - - test('shows tippy if active element is input', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createContentEditableInput({ - name: 'test', - id: 'test', - }); - formElement.appendChild(inputElement); - - inputElement.focus(); - - form(formElement); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.test); - }); - }); - - test('focuses first invalid input and shows tippy on submit', async () => { - type Data = { - test: string; - deep: { - value: string; - }; - }; - const mockErrors = { test: 'An error', deep: { value: 'Deep error' } }; - const mockValidate = jest.fn(() => mockErrors); - const { form } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createContentEditableInput({ - name: 'test', - }); - const valueElement = createContentEditableInput({ - name: 'value', - }); - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'deep'; - fieldsetElement.appendChild(valueElement); - formElement.appendChild(fieldsetElement); - formElement.appendChild(inputElement); - - form(formElement); - - formElement.submit(); - - await waitFor(() => { - expect(valueElement).toHaveFocus(); - let tippyInstance = getTippy(valueElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.deep.value); - tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.test); - }); - }); - - test('sets custom content', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ - setContent: (messages) => { - return messages?.map((message) => `

    ${message}

    `).join(''); - }, - }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createContentEditableInput({ - name: 'test', - id: 'test', - }); - formElement.appendChild(inputElement); - - inputElement.focus(); - - form(formElement); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent( - `

    ${mockErrors.test}

    ` - ); - }); - }); - - test('sets custom props per field', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - type TestData = { - test: string; - }; - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ - tippyPropsMap: { - test: { - hideOnClick: false, - }, - }, - }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createContentEditableInput({ - name: 'test', - id: 'test', - }); - formElement.appendChild(inputElement); - - inputElement.focus(); - - form(formElement); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - }); - - userEvent.click(formElement); - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - }); - }); - - test('handles mutation of DOM', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - type TestData = { - test: string; - }; - const { form } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ - tippyPropsMap: { - test: { - hideOnClick: false, - }, - }, - }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createContentEditableInput({ - name: 'test', - id: 'test', - }); - - expect(getTippy(inputElement)).toBeFalsy(); - - form(formElement); - - formElement.appendChild(inputElement); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance).toBeTruthy(); - }); - }); -}); diff --git a/packages/reporter-tippy/tests/custom-position.spec.ts b/packages/reporter-tippy/tests/custom-position.spec.ts new file mode 100644 index 00000000..e5e13ee0 --- /dev/null +++ b/packages/reporter-tippy/tests/custom-position.spec.ts @@ -0,0 +1,323 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import userEvent from '@testing-library/user-event'; +import type { Instance, Props } from 'tippy.js'; +import { + createForm, + createDOM, + cleanupDOM, + createInputElement, +} from './common'; +import { screen, waitFor } from '@testing-library/dom'; +import reporter from '../src'; + +function getTippy(element: any): Instance | undefined { + return element?._tippy; +} + +const Reporter = suite('Reporter Tippy Custom Position'); + +Reporter.before.each(createDOM); +Reporter.after.each(cleanupDOM); + +Reporter('sets aria-invalid to input and removes if valid', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + const labelElement = document.createElement('label'); + labelElement.dataset.felteReporterTippyPositionFor = 'test'; + formElement.appendChild(labelElement); + formElement.appendChild(inputElement); + + form(formElement); + + await validate(); + + await waitFor(() => { + expect(inputElement).to.be.invalid; + }); + + mockValidate.callsFake(() => ({} as any)); + + await validate(); + + await waitFor(() => { + expect(inputElement).not.to.be.invalid; + }); +}); + +Reporter('show tippy on hover and hide on unhover', async () => { + const mockErrors = { test: 'A test error' }; + const mockValidate = sinon.stub().callsFake(() => mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + const labelElement = document.createElement('label'); + labelElement.dataset.felteReporterTippyPositionFor = 'test'; + formElement.appendChild(labelElement); + formElement.appendChild(inputElement); + + const { destroy } = form(formElement); + + await validate(); + + expect(getTippy(labelElement)).to.be.ok; + + userEvent.hover(inputElement); + + await waitFor(() => { + const tippyInstance = getTippy(labelElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + expect(tippyInstance?.popper).to.have.text.that.contains(mockErrors.test); + }); + + userEvent.unhover(inputElement); + + await waitFor(() => { + const tippyInstance = getTippy(labelElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.not.be.ok; + }); + + mockValidate.callsFake(() => ({} as any)); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(labelElement); + expect(tippyInstance?.state.isEnabled).to.not.be.ok; + expect(tippyInstance?.state.isVisible).to.not.be.ok; + }); + + destroy(); +}); + +Reporter('shows tippy if active element is input', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + id: 'test', + }); + const labelElement = document.createElement('label'); + labelElement.dataset.felteReporterTippyPositionFor = 'test'; + formElement.appendChild(labelElement); + formElement.appendChild(inputElement); + + inputElement.focus(); + + form(formElement); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(labelElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + expect(tippyInstance?.popper).to.have.text.that.contains(mockErrors.test); + }); +}); + +Reporter('focuses first invalid input and shows tippy on submit', async () => { + const mockErrors = { test: 'A test error' }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + const labelElement = document.createElement('label'); + labelElement.dataset.felteReporterTippyPositionFor = 'test'; + formElement.appendChild(labelElement); + formElement.appendChild(inputElement); + + form(formElement); + + formElement.submit(); + + await waitFor(() => { + expect(inputElement).to.have.focus; + const tippyInstance = getTippy(labelElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + expect(tippyInstance?.popper).to.have.text.that.contains(mockErrors.test); + }); +}); + +Reporter('sets custom content', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ + setContent: (messages) => { + return messages?.map((message) => `

    ${message}

    `).join(''); + }, + }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + id: 'test', + }); + const labelElement = document.createElement('label'); + labelElement.dataset.felteReporterTippyPositionFor = 'test'; + formElement.appendChild(labelElement); + formElement.appendChild(inputElement); + + inputElement.focus(); + + form(formElement); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(labelElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + expect(tippyInstance?.popper).to.have.text.that.contains( + `

    ${mockErrors.test}

    ` + ); + }); +}); + +Reporter('sets custom props per field', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.stub().returns(mockErrors); + type TestData = { + test: string; + }; + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ + tippyPropsMap: { + test: { + hideOnClick: false, + }, + }, + }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + id: 'test', + }); + const labelElement = document.createElement('label'); + labelElement.dataset.felteReporterTippyPositionFor = 'test'; + formElement.appendChild(labelElement); + formElement.appendChild(inputElement); + + inputElement.focus(); + + form(formElement); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(labelElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + }); + + userEvent.click(formElement); + await waitFor(() => { + const tippyInstance = getTippy(labelElement); + expect(tippyInstance?.state.isEnabled).to.be.ok; + expect(tippyInstance?.state.isVisible).to.be.ok; + }); +}); + +Reporter('ignores tippy', async () => { + const { form } = createForm({ + onSubmit: sinon.fake(), + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + inputElement.dataset.felteReporterTippyIgnore = ''; + const labelElement = document.createElement('label'); + labelElement.dataset.felteReporterTippyPositionFor = 'test'; + formElement.appendChild(labelElement); + formElement.appendChild(inputElement); + + form(formElement); + + await waitFor(() => { + const tippyInstance = getTippy(labelElement); + expect(tippyInstance).to.not.be.ok; + }); +}); + +Reporter('shows custom position properly on nested forms', async () => { + const { form } = createForm({ + onSubmit: sinon.fake(), + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'group.test', + type: 'text', + id: 'group-test', + }); + const fieldsetElement = document.createElement('fieldset'); + const labelElement = document.createElement('label'); + labelElement.dataset.felteReporterTippyPositionFor = 'group.test'; + labelElement.htmlFor = 'group-test'; + fieldsetElement.appendChild(labelElement); + fieldsetElement.appendChild(inputElement); + formElement.appendChild(fieldsetElement); + + form(formElement); + + await waitFor(() => { + expect(getTippy(labelElement)).to.be.ok; + expect(getTippy(inputElement)).to.not.be.ok; + }); +}); + +Reporter.run(); diff --git a/packages/reporter-tippy/tests/custom-position.test.ts b/packages/reporter-tippy/tests/custom-position.test.ts deleted file mode 100644 index 45b2627d..00000000 --- a/packages/reporter-tippy/tests/custom-position.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen, waitFor } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import type { Instance, Props } from 'tippy.js'; -import { createForm } from 'felte'; -import { createDOM, cleanupDOM, createInputElement } from './common'; -import reporter from '../src'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn() })); - -function getTippy(element: any): Instance | undefined { - return element?._tippy; -} - -describe('Reporter Tippy Custom Position', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('sets aria-invalid to input and removes if valid', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - const labelElement = document.createElement('label'); - labelElement.dataset.felteReporterTippyPositionFor = 'test'; - formElement.appendChild(labelElement); - formElement.appendChild(inputElement); - - form(formElement); - - await validate(); - - await waitFor(() => { - expect(inputElement).toHaveAttribute('aria-invalid'); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - expect(inputElement).not.toHaveAttribute('aria-invalid'); - }); - }); - - test('show tippy on hover and hide on unhover', async () => { - const mockErrors = { test: 'A test error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - const labelElement = document.createElement('label'); - labelElement.dataset.felteReporterTippyPositionFor = 'test'; - formElement.appendChild(labelElement); - formElement.appendChild(inputElement); - - const { destroy } = form(formElement); - - await validate(); - - expect(getTippy(labelElement)).toBeTruthy(); - - userEvent.hover(inputElement); - - await waitFor(() => { - const tippyInstance = getTippy(labelElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.test); - }); - - userEvent.unhover(inputElement); - - await waitFor(() => { - const tippyInstance = getTippy(labelElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeFalsy(); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(labelElement); - expect(tippyInstance?.state.isEnabled).toBeFalsy(); - expect(tippyInstance?.state.isVisible).toBeFalsy(); - }); - - destroy(); - }); - - test('shows tippy if active element is input', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - const labelElement = document.createElement('label'); - labelElement.dataset.felteReporterTippyPositionFor = 'test'; - formElement.appendChild(labelElement); - formElement.appendChild(inputElement); - - inputElement.focus(); - - form(formElement); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(labelElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.test); - }); - }); - - test('focuses first invalid input and shows tippy on submit', async () => { - const mockErrors = { test: 'A test error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - const labelElement = document.createElement('label'); - labelElement.dataset.felteReporterTippyPositionFor = 'test'; - formElement.appendChild(labelElement); - formElement.appendChild(inputElement); - - form(formElement); - - formElement.submit(); - - await waitFor(() => { - expect(inputElement).toHaveFocus(); - const tippyInstance = getTippy(labelElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.test); - }); - }); - - test('sets custom content', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ - setContent: (messages) => { - return messages?.map((message) => `

    ${message}

    `).join(''); - }, - }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - const labelElement = document.createElement('label'); - labelElement.dataset.felteReporterTippyPositionFor = 'test'; - formElement.appendChild(labelElement); - formElement.appendChild(inputElement); - - inputElement.focus(); - - form(formElement); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(labelElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent( - `

    ${mockErrors.test}

    ` - ); - }); - }); - - test('sets custom props per field', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - type TestData = { - test: string; - }; - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ - tippyPropsMap: { - test: { - hideOnClick: false, - }, - }, - }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - const labelElement = document.createElement('label'); - labelElement.dataset.felteReporterTippyPositionFor = 'test'; - formElement.appendChild(labelElement); - formElement.appendChild(inputElement); - - inputElement.focus(); - - form(formElement); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(labelElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - }); - - userEvent.click(formElement); - await waitFor(() => { - const tippyInstance = getTippy(labelElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - }); - }); - - test('ignores tippy', async () => { - const { form } = createForm({ - onSubmit: jest.fn(), - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - inputElement.dataset.felteReporterTippyIgnore = ''; - const labelElement = document.createElement('label'); - labelElement.dataset.felteReporterTippyPositionFor = 'test'; - formElement.appendChild(labelElement); - formElement.appendChild(inputElement); - - form(formElement); - - await waitFor(() => { - const tippyInstance = getTippy(labelElement); - expect(tippyInstance).toBeFalsy(); - }); - }); - - test('shows custom position properly on nested forms', async () => { - const { form } = createForm({ - onSubmit: jest.fn(), - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'group-test', - }); - const fieldsetElement = document.createElement('fieldset'); - fieldsetElement.name = 'group'; - const labelElement = document.createElement('label'); - labelElement.dataset.felteReporterTippyPositionFor = 'group.test'; - labelElement.htmlFor = 'group-test'; - fieldsetElement.appendChild(labelElement); - fieldsetElement.appendChild(inputElement); - formElement.appendChild(fieldsetElement); - - form(formElement); - - await waitFor(() => { - expect(getTippy(labelElement)).toBeTruthy(); - expect(getTippy(inputElement)).toBeFalsy(); - }); - }); -}); diff --git a/packages/reporter-tippy/tests/reporter.spec.ts b/packages/reporter-tippy/tests/reporter.spec.ts new file mode 100644 index 00000000..a5dacb79 --- /dev/null +++ b/packages/reporter-tippy/tests/reporter.spec.ts @@ -0,0 +1,430 @@ +import * as sinon from 'sinon'; +import { suite } from 'uvu'; +import { expect } from 'uvu-expect'; +import userEvent from '@testing-library/user-event'; +import type { Instance, Props } from 'tippy.js'; +import { + createForm, + createDOM, + cleanupDOM, + createInputElement, + createMultipleInputElements, +} from './common'; +import { screen, waitFor } from '@testing-library/dom'; +import reporter from '../src'; + +function getTippy(element: any): Instance | undefined { + return element?._tippy; +} + +const Reporter = suite('Reporter Tippy'); + +Reporter.before.each(createDOM); +Reporter.after.each(cleanupDOM); + +Reporter('sets aria-invalid to input and removes if valid', async () => { + const mockErrors = { + test: 'An error', + multiple: new Array(3).fill('An error'), + }; + const mockValidate = sinon.stub().returns(mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + const multipleInputs = createMultipleInputElements({ + name: 'multiple', + type: 'text', + }); + const multipleMessages = multipleInputs.map((el) => { + const mes = document.createElement('div'); + mes.setAttribute('data-felte-reporter-dom-for', el.name); + return mes; + }); + formElement.appendChild(inputElement); + formElement.append(...multipleInputs, ...multipleMessages); + + form(formElement); + + await validate(); + + await waitFor(() => { + expect(inputElement).to.be.invalid; + multipleInputs.forEach((input) => expect(input).to.be.invalid); + }); + + mockValidate.callsFake(() => ({} as any)); + + await validate(); + + await waitFor(() => { + expect(inputElement).not.to.be.invalid; + multipleInputs.forEach((input) => expect(input).not.to.be.invalid); + }); +}); + +Reporter('show tippy on hover and hide on unhover', async () => { + const mockErrors = { + test: 'A test error', + multiple: new Array(3).fill('An error'), + }; + const mockValidate = sinon.stub().callsFake(() => mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + const multipleInputs = createMultipleInputElements({ + name: 'multiple', + type: 'text', + }); + const multipleMessages = multipleInputs.map((el) => { + const mes = document.createElement('div'); + mes.setAttribute('data-felte-reporter-dom-for', el.name); + return mes; + }); + formElement.appendChild(inputElement); + formElement.append(...multipleInputs, ...multipleMessages); + + const { destroy } = form(formElement); + + await validate(); + + expect(getTippy(inputElement)).to.be.ok; + + userEvent.hover(inputElement); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.true; + expect(tippyInstance?.popper).to.have.text.that.contains(mockErrors.test); + }); + + userEvent.unhover(inputElement); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.false; + }); + + for (const input of multipleInputs) { + expect(getTippy(input)).to.be.ok; + + userEvent.hover(input); + + await waitFor(() => { + const tippyInstance = getTippy(input); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.true; + expect(tippyInstance?.popper).to.have.text.that.contains( + mockErrors.multiple[0] + ); + }); + + userEvent.unhover(input); + + await waitFor(() => { + const tippyInstance = getTippy(input); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.false; + }); + } + + mockValidate.callsFake(() => ({} as any)); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.false; + expect(tippyInstance?.state.isVisible).to.be.false; + }); + + await waitFor(() => { + for (const input of multipleInputs) { + const tippyInstance = getTippy(input); + expect(tippyInstance?.state.isEnabled).to.be.false; + expect(tippyInstance?.state.isVisible).to.be.false; + } + }); + + destroy(); +}); + +Reporter('shows tippy if active element is input', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.fake(() => mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + id: 'test', + }); + formElement.appendChild(inputElement); + + inputElement.focus(); + + form(formElement); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.true; + expect(tippyInstance?.popper).to.have.text.that.contains(mockErrors.test); + }); +}); + +Reporter('focuses first invalid input and shows tippy on submit', async () => { + const mockErrors = { test: 'A test error' }; + const mockValidate = sinon.fake(() => mockErrors); + const { form } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + formElement.appendChild(inputElement); + + form(formElement); + + formElement.submit(); + + await waitFor(() => { + expect(inputElement).to.be.focused; + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.true; + expect(tippyInstance?.popper).to.have.text.that.contains(mockErrors.test); + }); +}); + +Reporter('sets custom content', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.fake(() => mockErrors); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ + setContent: (messages) => { + return messages?.map((message) => `

    ${message}

    `).join(''); + }, + }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + id: 'test', + }); + formElement.appendChild(inputElement); + + inputElement.focus(); + + form(formElement); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.true; + expect(tippyInstance?.popper).to.have.text.that.contains( + `

    ${mockErrors.test}

    ` + ); + }); +}); + +Reporter('sets custom props per field', async () => { + const mockErrors = { test: 'An error' }; + const mockValidate = sinon.fake(() => mockErrors); + type TestData = { + test: string; + }; + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + validate: mockValidate, + extend: reporter({ + tippyPropsMap: { + test: { + hideOnClick: false, + }, + }, + }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + id: 'test', + }); + formElement.appendChild(inputElement); + + inputElement.focus(); + + form(formElement); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.true; + }); + + userEvent.click(formElement); + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.true; + }); +}); + +Reporter('ignores tippy', async () => { + const { form } = createForm({ + onSubmit: sinon.fake(), + extend: reporter(), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + inputElement.dataset.felteReporterTippyIgnore = ''; + formElement.appendChild(inputElement); + + form(formElement); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance).to.not.be.ok; + }); +}); + +Reporter('show warning tippy on hover and hide on unhover', async () => { + const mockWarnings = { + test: 'A test warning', + multiple: new Array(3).fill('A warning'), + }; + const mockWarn = sinon.stub().callsFake(() => mockWarnings); + const { form, validate } = createForm({ + onSubmit: sinon.fake(), + warn: mockWarn, + extend: reporter({ level: 'warning' }), + }); + + const formElement = screen.getByRole('form') as HTMLFormElement; + const inputElement = createInputElement({ + name: 'test', + type: 'text', + }); + const multipleInputs = createMultipleInputElements({ + name: 'multiple', + type: 'text', + }); + const multipleMessages = multipleInputs.map((el) => { + const mes = document.createElement('div'); + mes.setAttribute('data-felte-reporter-dom-for', el.name); + return mes; + }); + formElement.appendChild(inputElement); + formElement.append(...multipleInputs, ...multipleMessages); + + const { destroy } = form(formElement); + + await validate(); + + expect(getTippy(inputElement)).to.be.ok; + + userEvent.hover(inputElement); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.true; + expect(tippyInstance?.popper).to.have.text.that.contains(mockWarnings.test); + }); + + userEvent.unhover(inputElement); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.false; + }); + + for (const input of multipleInputs) { + expect(getTippy(input)).to.be.ok; + + userEvent.hover(input); + + await waitFor(() => { + const tippyInstance = getTippy(input); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.true; + expect(tippyInstance?.popper).to.have.text.that.contains( + mockWarnings.multiple[0] + ); + }); + + userEvent.unhover(input); + + await waitFor(() => { + const tippyInstance = getTippy(input); + expect(tippyInstance?.state.isEnabled).to.be.true; + expect(tippyInstance?.state.isVisible).to.be.false; + }); + } + + mockWarn.callsFake(() => ({} as any)); + + await validate(); + + await waitFor(() => { + const tippyInstance = getTippy(inputElement); + expect(tippyInstance?.state.isEnabled).to.not.be.ok; + expect(tippyInstance?.state.isVisible).to.not.be.ok; + }); + + await waitFor(() => { + for (const input of multipleInputs) { + const tippyInstance = getTippy(input); + expect(tippyInstance?.state.isEnabled).to.not.be.ok; + expect(tippyInstance?.state.isVisible).to.not.be.ok; + } + }); + + destroy(); +}); + +Reporter.run(); diff --git a/packages/reporter-tippy/tests/reporter.test.ts b/packages/reporter-tippy/tests/reporter.test.ts deleted file mode 100644 index 7d71f968..00000000 --- a/packages/reporter-tippy/tests/reporter.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -import '@testing-library/jest-dom/extend-expect'; -import { screen, waitFor } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import type { Instance, Props } from 'tippy.js'; -import { createForm } from 'felte'; -import { - createDOM, - cleanupDOM, - createInputElement, - createMultipleInputElements, -} from './common'; -import reporter from '../src'; - -jest.mock('svelte', () => ({ onDestroy: jest.fn() })); - -function getTippy(element: any): Instance | undefined { - return element?._tippy; -} - -describe('Reporter Tippy', () => { - beforeEach(createDOM); - - afterEach(cleanupDOM); - - test('sets aria-invalid to input and removes if valid', async () => { - const mockErrors = { - test: 'An error', - multiple: new Array(3).fill('An error'), - }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - const multipleInputs = createMultipleInputElements({ - name: 'multiple', - type: 'text', - }); - const multipleMessages = multipleInputs.map((el, index) => { - const mes = document.createElement('div'); - mes.setAttribute('data-felte-reporter-dom-for', el.name); - mes.setAttribute('data-felte-index', String(index)); - return mes; - }); - formElement.appendChild(inputElement); - formElement.append(...multipleInputs, ...multipleMessages); - - form(formElement); - - await validate(); - - await waitFor(() => { - expect(inputElement).toHaveAttribute('aria-invalid'); - multipleInputs.forEach((input) => - expect(input).toHaveAttribute('aria-invalid') - ); - }); - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - expect(inputElement).not.toHaveAttribute('aria-invalid'); - multipleInputs.forEach((input) => - expect(input).not.toHaveAttribute('aria-invalid') - ); - }); - }); - - test('show tippy on hover and hide on unhover', async () => { - const mockErrors = { - test: 'A test error', - multiple: new Array(3).fill('An error'), - }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - const multipleInputs = createMultipleInputElements({ - name: 'multiple', - type: 'text', - }); - const multipleMessages = multipleInputs.map((el, index) => { - const mes = document.createElement('div'); - mes.setAttribute('data-felte-reporter-dom-for', el.name); - mes.setAttribute('data-felte-index', String(index)); - return mes; - }); - formElement.appendChild(inputElement); - formElement.append(...multipleInputs, ...multipleMessages); - - const { destroy } = form(formElement); - - await validate(); - - expect(getTippy(inputElement)).toBeTruthy(); - - userEvent.hover(inputElement); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.test); - }); - - userEvent.unhover(inputElement); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeFalsy(); - }); - - for (const input of multipleInputs) { - expect(getTippy(input)).toBeTruthy(); - - userEvent.hover(input); - - await waitFor(() => { - const tippyInstance = getTippy(input); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.multiple[0]); - }); - - userEvent.unhover(input); - - await waitFor(() => { - const tippyInstance = getTippy(input); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeFalsy(); - }); - } - - mockValidate.mockImplementation(() => ({} as any)); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeFalsy(); - expect(tippyInstance?.state.isVisible).toBeFalsy(); - }); - - await waitFor(() => { - for (const input of multipleInputs) { - const tippyInstance = getTippy(input); - expect(tippyInstance?.state.isEnabled).toBeFalsy(); - expect(tippyInstance?.state.isVisible).toBeFalsy(); - } - }); - - destroy(); - }); - - test('shows tippy if active element is input', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - formElement.appendChild(inputElement); - - inputElement.focus(); - - form(formElement); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.test); - }); - }); - - test('focuses first invalid input and shows tippy on submit', async () => { - const mockErrors = { test: 'A test error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - formElement.appendChild(inputElement); - - form(formElement); - - formElement.submit(); - - await waitFor(() => { - expect(inputElement).toHaveFocus(); - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent(mockErrors.test); - }); - }); - - test('sets custom content', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ - setContent: (messages) => { - return messages?.map((message) => `

    ${message}

    `).join(''); - }, - }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - formElement.appendChild(inputElement); - - inputElement.focus(); - - form(formElement); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - expect(tippyInstance?.popper).toHaveTextContent( - `

    ${mockErrors.test}

    ` - ); - }); - }); - - test('sets custom props per field', async () => { - const mockErrors = { test: 'An error' }; - const mockValidate = jest.fn(() => mockErrors); - type TestData = { - test: string; - }; - const { form, validate } = createForm({ - onSubmit: jest.fn(), - validate: mockValidate, - extend: reporter({ - tippyPropsMap: { - test: { - hideOnClick: false, - }, - }, - }), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - id: 'test', - }); - formElement.appendChild(inputElement); - - inputElement.focus(); - - form(formElement); - - await validate(); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - }); - - userEvent.click(formElement); - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance?.state.isEnabled).toBeTruthy(); - expect(tippyInstance?.state.isVisible).toBeTruthy(); - }); - }); - - test('ignores tippy', async () => { - const { form } = createForm({ - onSubmit: jest.fn(), - extend: reporter(), - }); - - const formElement = screen.getByRole('form') as HTMLFormElement; - const inputElement = createInputElement({ - name: 'test', - type: 'text', - }); - inputElement.dataset.felteReporterTippyIgnore = ''; - formElement.appendChild(inputElement); - - form(formElement); - - await waitFor(() => { - const tippyInstance = getTippy(inputElement); - expect(tippyInstance).toBeFalsy(); - }); - }); -}); diff --git a/packages/site/CHANGELOG.md b/packages/site/CHANGELOG.md index 57e1db33..23c45b4d 100644 --- a/packages/site/CHANGELOG.md +++ b/packages/site/CHANGELOG.md @@ -1,5 +1,40 @@ # @felte/site +## 0.0.9-next.1 + +### Patch Changes + +- a174e87: Check for strict equality on value change +- Updated dependencies [02fd56e] +- Updated dependencies [c412050] + - @felte/reporter-dom@1.0.0-next.18 + - @felte/reporter-svelte@1.0.0-next.18 + - @felte/reporter-tippy@1.0.0-next.19 + - felte@1.0.0-next.22 + - @felte/validator-yup@1.0.0-next.18 + +## 0.0.9-next.0 + +### Patch Changes + +- Updated dependencies [1bc036e] +- Updated dependencies [0c01eab] +- Updated dependencies [c9f9d9f] +- Updated dependencies [f59f31e] +- Updated dependencies [a2ea0b2] +- Updated dependencies [1dd68e7] +- Updated dependencies [6109533] +- Updated dependencies [9a48a40] +- Updated dependencies [0d22bc6] +- Updated dependencies [3d571bb] +- Updated dependencies [c1f32a0] +- Updated dependencies [2c0f874] + - @felte/reporter-dom@1.0.0-next.0 + - @felte/reporter-svelte@1.0.0-next.0 + - @felte/reporter-tippy@1.0.0-next.0 + - felte@1.0.0-next.0 + - @felte/validator-yup@1.0.0-next.0 + ## 0.0.8 ### Patch Changes diff --git a/packages/site/markdown/docs/latest/react/en/accessibility.md b/packages/site/markdown/docs/latest/react/en/accessibility.md new file mode 100644 index 00000000..4342369b --- /dev/null +++ b/packages/site/markdown/docs/latest/react/en/accessibility.md @@ -0,0 +1,14 @@ +--- +section: Accessibility +--- + +## Accessibility + +Felte's main package only cares about handling the reactivity of your form. In this sense, you should be the one taking into consideration the accessibility of it, like the correct use of labels, proper color contrast, focus management, etc. + +That said, Felte _does_ set `aria-invalid` to the inputs it controls when they contain validation errors. Also, the `reporter` packages do have some accessibility considerations built-in: + +- **`@felte/reporter-tippy`** sets an `aria-live` attribute to the tooltip instance itself to announce when an error appears. Tippy itself also adds an `aria-describedby` attribute to your input. If there are errors on submit, it'll focus the first invalid element of your form. +- **`@felte/reporter-dom`** sets an `aria-live` attribute to the added nodes containing your validation messages. It also focuses the first invalid input if therer are errors on submit. +- **`@felte/reporter-cvapi`** while not recommended due to its non-friendly behaviour for mobile users, it is accessible by default since it uses the browser's built-in validation mechanism. +- **`@felte/reporter-react`** does not take any accessibility considerations since its only job is to give you the validation messages. You should set an `aria-live` when necessary. diff --git a/packages/site/markdown/docs/latest/react/en/configuration-reference.md b/packages/site/markdown/docs/latest/react/en/configuration-reference.md new file mode 100644 index 00000000..cdcc9144 --- /dev/null +++ b/packages/site/markdown/docs/latest/react/en/configuration-reference.md @@ -0,0 +1,26 @@ +--- +section: Configuration reference +--- + +## Configuration reference + +Felte's `createForm` function accepts a configuration object with the following properties: + +- **onSubmit**: (See [Submitting](/docs/react/submitting#custom-handler)) a function that receives as a first argument the submitted data, and as a second argument a `context` object with the following properties: + - **form**: the native HTML form element. + - **controls**: the form's native HTML controls. + - **config**: the current configuration of the form. +- **validate**: a validation function (or array of functions) that receives the data to validate and returns an `errors` object, or `undefined` if the data is valid. The validation functions may be asynchronous. See [Validation](/docs/solid/validation). +- **warn**: a validation function (or array of functions) that receives the data to validate and returns an object with the same shape as `data`, but with warning messages or `undefined` as values. It can be an array of functions whose validation errors will be merged. See [Validation](/docs/solid/validation#warnings). +- **transform**: a transformation function (or array of functions) that receives the data coming from your form and returns a `data` object. See [Transformations](/docs/solid/transformations). +- **onSuccess**: a function that receives anything returned from the `onSubmit` function, or the `Response` object from `fetch` if no `onSubmit` function is provided. Useful for reacting when a form is succesfully submitted. +- **onError**: a function that receives any unhandled errors thrown in the `onSubmit` function, or an instance of `FelteSubmitError` if no `onSubmit` handler is provided. Useful for handling [server errors](/docs/solid/validation#server-errors). +- **extend**: an extender or array of extenders. Currently these are either [validators](/docs/solid/validators) or [reporters](/docs/solid/reporters). Note that extenders may add/change this same configuration object. Check their documentation when adding one. +- **debounced**: (See [Validation](/docs/react/validation#debounced-validations)) an object that contains the validations you'd like to be debounced (executed a certain amount of time after the user stops interacting with your form). It contain the following properties: + - **validate**: the same as the `validate` function described above. + - **warn**: the same as the `warn` function described above. + - **timeout**: time in milliseconds to wait after the user stops interacting with your form before running the validation functions. Defaults to 300. + - **validateTimeout**: time in milliseconds that overrides the value of `timeout` for your `validate` functions. + - **warnTimeout**: time in milliseconds that overrides the value of `timeout` for your `warn` functions. + +> Every property is optional. diff --git a/packages/site/markdown/docs/latest/react/en/custom-form-controls.md b/packages/site/markdown/docs/latest/react/en/custom-form-controls.md new file mode 100644 index 00000000..cce8a6e4 --- /dev/null +++ b/packages/site/markdown/docs/latest/react/en/custom-form-controls.md @@ -0,0 +1,92 @@ +--- +section: Custom form controls +subsections: + - Using helpers + - Using useField +--- + +## Custom form controls + +If for some reason you're not using an HTML5 input, select or textarea element as an input, you have two options: + +### Using helpers + +The more straightforward way would be to use any of the returned [helpers from `useForm`](/docs/react/helper-functions) for handling inputs. + +```jsx +import { useForm } from '@felte/react'; + +export function Form() { + const { form, setFields } = useForm({ + initialValues: { + customControlName: '', + }, + // ... + }); + + function handleChange(event) { + setFields('customControlName', event.detail.value, true); + } + + return ( +
    + + + ); +} +``` + +If your custom form control uses an `input` or other native form control behind the scenes, you may dispatch an `input` or `change` event from it when the value of it changes (if your control does not do this already). Felte listens to `change` events for ``, ``, `` elements; and for `input` events on any other type of `input`. + +### Using useField + +You might want to create a shareable component that should work with Felte without needing to use any of the helpers. If said component uses a native HTML control, and your user interacts directly with it, then assigning a `name` to it should be enough for Felte to manage it. But there might be situations on which the control itself is completely custom made, maybe using an `input[type="hidden"]` behind the scenes or not using a native control at all (e.g. a `contenteditable` div). For this situations you can use `useField`. + +`useField` provides you with some helpers to make your custom control visible to Felte without needing to use any of `useForm`'s helpers. Its usage looks something like: + +```jsx +import React from 'react'; +import { useField } from '@felte/react'; + +function CustomInput({ name, labelId }) { + const { field, onChange, onBlur } = useField(name); + + function handleChange(e) { + onChange(e.currentTarget.innerText); + } + + return ( +
    + ); +} +``` + +The previous component will behave just like a regular `input` when used within a form managed by Felte. Only requiring a `name` prop. + +`useField` can be called in two different ways: + +- With the name as a first argument, and optionally an `options` objects as a second argument. +- With an `options` object as first argument containig `name` as a property of it. + +The options accepted by `useField` are: + +- `name`: only when passing `options` as first argument. It's the `name` of the field. +- `defaultValue`: (Optional) the field's default value. Defaults to `undefined`. +- `touchOnChange`: (Optional) if set to `true`, the field will be marked as "touched" with a call to `onChange`. If `false`, the field will only be marked as "touched" when calling `onBlur`. Defaults to `false`. + +`useField` returns an object with the following properties: + +- `field`: a ref that _must_ be assigned to the focusable (tabIndex === 0) element of your control. It must be assigned in order for the following functions to do anything. +- `onChange`: a function that receives a value to be assigned to the field. +- `onInput`: an alias for `onChange`. +- `onBlur`: a function that needs to be called in order to mark a field as "touched" if `touchOnChange` is `false`. Not needed if `touchOnChange` is `true`. Useful if your custom control should behave like a text box. + +> **NOTE**: when creating custom controls like this, be mindful of the accessibility of your component. Handling proper keyboard interactions, roles and labels is a must for your custom control to be seen as an input by assistive technologies. diff --git a/packages/site/markdown/docs/latest/react/en/default-data.md b/packages/site/markdown/docs/latest/react/en/default-data.md new file mode 100644 index 00000000..8c701db9 --- /dev/null +++ b/packages/site/markdown/docs/latest/react/en/default-data.md @@ -0,0 +1,29 @@ +--- +section: Default data +--- + +## Default data + +Felte will take as default values whatever is set first in the HTML as a value/checked attribute (if the input is "controlled" by Felte). + +```html +
    + +
    +``` + +In this case, Felte will take `default@email.com` as a default value for the "email" field. + +An alternative to this would be to use the `initialValues` property of the configuration for `useForm`. This can also be useful if the form element is not controlled by Felte and you're binding directly to the `data` store. + +```javascript +const { form } = useForm({ + /* ... */ + initialValues: { + account: { + email: 'default@email.dev', + }, + }, + /* ... */ +}); +``` diff --git a/packages/site/markdown/docs/latest/react/en/dynamic-forms.md b/packages/site/markdown/docs/latest/react/en/dynamic-forms.md new file mode 100644 index 00000000..27d73e46 --- /dev/null +++ b/packages/site/markdown/docs/latest/react/en/dynamic-forms.md @@ -0,0 +1,45 @@ +--- +section: Dynamic forms +--- + +## Dynamic forms + +Felte watches for any added or removed form controls in your form, updating the `data`, `errors` and `touched` stores accordingly. + +```tsx +import { useState } from 'react'; +import { useForm } from '@felte/react'; + +function Form() { + const { form } = useForm({ /* ... */ }); + const [hasBio, setHasBio] = useState(false); + const handleChange = () => setHasBio(v => !v); + + return ( +
    + + + {hasBio &&