From 1f71d8035b4251f133333cfa35660070a5423492 Mon Sep 17 00:00:00 2001 From: Ido S Date: Sun, 24 Dec 2023 17:19:26 +0200 Subject: [PATCH] feat: form state (ASPX) (#3) * fix: remove formidable deps * feat: button pass props to onClick * feat: view-state --- examples/simple-form/src/pages/button.astro | 1 - examples/simple-form/src/pages/index.astro | 22 +- package-lock.json | 312 +++++++++++++++++- .../src/http/express-request.ts | 7 +- packages/forms/components/WebForms.astro | 28 -- packages/forms/components/form/BButton.astro | 34 -- packages/forms/components/form/BindForm.astro | 23 -- packages/forms/forms.ts | 18 +- packages/forms/package.json | 12 +- .../form-utils/about-form-name.ts | 20 +- .../bind-form-plugins/iform-plugin.ts | 13 +- .../bind-form-plugins/input-radio.ts | 14 +- .../form-utils/bind-form-plugins/select.ts | 26 +- .../form-utils/bind-form.ts | 58 ++++ .../form-utils/parse-multi.ts | 6 +- .../form-utils/parse.ts | 18 +- .../form-utils/validate.ts | 26 +- .../form-utils/view-state.ts | 81 +++++ .../input-parse.ts | 88 ++--- .../src/components-control/props-utils.ts | 23 ++ .../select.ts | 28 +- packages/forms/src/components/WebForms.astro | 36 ++ .../src/components/form-utils/bind-form.ts | 46 --- .../forms/src/components/form/BButton.astro | 67 ++++ .../{ => src}/components/form/BInput.astro | 8 +- .../{ => src}/components/form/BOption.astro | 6 +- .../{ => src}/components/form/BSelect.astro | 7 +- .../{ => src}/components/form/BTextarea.astro | 8 +- .../forms/src/components/form/BindForm.astro | 32 ++ .../components/form/FormErrors.astro | 10 +- packages/forms/src/form-tools/csrf.ts | 32 +- packages/forms/src/form-tools/forms-react.ts | 99 ++++++ packages/forms/src/form-tools/post.ts | 21 +- packages/forms/src/index.ts | 5 +- packages/forms/src/jwt-session.ts | 2 +- packages/forms/src/middleware.ts | 29 +- packages/forms/src/settings.ts | 22 +- packages/forms/src/utils.ts | 34 +- packages/forms/tsconfig.json | 7 +- 39 files changed, 974 insertions(+), 355 deletions(-) delete mode 100644 packages/forms/components/WebForms.astro delete mode 100644 packages/forms/components/form/BButton.astro delete mode 100644 packages/forms/components/form/BindForm.astro rename packages/forms/src/{components => components-control}/form-utils/about-form-name.ts (73%) rename packages/forms/src/{components => components-control}/form-utils/bind-form-plugins/iform-plugin.ts (59%) rename packages/forms/src/{components => components-control}/form-utils/bind-form-plugins/input-radio.ts (79%) rename packages/forms/src/{components => components-control}/form-utils/bind-form-plugins/select.ts (74%) create mode 100644 packages/forms/src/components-control/form-utils/bind-form.ts rename packages/forms/src/{components => components-control}/form-utils/parse-multi.ts (83%) rename packages/forms/src/{components => components-control}/form-utils/parse.ts (81%) rename packages/forms/src/{components => components-control}/form-utils/validate.ts (69%) create mode 100644 packages/forms/src/components-control/form-utils/view-state.ts rename packages/forms/src/{components => components-control}/input-parse.ts (60%) create mode 100644 packages/forms/src/components-control/props-utils.ts rename packages/forms/src/{components => components-control}/select.ts (59%) create mode 100644 packages/forms/src/components/WebForms.astro delete mode 100644 packages/forms/src/components/form-utils/bind-form.ts create mode 100644 packages/forms/src/components/form/BButton.astro rename packages/forms/{ => src}/components/form/BInput.astro (87%) rename packages/forms/{ => src}/components/form/BOption.astro (85%) rename packages/forms/{ => src}/components/form/BSelect.astro (83%) rename packages/forms/{ => src}/components/form/BTextarea.astro (80%) create mode 100644 packages/forms/src/components/form/BindForm.astro rename packages/forms/{ => src}/components/form/FormErrors.astro (54%) create mode 100644 packages/forms/src/form-tools/forms-react.ts diff --git a/examples/simple-form/src/pages/button.astro b/examples/simple-form/src/pages/button.astro index 294de97..89390f5 100644 --- a/examples/simple-form/src/pages/button.astro +++ b/examples/simple-form/src/pages/button.astro @@ -6,7 +6,6 @@ import Layout from '../layouts/Layout.astro'; let showSubmitText: string; - function formSubmit() { Astro.locals.session.counter ??= 0; Astro.locals.session.counter++; diff --git a/examples/simple-form/src/pages/index.astro b/examples/simple-form/src/pages/index.astro index 9eaa6f7..f74b00e 100644 --- a/examples/simple-form/src/pages/index.astro +++ b/examples/simple-form/src/pages/index.astro @@ -5,17 +5,35 @@ import {Button} from 'reactstrap'; import 'bootstrap/dist/css/bootstrap.css'; -const form = Bind({age: 0, name: ''}); +const form = Bind({age: 0, name: '', about: ''}); let showSubmitText: string; function formSubmit(){ Astro.locals.session.counter ??= 0; Astro.locals.session.counter++; showSubmitText = `Your name is ${form.name}, you are ${form.age} years old. `; + form.age++; +} + +let value = 4; +function makeBackgroundRed() { + this.style = 'background-color: red'; + value = 5; + this.innerHTML = 'Clicked ' + (++this.extra || ++this.state.counter); + form.about = 'This is a form about something'; } --- - + Should click + + + {[1, 2, 3].map(key => + Should click + )} + +

Value: {value}

+

About: {form.about}

+ {showSubmitText} diff --git a/package-lock.json b/package-lock.json index 566e606..54a15fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -852,6 +852,201 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/snappy-android-arm-eabi": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-android-arm-eabi/-/snappy-android-arm-eabi-7.2.2.tgz", + "integrity": "sha512-H7DuVkPCK5BlAr1NfSU8bDEN7gYs+R78pSHhDng83QxRnCLmVIZk33ymmIwurmoA1HrdTxbkbuNl+lMvNqnytw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-android-arm64": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-android-arm64/-/snappy-android-arm64-7.2.2.tgz", + "integrity": "sha512-2R/A3qok+nGtpVK8oUMcrIi5OMDckGYNoBLFyli3zp8w6IArPRfg1yOfVUcHvpUDTo9T7LOS1fXgMOoC796eQw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-darwin-arm64": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-darwin-arm64/-/snappy-darwin-arm64-7.2.2.tgz", + "integrity": "sha512-USgArHbfrmdbuq33bD5ssbkPIoT7YCXCRLmZpDS6dMDrx+iM7eD2BecNbOOo7/v1eu6TRmQ0xOzeQ6I/9FIi5g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-darwin-x64": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-darwin-x64/-/snappy-darwin-x64-7.2.2.tgz", + "integrity": "sha512-0APDu8iO5iT0IJKblk2lH0VpWSl9zOZndZKnBYIc+ei1npw2L5QvuErFOTeTdHBtzvUHASB+9bvgaWnQo4PvTQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-freebsd-x64": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-freebsd-x64/-/snappy-freebsd-x64-7.2.2.tgz", + "integrity": "sha512-mRTCJsuzy0o/B0Hnp9CwNB5V6cOJ4wedDTWEthsdKHSsQlO7WU9W1yP7H3Qv3Ccp/ZfMyrmG98Ad7u7lG58WXA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-arm-gnueabihf": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-arm-gnueabihf/-/snappy-linux-arm-gnueabihf-7.2.2.tgz", + "integrity": "sha512-v1uzm8+6uYjasBPcFkv90VLZ+WhLzr/tnfkZ/iD9mHYiULqkqpRuC8zvc3FZaJy5wLQE9zTDkTJN1IvUcZ+Vcg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-arm64-gnu": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-arm64-gnu/-/snappy-linux-arm64-gnu-7.2.2.tgz", + "integrity": "sha512-LrEMa5pBScs4GXWOn6ZYXfQ72IzoolZw5txqUHVGs8eK4g1HR9HTHhb2oY5ySNaKakG5sOgMsb1rwaEnjhChmQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-arm64-musl": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-arm64-musl/-/snappy-linux-arm64-musl-7.2.2.tgz", + "integrity": "sha512-3orWZo9hUpGQcB+3aTLW7UFDqNCQfbr0+MvV67x8nMNYj5eAeUtMmUE/HxLznHO4eZ1qSqiTwLbVx05/Socdlw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-x64-gnu": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-x64-gnu/-/snappy-linux-x64-gnu-7.2.2.tgz", + "integrity": "sha512-jZt8Jit/HHDcavt80zxEkDpH+R1Ic0ssiVCoueASzMXa7vwPJeF4ZxZyqUw4qeSy7n8UUExomu8G8ZbP6VKhgw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-x64-musl": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-x64-musl/-/snappy-linux-x64-musl-7.2.2.tgz", + "integrity": "sha512-Dh96IXgcZrV39a+Tej/owcd9vr5ihiZ3KRix11rr1v0MWtVb61+H1GXXlz6+Zcx9y8jM1NmOuiIuJwkV4vZ4WA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-win32-arm64-msvc": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-win32-arm64-msvc/-/snappy-win32-arm64-msvc-7.2.2.tgz", + "integrity": "sha512-9No0b3xGbHSWv2wtLEn3MO76Yopn1U2TdemZpCaEgOGccz1V+a/1d16Piz3ofSmnA13HGFz3h9NwZH9EOaIgYA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-win32-ia32-msvc": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-win32-ia32-msvc/-/snappy-win32-ia32-msvc-7.2.2.tgz", + "integrity": "sha512-QiGe+0G86J74Qz1JcHtBwM3OYdTni1hX1PFyLRo3HhQUSpmi13Bzc1En7APn+6Pvo7gkrcy81dObGLDSxFAkQQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-win32-x64-msvc": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-win32-x64-msvc/-/snappy-win32-x64-msvc-7.2.2.tgz", + "integrity": "sha512-a43cyx1nK0daw6BZxVcvDEXxKMFLSBSDTAhsFD0VqSKcC7MGUBMaqyoWUcMiI7LBSz4bxUmxDWKfCYzpEmeb3w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "license": "MIT", @@ -3468,6 +3663,20 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "peer": true }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "dev": true, @@ -3551,6 +3760,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cryptr": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cryptr/-/cryptr-6.3.0.tgz", + "integrity": "sha512-TA4byAuorT8qooU9H8YJhBwnqD151i1rcauHfJ3Divg6HmukHB2AYMp0hmjv2873J2alr4t15QqC7zAnWFrtfQ==" + }, "node_modules/csrf": { "version": "3.1.0", "license": "MIT", @@ -3617,10 +3831,19 @@ "node": ">=4.0.0" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defaults": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/defaults/-/defaults-3.0.0.tgz", "integrity": "sha512-RsqXDEAALjfRTro+IFNKpcPCt0/Cy2FqHSIlnomiJp9YGadpQnrtbRpSgN2+np21qHcIKiva4fiOQGjS9/qR/A==", + "dev": true, "engines": { "node": ">=18" }, @@ -3972,6 +4195,15 @@ "version": "5.0.1", "license": "MIT" }, + "node_modules/exec-sh": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.2.2.tgz", + "integrity": "sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==", + "dev": true, + "dependencies": { + "merge": "^1.2.0" + } + }, "node_modules/execa": { "version": "8.0.1", "license": "MIT", @@ -5090,6 +5322,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/is-wsl": { "version": "3.1.0", "license": "MIT", @@ -5725,6 +5968,12 @@ "node": ">= 0.6" } }, + "node_modules/merge": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.1.tgz", + "integrity": "sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==", + "dev": true + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -11606,6 +11855,33 @@ "integrity": "sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==", "dev": true }, + "node_modules/snappy": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/snappy/-/snappy-7.2.2.tgz", + "integrity": "sha512-iADMq1kY0v3vJmGTuKcFWSXt15qYUz7wFkArOrsSg0IFfI3nJqIJvK2/ZbEIndg7erIJLtAVX2nSOqPz7DcwbA==", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/snappy-android-arm-eabi": "7.2.2", + "@napi-rs/snappy-android-arm64": "7.2.2", + "@napi-rs/snappy-darwin-arm64": "7.2.2", + "@napi-rs/snappy-darwin-x64": "7.2.2", + "@napi-rs/snappy-freebsd-x64": "7.2.2", + "@napi-rs/snappy-linux-arm-gnueabihf": "7.2.2", + "@napi-rs/snappy-linux-arm64-gnu": "7.2.2", + "@napi-rs/snappy-linux-arm64-musl": "7.2.2", + "@napi-rs/snappy-linux-x64-gnu": "7.2.2", + "@napi-rs/snappy-linux-x64-musl": "7.2.2", + "@napi-rs/snappy-win32-arm64-msvc": "7.2.2", + "@napi-rs/snappy-win32-ia32-msvc": "7.2.2", + "@napi-rs/snappy-win32-x64-msvc": "7.2.2" + } + }, "node_modules/source-map": { "version": "0.6.1", "dev": true, @@ -11842,6 +12118,17 @@ "node": ">=0.10.0" } }, + "node_modules/superjson": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.1.tgz", + "integrity": "sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/supports-color": { "version": "5.5.0", "license": "MIT", @@ -12887,6 +13174,22 @@ "loose-envify": "^1.0.0" } }, + "node_modules/watch": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/watch/-/watch-1.0.2.tgz", + "integrity": "sha512-1u+Z5n9Jc1E2c7qDO8SinPoZuHj7FgbgU1olSFoyaklduDvvtX7GMMtlE6OC9FTXq4KvNAOfj6Zu4vI1e9bAKA==", + "dev": true, + "dependencies": { + "exec-sh": "^0.2.0", + "minimist": "^1.2.0" + }, + "bin": { + "watch": "cli.js" + }, + "engines": { + "node": ">=0.1.95" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "license": "MIT", @@ -13211,11 +13514,13 @@ "@astro-utils/context": "0.0.1", "await-lock": "^2.2.2", "cookie": "^0.5.0", + "cryptr": "^6.3.0", "csrf": "^3.1.0", - "defaults": "^3.0.0", - "formidable": "^3.2.5", + "deepmerge": "^4.3.1", "jsonwebtoken": "^9.0.0", "promise-timeout": "^1.3.0", + "snappy": "^7.2.2", + "superjson": "^2.2.1", "uuid": "^9.0.0", "zod": "^3.19.1" }, @@ -13229,7 +13534,8 @@ "@types/uuid": "^9.0.1", "semantic-release-commit-filter": "^1.0.2", "typescript": "^5.2.2", - "vite": "^4.1.2" + "vite": "^4.1.2", + "watch": "^1.0.2" }, "funding": { "url": "https://github.com/sponsors/ido-pluto" diff --git a/packages/express-endpoints/src/http/express-request.ts b/packages/express-endpoints/src/http/express-request.ts index cd791b9..f1e1079 100644 --- a/packages/express-endpoints/src/http/express-request.ts +++ b/packages/express-endpoints/src/http/express-request.ts @@ -7,7 +7,6 @@ import {EventEmitter} from 'events'; import type {ExpressRouteBodyType} from '../express-route.js'; import {ExpressRouteBodyOptions} from '../express-route.js'; import {Accepts} from '@tinyhttp/accepts'; -import {Options} from 'formidable'; interface ExpressRequestEventEmitterTypes { on(event: 'close', listener: (error?: Error) => void): this; @@ -82,7 +81,7 @@ export default class ExpressRequest extends EventEmitter implements ExpressReque } } - async parseBody(type: ExpressRouteBodyType, options?: Options) { + async parseBody(type: ExpressRouteBodyType) { if (!BODY_METHODS.includes(this.method as any)) { throw new ExpressBodyError(`Body parsing only available for ${BODY_METHODS.join(', ')}`, 500); } @@ -101,7 +100,7 @@ export default class ExpressRequest extends EventEmitter implements ExpressReque this.body = await this.astroContext.request.json(); break; case 'multipart': - await this._parseBodyMultiPart(options); + await this._parseBodyMultiPart(); break; case 'urlencoded': this.body = await this.astroContext.request.formData(); @@ -116,7 +115,7 @@ export default class ExpressRequest extends EventEmitter implements ExpressReque return this.body; } - private async _parseBodyMultiPart(options?: Options) { + private async _parseBodyMultiPart() { try { const formData = await this.astroContext.request.formData(); diff --git a/packages/forms/components/WebForms.astro b/packages/forms/components/WebForms.astro deleted file mode 100644 index 0641d02..0000000 --- a/packages/forms/components/WebForms.astro +++ /dev/null @@ -1,28 +0,0 @@ ---- -import {asyncContext} from '@astro-utils/context'; -import {createFormToken} from '../dist/form-tools/csrf.js'; -import {FORM_OPTIONS} from '../dist/settings.js'; - -export interface Props extends astroHTML.JSX.FormHTMLAttributes { -} - -const context = { - ...Astro.props, - webFormsSettings: {haveFileUpload: false} -}; - -await Astro.locals.__formsInternalUtils?.onWebFormsOpen?.(); -const htmlSolt = await asyncContext(() => Astro.slots.render('default'), Astro, {name: '@astro-utils/forms', context}); - -const {webFormsSettings, ...props} = context; -if (webFormsSettings.haveFileUpload) { - props.enctype = 'multipart/form-data'; -} - -const formRequestToken = FORM_OPTIONS.session?.cookieOptions?.maxAge && await createFormToken(Astro); -await Astro.locals.__formsInternalUtils?.onWebFormClose?.(); ---- -
- {formRequestToken && } - - diff --git a/packages/forms/components/form/BButton.astro b/packages/forms/components/form/BButton.astro deleted file mode 100644 index 3d62326..0000000 --- a/packages/forms/components/form/BButton.astro +++ /dev/null @@ -1,34 +0,0 @@ ---- -import getContext from '@astro-utils/context'; -import {createUniqueContinuanceName} from '../../dist/form-tools/connectId.js'; -import {isPost, validateAction} from '../../dist/form-tools/post.js'; - -export interface Props> extends astroHTML.JSX.ButtonHTMLAttributes { - onClick: Function; - connectId?: string; - whenFormOK?: boolean; - as?: T; - props?: React.ComponentProps; -} - -const {as: asComponent = 'button', props: componentProps, onClick, whenFormOK, connectId = createUniqueContinuanceName(onClick), ...props} = Astro.props; -const {bind, executeAfter} = getContext(Astro, '@astro-utils/forms'); - -async function executeFormAction(callback = onClick) { - const checkFormValidation = whenFormOK && !bind?.errors.length || !whenFormOK; - if (isPost(Astro) && await validateAction(Astro, 'button-callback', connectId) && checkFormValidation) { - await callback(); - } -} - -if (executeAfter) { - executeAfter.push(executeFormAction); -} else { - throw new Error('Use BButton inside a BindForm component'); -} - -const Component = asComponent as any; ---- - - - diff --git a/packages/forms/components/form/BindForm.astro b/packages/forms/components/form/BindForm.astro deleted file mode 100644 index 43d8c6f..0000000 --- a/packages/forms/components/form/BindForm.astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -import { asyncContext } from "@astro-utils/context"; - -export interface Props { - bind?: any -}; - -const {bind} = Astro.props; -const context = {executeAfter: [], method: Astro.request.method, bind}; - -let htmlSolt = await asyncContext(() => Astro.slots.render('default'), Astro, {name: "@astro-utils/forms", context}); -bind?.finishFormValidation(); -for(const func of context.executeAfter){ - await (func as any)(); -} - - -if(context.method == "POST"){ - context.method = "GET"; - htmlSolt = await asyncContext(() => Astro.slots.render('default'), Astro, {name: "@astro-utils/forms", context}); -} ---- - diff --git a/packages/forms/forms.ts b/packages/forms/forms.ts index 5c64387..031377d 100644 --- a/packages/forms/forms.ts +++ b/packages/forms/forms.ts @@ -1,12 +1,12 @@ -import BindForm from './components/form/BindForm.astro'; -import BButton from './components/form/BButton.astro'; -import FormErrors from './components/form/FormErrors.astro'; -import BInput from './components/form/BInput.astro'; -import BTextarea from './components/form/BTextarea.astro'; -import BOption from './components/form/BOption.astro'; -import BSelect from './components/form/BSelect.astro'; -import WebForms from './components/WebForms.astro'; -import Bind from './dist/components/form-utils/bind-form.js'; +import BindForm from './dist/components/form/BindForm.astro'; +import BButton from './dist/components/form/BButton.astro'; +import FormErrors from './dist/components/form/FormErrors.astro'; +import BInput from './dist/components/form/BInput.astro'; +import BTextarea from './dist/components/form/BTextarea.astro'; +import BOption from './dist/components/form/BOption.astro'; +import BSelect from './dist/components/form/BSelect.astro'; +import WebForms from './dist/components/WebForms.astro'; +import Bind from './dist/components-control/form-utils/bind-form.js'; export { Bind, diff --git a/packages/forms/package.json b/packages/forms/package.json index 4407911..0065b87 100644 --- a/packages/forms/package.json +++ b/packages/forms/package.json @@ -4,7 +4,8 @@ "description": "React-like web forms hooks for Astro (server + client validation)", "type": "module", "scripts": { - "build": "rm -r dist; tsc", + "watch": "watch 'tsc' ./components", + "build": "rm -r dist/*; tsc; mkdir dist/components; cp -r src/components/* dist/components/", "prepack": "npm run build" }, "keywords": [ @@ -57,17 +58,20 @@ "@types/uuid": "^9.0.1", "semantic-release-commit-filter": "^1.0.2", "typescript": "^5.2.2", - "vite": "^4.1.2" + "vite": "^4.1.2", + "watch": "^1.0.2" }, "dependencies": { "@astro-utils/context": "0.0.1", "await-lock": "^2.2.2", "cookie": "^0.5.0", + "cryptr": "^6.3.0", "csrf": "^3.1.0", - "defaults": "^3.0.0", - "formidable": "^3.2.5", + "deepmerge": "^4.3.1", "jsonwebtoken": "^9.0.0", "promise-timeout": "^1.3.0", + "snappy": "^7.2.2", + "superjson": "^2.2.1", "uuid": "^9.0.0", "zod": "^3.19.1" }, diff --git a/packages/forms/src/components/form-utils/about-form-name.ts b/packages/forms/src/components-control/form-utils/about-form-name.ts similarity index 73% rename from packages/forms/src/components/form-utils/about-form-name.ts rename to packages/forms/src/components-control/form-utils/about-form-name.ts index ee51398..2559ec2 100644 --- a/packages/forms/src/components/form-utils/about-form-name.ts +++ b/packages/forms/src/components-control/form-utils/about-form-name.ts @@ -1,14 +1,14 @@ -import { ZodError, ZodFirstPartySchemaTypes } from "zod"; -import { BindForm } from "./bind-form.js"; +import {ZodError, type ZodFirstPartySchemaTypes} from 'zod'; +import {BindForm} from './bind-form.js'; export default class AboutFormName { hadError = false; - constructor(public form: BindForm, public originalName: string, public formValue?: any, public errorMessage?: string){ + constructor(public form: BindForm, public originalName: string, public formValue?: any, public errorMessage?: string) { } - pushError(zodError: ZodError, overrideMessage?: string){ + pushError(zodError: ZodError, overrideMessage?: string) { this.hadError = true; const topMessage = overrideMessage ?? zodError.issues.at(0).message; @@ -20,7 +20,7 @@ export default class AboutFormName { }); } - pushErrorManually(code: string, errorMessage: string){ + pushErrorManually(code: string, errorMessage: string) { this.hadError = true; this.form.errors.push({ name: this.originalName, @@ -30,17 +30,17 @@ export default class AboutFormName { }); } - catchParse(zObject: ZodFirstPartySchemaTypes, overrideMessage?: string){ + catchParse(zObject: ZodFirstPartySchemaTypes, overrideMessage?: string) { try { this.formValue = zObject.parse(this.formValue); return true; - } catch (err){ + } catch (err) { this.pushError(err, overrideMessage); } } - setValue(){ - if(this.hadError) return; + setValue() { + if (this.hadError) return; this.form[this.originalName] = this.formValue; } -} \ No newline at end of file +} diff --git a/packages/forms/src/components/form-utils/bind-form-plugins/iform-plugin.ts b/packages/forms/src/components-control/form-utils/bind-form-plugins/iform-plugin.ts similarity index 59% rename from packages/forms/src/components/form-utils/bind-form-plugins/iform-plugin.ts rename to packages/forms/src/components-control/form-utils/bind-form-plugins/iform-plugin.ts index ee7506b..ebe9f0e 100644 --- a/packages/forms/src/components/form-utils/bind-form-plugins/iform-plugin.ts +++ b/packages/forms/src/components-control/form-utils/bind-form-plugins/iform-plugin.ts @@ -1,20 +1,21 @@ -import type { BindForm } from "../bind-form.js"; -import AboutFormName from "../about-form-name.js"; +import type {BindForm} from '../bind-form.js'; +import AboutFormName from '../about-form-name.js'; export abstract class IHTMLFormPlugin { storage: Map = new Map(); - constructor(protected form: BindForm){ + constructor(protected form: BindForm) { } abstract createOneValidation(key: string, value: any): void; + abstract addNewValue(about: AboutFormName, ...any: any[]): void; - createValidation(){ - for(const [key, value] of this.storage){ + createValidation() { + for (const [key, value] of this.storage) { this.createOneValidation(key, value); this.storage.delete(key); } } -} \ No newline at end of file +} diff --git a/packages/forms/src/components/form-utils/bind-form-plugins/input-radio.ts b/packages/forms/src/components-control/form-utils/bind-form-plugins/input-radio.ts similarity index 79% rename from packages/forms/src/components/form-utils/bind-form-plugins/input-radio.ts rename to packages/forms/src/components-control/form-utils/bind-form-plugins/input-radio.ts index 8cc22c4..41a1f19 100644 --- a/packages/forms/src/components/form-utils/bind-form-plugins/input-radio.ts +++ b/packages/forms/src/components-control/form-utils/bind-form-plugins/input-radio.ts @@ -1,5 +1,5 @@ -import AboutFormName from "../about-form-name.js"; -import { IHTMLFormPlugin } from "./iform-plugin.js" +import AboutFormName from '../about-form-name.js'; +import {IHTMLFormPlugin} from './iform-plugin.js'; type RadioItem = { about: AboutFormName, @@ -13,7 +13,7 @@ export default class HTMLInputRadioPlugin extends IHTMLFormPlugin { createOneValidation(name: string, keyData: any): void { const {options, about}: RadioItem = keyData; - if(!options.has(about.formValue)){ + if (!options.has(about.formValue)) { about.pushErrorManually('radio-invalid-value', 'Radio value invalid'); return; } @@ -23,16 +23,16 @@ export default class HTMLInputRadioPlugin extends IHTMLFormPlugin { private createRadioDefault(about: AboutFormName): RadioItem { return { - about, + about, options: new Set() - } + }; } addNewValue(about: AboutFormName, originalValue: string): void { - if(!this.storage.has(about.originalName)){ + if (!this.storage.has(about.originalName)) { this.storage.set(about.originalName, this.createRadioDefault(about)); } else { this.storage.get(about.originalName).options.add(originalValue); } } -} \ No newline at end of file +} diff --git a/packages/forms/src/components/form-utils/bind-form-plugins/select.ts b/packages/forms/src/components-control/form-utils/bind-form-plugins/select.ts similarity index 74% rename from packages/forms/src/components/form-utils/bind-form-plugins/select.ts rename to packages/forms/src/components-control/form-utils/bind-form-plugins/select.ts index 677f0b2..7778468 100644 --- a/packages/forms/src/components/form-utils/bind-form-plugins/select.ts +++ b/packages/forms/src/components-control/form-utils/bind-form-plugins/select.ts @@ -1,5 +1,5 @@ -import type AboutFormName from "../about-form-name.js" -import { IHTMLFormPlugin } from "./iform-plugin.js" +import type AboutFormName from '../about-form-name.js'; +import {IHTMLFormPlugin} from './iform-plugin.js'; type SelectObject = { about: AboutFormName @@ -14,17 +14,17 @@ type SelectValidation = Map export default class HTMLSelectPlugin extends IHTMLFormPlugin { storage: SelectValidation = new Map(); - static errorOptionNotValid(about: AboutFormName){ + static errorOptionNotValid(about: AboutFormName) { about.pushErrorManually('option-not-valid', 'Select option not valid'); } createOneValidation(name: string, keyData: any): void { const {options, multiOptions, value, about, required}: SelectObject = keyData; - if(multiOptions){ - const arrayValue = Array.isArray(value) ? value: [value]; + if (multiOptions) { + const arrayValue = Array.isArray(value) ? value : [value]; - if(!arrayValue.every(x => options.has(x))){ + if (!arrayValue.every(x => options.has(x))) { required && HTMLSelectPlugin.errorOptionNotValid(about); return; } @@ -33,31 +33,31 @@ export default class HTMLSelectPlugin extends IHTMLFormPlugin { return; } - if(!options.has(value[0])){ + if (!options.has(value[0])) { required && HTMLSelectPlugin.errorOptionNotValid(about); return; } - + this.form[name] = about.formValue[0]; // the parsed value } private createSelectDefault(about: AboutFormName, value: string | string[], multiOptions: boolean, required: boolean): SelectObject { return { - about, + about, options: new Set(), value, multiOptions, required - } + }; } addNewValue(about: AboutFormName, value: string | string[], multiOptions: boolean, required = true): void { - if(!this.storage.has(about.originalName)){ + if (!this.storage.has(about.originalName)) { this.storage.set(about.originalName, this.createSelectDefault(about, value, multiOptions, required)); } } - addOption(originalName: string, option: string){ + addOption(originalName: string, option: string) { this.storage.get(originalName)?.options.add(option); } -} \ No newline at end of file +} diff --git a/packages/forms/src/components-control/form-utils/bind-form.ts b/packages/forms/src/components-control/form-utils/bind-form.ts new file mode 100644 index 0000000..9a47c0f --- /dev/null +++ b/packages/forms/src/components-control/form-utils/bind-form.ts @@ -0,0 +1,58 @@ +import {IHTMLFormPlugin} from './bind-form-plugins/iform-plugin.js'; +import HTMLInputRadioPlugin from './bind-form-plugins/input-radio.js'; +import HTMLSelectPlugin from './bind-form-plugins/select.js'; + +const DEFAULT_PLUGINS = [HTMLInputRadioPlugin, HTMLSelectPlugin]; +type PluginsNames = 'HTMLInputRadioPlugin' | 'HTMLSelectPlugin'; + +export class BindForm { + errors: { + name: string, + value: string, + issues: { + code: string, + message: string + }[], + message: string + }[] = []; + private _plugins: IHTMLFormPlugin[]; + + constructor(private _defaults?: T) { + this.defaults(); + this.initializePlugins(); + } + + private initializePlugins() { + this._plugins = DEFAULT_PLUGINS.map(plugin => new plugin(this)); + } + + getPlugin(name: PluginsNames) { + return this._plugins.find(x => x.constructor.name == name); + } + + finishFormValidation() { + for (const plugin of this._plugins) { + plugin.createValidation(); + } + } + + defaults() { + this._defaults && Object.assign(this, this._defaults); + } + + /** + * @internal + */ + __getState() { + const state: any = {...this}; + delete state._defaults; + delete state._plugins; + delete state.errors; + + return state; + } +} + +export default function Bind(defaults?: T): BindForm & T & { [key: string]: any } { + return new BindForm(defaults); +} diff --git a/packages/forms/src/components/form-utils/parse-multi.ts b/packages/forms/src/components-control/form-utils/parse-multi.ts similarity index 83% rename from packages/forms/src/components/form-utils/parse-multi.ts rename to packages/forms/src/components-control/form-utils/parse-multi.ts index 29b968d..b647b0f 100644 --- a/packages/forms/src/components/form-utils/parse-multi.ts +++ b/packages/forms/src/components-control/form-utils/parse-multi.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; -import AboutFormName from "./about-form-name.js"; +import {z} from 'zod'; +import AboutFormName from './about-form-name.js'; export function parseMultiNumber(about: AboutFormName) { const numArray = z.array(z.number()); @@ -13,4 +13,4 @@ export function parseMultiDate(about: AboutFormName) { about.formValue = about.formValue.map((date: string) => new Date(date)); about.catchParse(dateArray); -} \ No newline at end of file +} diff --git a/packages/forms/src/components/form-utils/parse.ts b/packages/forms/src/components-control/form-utils/parse.ts similarity index 81% rename from packages/forms/src/components/form-utils/parse.ts rename to packages/forms/src/components-control/form-utils/parse.ts index 8033dea..d40dc6b 100644 --- a/packages/forms/src/components/form-utils/parse.ts +++ b/packages/forms/src/components-control/form-utils/parse.ts @@ -1,12 +1,12 @@ -import {AstroGlobal} from 'astro'; +import type {AstroGlobal} from 'astro'; import {z} from 'zod'; import {getFormMultiValue} from '../../form-tools/post.js'; import AboutFormName from './about-form-name.js'; const HEX_COLOR_REGEX = /^#?([0-9a-f]{6}|[0-9a-f]{3})$/i; -export function parseCheckbox(about: AboutFormName, originalValue?: string){ - if(originalValue == null) { +export function parseCheckbox(about: AboutFormName, originalValue?: string) { + if (originalValue == null) { about.formValue = about.formValue === 'on'; } } @@ -45,25 +45,25 @@ export function parseDate(about: AboutFormName, min?: string, max?: string) { about.catchParse(date); } -export function parseEmail(about: AboutFormName){ +export function parseEmail(about: AboutFormName) { about.catchParse(z.string().email()); } -export function parseURL(about: AboutFormName){ +export function parseURL(about: AboutFormName) { about.catchParse(z.string().url()); } -export function parseColor(about: AboutFormName){ +export function parseColor(about: AboutFormName) { about.catchParse(z.string().regex(HEX_COLOR_REGEX), 'Invalid hex color'); } -export async function parseFiles(about: AboutFormName, astro: AstroGlobal, multiple: boolean, readonly: boolean){ +export async function parseFiles(about: AboutFormName, astro: AstroGlobal, multiple: boolean, readonly: boolean) { let values = [about.formValue]; - if(multiple && !readonly){ + if (multiple && !readonly) { values = about.formValue = await getFormMultiValue(astro.request, about.originalName); } - for(const value of values){ + for (const value of values) { if (!(value instanceof File)) { about.pushErrorManually('upload-not-file', 'The upload value is not a file'); break; diff --git a/packages/forms/src/components/form-utils/validate.ts b/packages/forms/src/components-control/form-utils/validate.ts similarity index 69% rename from packages/forms/src/components/form-utils/validate.ts rename to packages/forms/src/components-control/form-utils/validate.ts index 81ed690..6a951d6 100644 --- a/packages/forms/src/components/form-utils/validate.ts +++ b/packages/forms/src/components-control/form-utils/validate.ts @@ -1,14 +1,14 @@ import {z} from 'zod'; import AboutFormName from '../form-utils/about-form-name.js'; -function validateEmptyFile(about: AboutFormName){ +function validateEmptyFile(about: AboutFormName) { const value = about.formValue; return value instanceof File && value.size == 0; } -export function validateRequire(about: AboutFormName, required: boolean){ - if(about.formValue == null || about.formValue === '' || validateEmptyFile(about)){ - if(required){ +export function validateRequire(about: AboutFormName, required: boolean) { + if (about.formValue == null || about.formValue === '' || validateEmptyFile(about)) { + if (required) { about.pushErrorManually('missing-require-filed', 'Missing required filed'); } return false; @@ -17,35 +17,35 @@ export function validateRequire(about: AboutFormName, required: boolean){ return true; } -export function validateStringPatters(about: AboutFormName, minlength: number, maxlength: number, pattern: RegExp){ +export function validateStringPatters(about: AboutFormName, minlength: number, maxlength: number, pattern: RegExp) { let text = z.string(); - if(minlength){ + if (minlength) { text = text.min(minlength); } - if(maxlength){ + if (maxlength) { text = text.max(maxlength); } - if(pattern){ + if (pattern) { text = text.regex(pattern); } return about.catchParse(text); } -export async function validateFunc(about: AboutFormName, method: Function){ +export async function validateFunc(about: AboutFormName, method: Function) { try { const response = await method(about.formValue); - if(!response) return; + if (!response) return; - if(response.error){ + if (response.error) { about.pushErrorManually(response.code, response.error); - } else if(response.value){ + } else if (response.value) { about.formValue = response.value; } - } catch(err){ + } catch (err) { about.pushErrorManually(err.code ?? err.name, err.message); } } diff --git a/packages/forms/src/components-control/form-utils/view-state.ts b/packages/forms/src/components-control/form-utils/view-state.ts new file mode 100644 index 0000000..8b9dbbb --- /dev/null +++ b/packages/forms/src/components-control/form-utils/view-state.ts @@ -0,0 +1,81 @@ +import {AstroGlobal} from 'astro'; +import Cryptr from 'cryptr'; +import superjson from 'superjson'; +import {parseFormData} from '../../form-tools/post.js'; +import {FormsSettings, getFormOptions} from '../../settings.js'; +import {BindForm} from './bind-form.js'; +import snappy from 'snappy'; +import {getSomeProps} from '../props-utils.js'; + +export default class ViewStateManager { + private readonly _FORM_OPTIONS: FormsSettings; + private readonly _cryptr: Cryptr; + + get filedName() { + if (!this._FORM_OPTIONS.forms) { + throw new Error('Forms options not set'); + } + + return this._FORM_OPTIONS.forms.viewStateFormFiled + this._counter; + } + + get stateProp() { + return this._astro.props.state ?? true; + } + + get useState() { + return this.stateProp && this._astro.request.method === 'POST'; + } + + constructor(private _bind: BindForm, private _elementsState: any, private _astro: AstroGlobal, private _counter: number) { + this._FORM_OPTIONS = getFormOptions(_astro); + + if (!this._FORM_OPTIONS.secret) { + throw new Error('Secret not set in form options'); + } + + this._cryptr = new Cryptr(this._FORM_OPTIONS.secret, {'encoding': 'base64'}); + } + + private async _extractStateFromForm() { + const form = await parseFormData(this._astro.request); + const value = form.get(this.filedName).toString(); + form.delete(this.filedName); + return value; + } + + private async _parseState() { + try { + const state = await this._extractStateFromForm(); + const data = this._cryptr.decrypt(state); + const uncompress = await snappy.uncompress(Buffer.from(data, 'base64')); + return superjson.parse(uncompress.toString()); + } catch (error: any) { + this._FORM_OPTIONS.logs?.('warn', `ViewStateManager: ${error.message}`); + } + } + + public async loadState() { + if (!this.useState) { + return false; + } + + const state: any = await this._parseState(); + if (!state) return; + + Object.assign(this._bind, state.bind); + Object.assign(this._elementsState, state.elements); + return; + } + + public async createViewState(): Promise { + const data = { + bind: getSomeProps(this._bind.__getState(), this.stateProp), + elements: this._elementsState + }; + + const stringify = superjson.stringify(data); + const compress = await snappy.compress(stringify, {}); + return this._cryptr.encrypt(compress.toString('base64')); + } +} diff --git a/packages/forms/src/components/input-parse.ts b/packages/forms/src/components-control/input-parse.ts similarity index 60% rename from packages/forms/src/components/input-parse.ts rename to packages/forms/src/components-control/input-parse.ts index 64cc358..78db2e5 100644 --- a/packages/forms/src/components/input-parse.ts +++ b/packages/forms/src/components-control/input-parse.ts @@ -1,43 +1,43 @@ -import { AstroGlobal } from "astro"; -import { getFormValue } from "../form-tools/post.js"; -import AboutFormName from "./form-utils/about-form-name.js"; -import type HTMLInputRadioPlugin from "./form-utils/bind-form-plugins/input-radio.js"; -import { BindForm } from "./form-utils/bind-form.js"; -import { parseCheckbox, parseColor, parseDate, parseEmail, parseFiles, parseNumber, parseURL } from "./form-utils/parse.js"; -import { validateFunc, validateRequire, validateStringPatters } from "./form-utils/validate.js"; +import type {AstroGlobal} from 'astro'; +import {getFormValue} from '../form-tools/post.js'; +import AboutFormName from './form-utils/about-form-name.js'; +import type HTMLInputRadioPlugin from './form-utils/bind-form-plugins/input-radio.js'; +import {BindForm} from './form-utils/bind-form.js'; +import {parseCheckbox, parseColor, parseDate, parseEmail, parseFiles, parseNumber, parseURL} from './form-utils/parse.js'; +import {validateFunc, validateRequire, validateStringPatters} from './form-utils/validate.js'; const OK_NOT_STRING_VALUE = ['checkbox', 'file']; const OK_INPUT_VALUE_NULL = ['checkbox']; type InputTypes = - | "button" - | "checkbox" - | "color" - | "date" - | "datetime-local" - | "email" - | "file" - | "hidden" - | "image" - | "month" - | "number" - | "password" - | "radio" - | "range" - | "reset" - | "search" - | "submit" - | "tel" - | "text" - | "time" - | "url" - | "week"; - -type ExtendedInputTypes = InputTypes | "int" + | 'button' + | 'checkbox' + | 'color' + | 'date' + | 'datetime-local' + | 'email' + | 'file' + | 'hidden' + | 'image' + | 'month' + | 'number' + | 'password' + | 'radio' + | 'range' + | 'reset' + | 'search' + | 'submit' + | 'tel' + | 'text' + | 'time' + | 'url' + | 'week'; + +type ExtendedInputTypes = InputTypes | 'int' export async function getInputValue(astro: AstroGlobal) { - const {value, name, readonly } = astro.props; - if(readonly){ + const {value, name, readonly} = astro.props; + if (readonly) { return value; } @@ -45,7 +45,7 @@ export async function getInputValue(astro: AstroGlobal) { } export async function validateFormInput(astro: AstroGlobal, bind: BindForm) { - const { type, value: originalValue, minlength, maxlength, pattern, required, name, errorMessage, validate} = astro.props; + const {type, value: originalValue, minlength, maxlength, pattern, required, name, errorMessage, validate} = astro.props; const parseValue: any = await getInputValue(astro); const aboutInput = new AboutFormName(bind, name, parseValue, errorMessage); @@ -63,15 +63,15 @@ export async function validateFormInput(astro: AstroGlobal, bind: BindForm) // specific validation by type / function validateByInputType(astro, aboutInput, bind); - if(!aboutInput.hadError && typeof validate == 'function'){ + if (!aboutInput.hadError && typeof validate == 'function') { await validateFunc(aboutInput, validate); } aboutInput.setValue(); } -function validateByInputType(astro: AstroGlobal, aboutInput: AboutFormName, bind: BindForm){ - const { type, min, max, value: originalValue, multiple, readonly } = astro.props; +function validateByInputType(astro: AstroGlobal, aboutInput: AboutFormName, bind: BindForm) { + const {type, min, max, value: originalValue, multiple, readonly} = astro.props; switch (type) { case 'checkbox': @@ -113,9 +113,9 @@ function validateByInputType(astro: AstroGlobal, aboutInput: AboutFormName, bind } } -export function inputReturnValueAttr(astro: AstroGlobal, bind: BindForm){ +export function inputReturnValueAttr(astro: AstroGlobal, bind: BindForm) { const value = bind[astro.props.name]; - switch(astro.props.type as ExtendedInputTypes){ + switch (astro.props.type as ExtendedInputTypes) { case 'checkbox': return {checked: value}; default: @@ -124,14 +124,14 @@ export function inputReturnValueAttr(astro: AstroGlobal, bind: BindForm){ } -export function caseTypes(type: ExtendedInputTypes): {type: InputTypes} & {[key: string]: string} { - if(type == 'int'){ +export function caseTypes(type: ExtendedInputTypes): { type: InputTypes } & { [key: string]: string } { + if (type == 'int') { return { type: 'number', - pattern: "\\d+", - step: "1" + pattern: '\\d+', + step: '1' }; } return {type}; -} \ No newline at end of file +} diff --git a/packages/forms/src/components-control/props-utils.ts b/packages/forms/src/components-control/props-utils.ts new file mode 100644 index 0000000..56082a5 --- /dev/null +++ b/packages/forms/src/components-control/props-utils.ts @@ -0,0 +1,23 @@ +/** + * Returns the difference between two objects + */ +export function diffProps(object1: any, object2: any) { + const diff = {}; + for (const [key, value] of Object.entries(object2)) { + if (object1[key] !== value) { + diff[key] = value; + } + } + return diff; +} + +export function getSomeProps(object: any, props: string[] | true) { + if (props === true) { + return object; + } + const result = {}; + for (const prop of props) { + result[prop] = object[prop]; + } + return result; +} diff --git a/packages/forms/src/components/select.ts b/packages/forms/src/components-control/select.ts similarity index 59% rename from packages/forms/src/components/select.ts rename to packages/forms/src/components-control/select.ts index 92af641..aeaa242 100644 --- a/packages/forms/src/components/select.ts +++ b/packages/forms/src/components-control/select.ts @@ -1,21 +1,21 @@ -import { AstroGlobal } from "astro"; -import { getFormMultiValue } from "../form-tools/post.js"; -import AboutFormName from "./form-utils/about-form-name.js"; -import HTMLSelectPlugin from "./form-utils/bind-form-plugins/select.js"; -import { BindForm } from "./form-utils/bind-form.js"; -import { parseMultiDate, parseMultiNumber } from "./form-utils/parse-multi.js"; -import { validateRequire } from "./form-utils/validate.js"; +import type {AstroGlobal} from 'astro'; +import {getFormMultiValue} from '../form-tools/post.js'; +import AboutFormName from './form-utils/about-form-name.js'; +import HTMLSelectPlugin from './form-utils/bind-form-plugins/select.js'; +import {BindForm} from './form-utils/bind-form.js'; +import {parseMultiDate, parseMultiNumber} from './form-utils/parse-multi.js'; +import {validateRequire} from './form-utils/validate.js'; -type InputTypes = "number" | "date" | "text" +type InputTypes = 'number' | 'date' | 'text' -export async function getSelectValue(astro: AstroGlobal){ +export async function getSelectValue(astro: AstroGlobal) { const {value: originalValue, name} = astro.props; - if(originalValue) return [originalValue]; + if (originalValue) return [originalValue]; return await getFormMultiValue(astro.request, name); } export async function validateSelect(astro: AstroGlobal, bind: BindForm) { - const { type, required, name, multiple, errorMessage } = astro.props; + const {type, required, name, multiple, errorMessage} = astro.props; const parseValue: any = await getSelectValue(astro); const aboutSelect = new AboutFormName(bind, name, parseValue, errorMessage); @@ -39,10 +39,10 @@ export async function validateSelect(astro: AstroGlobal, bind: BindForm) { } export function validateSelectOption(astro: AstroGlobal, bind: BindForm, name: string, slotValue: string) { - const { value, disabled } = astro.props; - if(disabled) return; + const {value, disabled} = astro.props; + if (disabled) return; const realValue = value ?? slotValue; const selectPlugin = bind.getPlugin('HTMLSelectPlugin') as HTMLSelectPlugin; selectPlugin.addOption(name, realValue); -} \ No newline at end of file +} diff --git a/packages/forms/src/components/WebForms.astro b/packages/forms/src/components/WebForms.astro new file mode 100644 index 0000000..bdc7a44 --- /dev/null +++ b/packages/forms/src/components/WebForms.astro @@ -0,0 +1,36 @@ +--- +import {asyncContext} from '@astro-utils/context'; +import {createFormToken} from '../form-tools/csrf.js'; +const utils = Astro.locals.__formsInternalUtils; + +export interface Props extends astroHTML.JSX.FormHTMLAttributes {} + +const context = { + ...Astro.props, + webFormsSettings: {haveFileUpload: false}, + tempValues: {}, + viewStates: { + counter: 0, + store: {}, + }, +}; + +const htmlSolt = await asyncContext(() => Astro.slots.render('default'), Astro, {name: '@astro-utils/forms', context}); + +const {webFormsSettings, tempValues, viewStates, ...props} = context; +if (webFormsSettings.haveFileUpload) { + props.enctype = 'multipart/form-data'; +} + +const useSession = utils?.FORM_OPTIONS.session?.cookieOptions?.maxAge; +const formRequestToken = useSession && (await createFormToken(Astro)); +await utils?.onWebFormClose?.(); + +const clientScript = Astro.locals.forms.scriptToRun; +--- + +
+ {formRequestToken && } + + {clientScript && } + diff --git a/packages/forms/src/components/form-utils/bind-form.ts b/packages/forms/src/components/form-utils/bind-form.ts deleted file mode 100644 index ea47817..0000000 --- a/packages/forms/src/components/form-utils/bind-form.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { IHTMLFormPlugin } from "./bind-form-plugins/iform-plugin.js"; -import HTMLInputRadioPlugin from "./bind-form-plugins/input-radio.js"; -import HTMLSelectPlugin from "./bind-form-plugins/select.js"; - -const DEFAULT_PLUGINS = [HTMLInputRadioPlugin, HTMLSelectPlugin]; -type PluginsNames = 'HTMLInputRadioPlugin' | 'HTMLSelectPlugin'; - -export class BindForm { - errors: { - name: string, - value: string, - issues: { - code: string, - message: string - }[], - message: string - }[] = []; - private plugins: IHTMLFormPlugin[]; - - constructor(private _defaults?: T) { - this.defaults(); - this.initializePlugins(); - } - - private initializePlugins(){ - this.plugins = DEFAULT_PLUGINS.map(plugin => new plugin(this)); - } - - getPlugin(name: PluginsNames){ - return this.plugins.find(x => x.constructor.name == name); - } - - finishFormValidation(){ - for(const plugin of this.plugins){ - plugin.createValidation(); - } - } - - defaults(){ - this._defaults && Object.assign(this, this._defaults); - } -} - -export default function Bind(defaults?: T): BindForm & T & {[key: string]: any} { - return new BindForm(defaults); -} \ No newline at end of file diff --git a/packages/forms/src/components/form/BButton.astro b/packages/forms/src/components/form/BButton.astro new file mode 100644 index 0000000..7029552 --- /dev/null +++ b/packages/forms/src/components/form/BButton.astro @@ -0,0 +1,67 @@ +--- +import getContext from '@astro-utils/context'; +import {createUniqueContinuanceName} from '../../form-tools/connectId.js'; +import {isPost, validateAction} from '../../form-tools/post.js'; +import {diffProps} from '../../components-control/props-utils.js'; + +export interface Props> extends astroHTML.JSX.ButtonHTMLAttributes { + onClick: Function; + connectId?: string; + whenFormOK?: boolean; + as?: T; + props?: React.ComponentProps; + state?: any; + extra?: any; +} + +const {as: asComponent = 'button', props: componentProps, onClick, whenFormOK, connectId, ...props} = Astro.props; +const {bind, executeAfter, elementsState, tempValues, tempBindValues, method} = getContext(Astro, '@astro-utils/forms'); +const tempCounter = tempBindValues || tempValues; +const elementPropsState = elementsState || tempValues; + +let buttonUniqueId = connectId; +if (!connectId) { + const idBaseFunction = createUniqueContinuanceName(onClick) + (method ? 'form' : ''); + tempCounter[idBaseFunction] ??= 0; + + const counter = ++tempCounter[idBaseFunction]; + buttonUniqueId = `${idBaseFunction}-${counter}`; +} + +const allProps = {...props, ...componentProps}; +async function executeFormAction(callback: Function = onClick) { + const checkFormValidation = (whenFormOK && !bind?.errors.length) || !whenFormOK; + if (checkFormValidation && isPost(Astro) && (await validateAction(Astro, 'button-callback', buttonUniqueId))) { + const copyProps = structuredClone({...allProps, ...elementPropsState[buttonUniqueId]}); + + await callback.call(copyProps); + delete copyProps.extra; + + elementPropsState[buttonUniqueId] = diffProps(allProps, copyProps); + } +} + +if (executeAfter) { + executeAfter.push(executeFormAction); +} else if (whenFormOK) { + throw new Error('Use BButton with `whenFormOK` inside a BindForm component'); +} else { + await executeFormAction(); +} + +const {innerText, innerHTML, remove: doNotWriteHTML, ...changedProps}: any = {...allProps, ...elementPropsState[buttonUniqueId]}; +delete changedProps.extra; +delete changedProps.state; + +const Component = asComponent as any; +const slotData = innerHTML ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : ''); +--- + +{ + !doNotWriteHTML && ( + + {innerText ?? + } + + ) + } diff --git a/packages/forms/components/form/BInput.astro b/packages/forms/src/components/form/BInput.astro similarity index 87% rename from packages/forms/components/form/BInput.astro rename to packages/forms/src/components/form/BInput.astro index b805206..d58e16b 100644 --- a/packages/forms/components/form/BInput.astro +++ b/packages/forms/src/components/form/BInput.astro @@ -1,8 +1,8 @@ --- import getContext from '@astro-utils/context'; -import {caseTypes, inputReturnValueAttr, validateFormInput} from '../../dist/components/input-parse.js'; -import {validatePostRequest} from '../../dist/form-tools/post.js'; -import {ModifyDeep} from '../../dist/utils.js'; +import {caseTypes, inputReturnValueAttr, validateFormInput} from '../../components-control/input-parse.js'; +import {validateFrom} from '../../form-tools/csrf.js'; +import {ModifyDeep} from '../../utils.js'; type inputTypes = astroHTML.JSX.InputHTMLAttributes['type'] | 'int'; @@ -22,7 +22,7 @@ export interface Props> extends astroHTML.JSX.OptionHTMLAttributes { as?: T; @@ -11,7 +11,7 @@ export interface Props> extends astroHTML.JSX.SelectHTMLAttributes { name: string @@ -18,7 +17,7 @@ const {bind, method} = getContext(Astro, '@astro-utils/forms'); const {as: asComponent = 'select', props: componentProps, value: defaultValue, ...props} = Astro.props; let value = bind[Astro.props.name] ?? defaultValue; -if (!Astro.props.disabled && method === 'POST' && await validatePostRequest(Astro as any)) { +if (!Astro.props.disabled && method === 'POST' && await validateFrom(Astro)) { await validateSelect(Astro, bind); value = await getSelectValue(Astro); } diff --git a/packages/forms/components/form/BTextarea.astro b/packages/forms/src/components/form/BTextarea.astro similarity index 80% rename from packages/forms/components/form/BTextarea.astro rename to packages/forms/src/components/form/BTextarea.astro index 182f92e..b8823e5 100644 --- a/packages/forms/components/form/BTextarea.astro +++ b/packages/forms/src/components/form/BTextarea.astro @@ -1,8 +1,8 @@ --- import getContext from '@astro-utils/context'; -import {validateFormInput} from '../../dist/components/input-parse.js'; -import {validatePostRequest} from '../../dist/form-tools/post.js'; -import {ModifyDeep} from '../../dist/utils.js'; +import {validateFormInput} from '../../components-control/input-parse.js'; +import {validateFrom} from '../../form-tools/csrf.js'; +import {ModifyDeep} from '../../utils.js'; interface ModifyInputProps { minlength?: number; @@ -18,7 +18,7 @@ export interface Props Astro.slots.render('default'), Astro, {name: '@astro-utils/forms', context}); +bind?.finishFormValidation(); +for (const func of context.executeAfter) { + await (func as any)(); +} + +if (context.method == 'POST') { + context.method = 'GET'; + context.tempBindValues = {}; + htmlSolt = await asyncContext(() => Astro.slots.render('default'), Astro, {name: '@astro-utils/forms', context}); +} +--- + +{viewState.useState ? : null} + diff --git a/packages/forms/components/form/FormErrors.astro b/packages/forms/src/components/form/FormErrors.astro similarity index 54% rename from packages/forms/components/form/FormErrors.astro rename to packages/forms/src/components/form/FormErrors.astro index fdb3ad0..e1ffed7 100644 --- a/packages/forms/components/form/FormErrors.astro +++ b/packages/forms/src/components/form/FormErrors.astro @@ -2,15 +2,17 @@ import getContext from '@astro-utils/context'; export interface Props extends astroHTML.JSX.HTMLAttributes { - title?: string + title?: string; }; -const { bind } = getContext(Astro, "@astro-utils/forms"); +const {bind} = getContext(Astro, '@astro-utils/forms'); const {title, ...props} = Astro.props; --- -{bind.errors.length &&
+{bind.errors.length && +
{title &&

{title}

}
    - {bind.errors.map(x =>
  1. {x.message}
  2. )} + {bind.errors.map(x => +
  3. {x.message}
  4. )}
|| ''} diff --git a/packages/forms/src/form-tools/csrf.ts b/packages/forms/src/form-tools/csrf.ts index f0c552c..76c8747 100644 --- a/packages/forms/src/form-tools/csrf.ts +++ b/packages/forms/src/form-tools/csrf.ts @@ -1,8 +1,8 @@ import Tokens from 'csrf'; import {promisify} from 'node:util'; -import {AstroLinkHTTP} from '../utils.js'; +import {type AstroLinkHTTP, createLock} from '../utils.js'; import {getFormValue, isPost} from './post.js'; -import {FORM_OPTIONS} from '../settings.js'; +import {FORM_OPTIONS, getFormOptions} from '../settings.js'; export type CSRFSettings = { formFiled: string, @@ -17,22 +17,30 @@ export const DEFAULT_SETTINGS: CSRFSettings = { const tokens = new Tokens(); const createSecret = () => promisify(tokens.secret.bind(tokens))(); -export async function ensureValidationSecret(astro: AstroLinkHTTP) { +export async function ensureValidationSecret(astro: AstroLinkHTTP, formOptions = getFormOptions(astro)) { const currentSession = astro.locals.session; - return currentSession[FORM_OPTIONS.csrf.sessionFiled] ??= await createSecret(); + return currentSession[formOptions.csrf.sessionFiled] ??= await createSecret(); } export async function validateFrom(astro: AstroLinkHTTP) { - //@ts-ignore - if (!isPost(astro) || typeof astro.request.formData.requestFormValid == 'boolean') return; + const lock = astro.request.validateFormLock ??= createLock(); + await lock.acquireAsync(); - const validationSecret = await ensureValidationSecret(astro); - const validateToken = await getFormValue(astro.request, FORM_OPTIONS.csrf.formFiled); - const requestValid = validateToken && validationSecret && typeof validateToken == 'string' && - tokens.verify(validationSecret, validateToken); + try { + if (!isPost(astro) || typeof astro.request.formData.requestFormValid === 'boolean') { + return astro.request.formData.requestFormValid; + } + + const validationSecret = await ensureValidationSecret(astro); + const validateToken = await getFormValue(astro.request, getFormOptions(astro).csrf.formFiled); + + const requestValid = validateToken && validationSecret && typeof validateToken == 'string' && + tokens.verify(validationSecret, validateToken); - //@ts-ignore - astro.request.formData.requestFormValid = Boolean(requestValid); + return astro.request.formData.requestFormValid = Boolean(requestValid); + } finally { + lock.release(); + } } export async function createFormToken(astro: AstroLinkHTTP) { diff --git a/packages/forms/src/form-tools/forms-react.ts b/packages/forms/src/form-tools/forms-react.ts new file mode 100644 index 0000000..e8a7c04 --- /dev/null +++ b/packages/forms/src/form-tools/forms-react.ts @@ -0,0 +1,99 @@ +import type {ValidRedirectStatus} from 'astro'; +import {AstroLinkHTTP} from 'src/utils.js'; + +export default class FormsReact { + public scriptToRun = ''; + + public constructor(private _astro: AstroLinkHTTP) { + } + + /** + * Redirects the user to the given URL after the given timeout. (using `setTimeout`) + * @param url - URL to redirect to + * @param timeoutSec - timeout in seconds + */ + public redirectTimeoutScends(url: string, timeoutSec: number) { + this.scriptToRun += ` + setTimeout(function() { + window.location.href = new URL("${this._escapeParentheses(url)}", window.location.href).href; + }, ${timeoutSec * 1000}); + `.trim(); + } + + /** + * Update the search parameters of the current URL and return `Response` object. + */ + public updateSearchParams() { + const url = new URL(this._astro.request.url, 'http://example.com'); + const search = url.searchParams; + + return { + search, + redirect(status?: ValidRedirectStatus) { + const pathWithSearch = url.pathname.split('/').pop() + search.toString(); + return new Response(null, { + status: status || 302, + headers: { + Location: pathWithSearch, + }, + }); + } + }; + } + + /** + * Update **one** search parameter of the current URL and return `Response` object. + * @param key - search parameter key + * @param value - search parameter value (if `null` the parameter will be removed) + * @param status - redirect status code + */ + public updateOneSearchParam(key: string, value?: string, status?: ValidRedirectStatus) { + const {search, redirect} = this.updateSearchParams(); + + if (value == null) { + search.delete(key); + } else { + search.set(key, value); + } + + return redirect(status); + } + + /** + * Prompt alert message to the user with the `window.alert` function. + * @param message + */ + public alert(message: string) { + this.callFunction('alert', message); + } + + /** + * Print a message to the client console with the `console` class. + */ + public console(type: keyof Console, ...messages: any[]) { + if (!(type in console)) { + throw new Error(`Invalid console type: ${type}`); + } + + this.callFunction(`console.${type}`, ...messages); + } + + /** + * Print a message to the client console with the `console.log` function. + */ + public consoleLog = this.console.bind(this, 'log'); + + /** + * Call a client side function with the given arguments. + * @warning - this is **not** a safe function, make sure to validate the arguments before calling this function. + */ + public callFunction(func: string, ...args: any[]) { + this.scriptToRun += ` + ${func}(...${JSON.stringify(args)}); + `.trim(); + } + + private _escapeParentheses(str: string) { + return str.replace(/"/g, '\\"'); + } +} diff --git a/packages/forms/src/form-tools/post.ts b/packages/forms/src/form-tools/post.ts index 2ddfee3..a9c7c24 100644 --- a/packages/forms/src/form-tools/post.ts +++ b/packages/forms/src/form-tools/post.ts @@ -1,16 +1,13 @@ -import AwaitLockDefault from 'await-lock'; -import {AstroLinkHTTP} from '../utils.js'; +import {type AstroLinkHTTP, createLock, type ExtendedRequest} from '../utils.js'; import {validateFrom} from './csrf.js'; -const AwaitLock = AwaitLockDefault.default || AwaitLockDefault; export function isPost(astro: {request: Request}){ return astro.request.method === "POST"; } -export async function parseFormData(request: Request): Promise { - //@ts-ignore - const lock = request.formDataLock ??= new AwaitLock(); +export async function parseFormData(request: ExtendedRequest): Promise { + const lock = request.formDataLock ??= createLock(); await lock.acquireAsync(); try { @@ -23,22 +20,16 @@ export async function parseFormData(request: Request): Promise { } } -export async function getFormValue(request: Request, key: string): Promise { +export async function getFormValue(request: ExtendedRequest, key: string): Promise { const data = await parseFormData(request); return data.get(key); } -export async function getFormMultiValue(request: Request, key: string): Promise { +export async function getFormMultiValue(request: ExtendedRequest, key: string): Promise { const data = await parseFormData(request); return data.getAll(key); } -export async function validatePostRequest(astro: AstroLinkHTTP){ - await validateFrom(astro); // load the session & validation, the session contains the secrets for the validation - //@ts-ignore - return astro.request.formData.requestFormValid; -} - export async function validateAction(astro: AstroLinkHTTP, formKey: string, value: string){ - return await validatePostRequest(astro) && await getFormValue(astro.request, formKey) == value; + return await validateFrom(astro) && await getFormValue(astro.request, formKey) == value; } diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index d57362e..0391bb4 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -1,8 +1,5 @@ -import type {File as PersistentFile} from 'formidable'; import astroMiddleware from './middleware.js'; export { - PersistentFile, astroMiddleware as default -}; - +}; \ No newline at end of file diff --git a/packages/forms/src/jwt-session.ts b/packages/forms/src/jwt-session.ts index e571a6f..837f3e2 100644 --- a/packages/forms/src/jwt-session.ts +++ b/packages/forms/src/jwt-session.ts @@ -1,5 +1,5 @@ import {FORM_OPTIONS} from './settings.js'; -import {AstroLinkHTTP} from './utils.js'; +import type {AstroLinkHTTP} from './utils.js'; import jwt from 'jsonwebtoken'; import cookie from 'cookie'; import {deepStrictEqual} from 'assert'; diff --git a/packages/forms/src/middleware.ts b/packages/forms/src/middleware.ts index ba1cb58..d5a6504 100644 --- a/packages/forms/src/middleware.ts +++ b/packages/forms/src/middleware.ts @@ -1,17 +1,16 @@ -import {APIContext, MiddlewareHandler, MiddlewareNext} from 'astro'; +import type {APIContext, MiddlewareHandler, MiddlewareNext} from 'astro'; import {DEFAULT_SETTINGS as DEFAULT_SETTINGS_CSRF, ensureValidationSecret} from './form-tools/csrf.js'; import {JWTSession} from './jwt-session.js'; -import {FORM_OPTIONS, FormsSettings} from './settings.js'; +import {FORM_OPTIONS, type FormsSettings} from './settings.js'; import {v4 as uuid} from 'uuid'; -import defaults from 'defaults'; +import deepmerge from 'deepmerge'; import {timeout} from 'promise-timeout'; +import FormsReact from './form-tools/forms-react.js'; const DEFAULT_FORM_OPTIONS: FormsSettings = { csrf: DEFAULT_SETTINGS_CSRF, forms: { - allowEmptyFiles: true, - minFileSize: 0, - multiples: true + viewStateFormFiled: '__view-state', }, session: { cookieName: 'session', @@ -22,15 +21,20 @@ const DEFAULT_FORM_OPTIONS: FormsSettings = { } }, pageLoadTimeoutMS: 1000 * 5, - secret: uuid() + secret: uuid(), + logs: (type, message) => { + console[type](message); + }, }; -export default function astroForms(settings: Partial = {}){ - Object.assign(FORM_OPTIONS, defaults(settings, DEFAULT_FORM_OPTIONS)); +export default function astroForms(settings: Partial = {}) { + Object.assign(FORM_OPTIONS, deepmerge(settings, DEFAULT_FORM_OPTIONS)); return async function onRequest({locals, request, cookies}: APIContext, next: MiddlewareNext) { + const likeAstro = {locals, request, cookies}; const session = new JWTSession(cookies); locals.session = session.sessionData; + locals.forms = new FormsReact(likeAstro); let response: Response; let pageFinished: (data?: any) => void; @@ -39,10 +43,11 @@ export default function astroForms(settings: Partial = {}){ onWebFormClose() { session.setCookieHeader(response.headers); pageFinished(); - } + }, + FORM_OPTIONS: FORM_OPTIONS }; - await ensureValidationSecret({locals, request, cookies}); + await ensureValidationSecret(likeAstro); response = await next(); if (!locals.webFormOff) { @@ -50,7 +55,7 @@ export default function astroForms(settings: Partial = {}){ const pageFinishedPromise = new Promise(resolve => pageFinished = resolve); await timeout(pageFinishedPromise, FORM_OPTIONS.pageLoadTimeoutMS); } catch { - console.warn('WebForms page load timeout (are you sure you are using WebForms?)'); + FORM_OPTIONS.logs?.('warn', 'WebForms page load timeout (are you sure you are using WebForms?)'); } } diff --git a/packages/forms/src/settings.ts b/packages/forms/src/settings.ts index b6fbb3e..288e9fd 100644 --- a/packages/forms/src/settings.ts +++ b/packages/forms/src/settings.ts @@ -1,10 +1,11 @@ -// @ts-ignore -import {Options as formidableOptions} from 'formidable'; -import {CSRFSettings} from './form-tools/csrf.js'; +import type {CSRFSettings} from './form-tools/csrf.js'; +import {AstroLinkHTTP} from './utils.js'; export type FormsSettings = { csrf?: CSRFSettings - forms?: formidableOptions + forms?: { + viewStateFormFiled: string + } session?: { cookieName: string cookieOptions: { @@ -15,14 +16,11 @@ export type FormsSettings = { }, secret?: string, pageLoadTimeoutMS?: number + logs?: (type: 'warn' | 'error' | 'log', message: string) => void } -/// -declare namespace App { - export interface Locals { - session: { [key: string]: any }; - } -} - - export const FORM_OPTIONS: FormsSettings = {} as any; + +export function getFormOptions(Astro: AstroLinkHTTP) { + return Astro.locals?.__formsInternalUtils?.FORM_OPTIONS ?? FORM_OPTIONS; +} diff --git a/packages/forms/src/utils.ts b/packages/forms/src/utils.ts index 856a790..0483b08 100644 --- a/packages/forms/src/utils.ts +++ b/packages/forms/src/utils.ts @@ -1,7 +1,26 @@ -import {AstroGlobal} from 'astro'; +import type {AstroGlobal} from 'astro'; +import {FormsSettings} from './settings.js'; +import AwaitLockDefault from 'await-lock'; +import FormsReact from './form-tools/forms-react.js'; + +export function createLock(): InstanceType { + if ('default' in AwaitLockDefault) { + return new AwaitLockDefault.default(); + } + + return new (AwaitLockDefault as any)(); +} + +export type ExtendedRequest = AstroGlobal['request'] & { + formDataLock?: ReturnType + validateFormLock?: ReturnType + formData: (Request['formData'] | (() => FormData | Promise)) & { + requestFormValid?: boolean + } +} export interface AstroLinkHTTP { - request: AstroGlobal['request'] + request: ExtendedRequest; cookies: AstroGlobal['cookies'] locals: AstroGlobal['locals']; } @@ -9,11 +28,18 @@ export interface AstroLinkHTTP { declare global { export namespace App { interface Locals { - [key: string]: any; + /** + * @internal + */ + __formsInternalUtils: { + onWebFormClose(): void; + FORM_OPTIONS: FormsSettings; + }; + forms: FormsReact; webFormOff?: boolean; session: { [key: string]: any; - } + }; } } } diff --git a/packages/forms/tsconfig.json b/packages/forms/tsconfig.json index 52c9b16..6de938d 100644 --- a/packages/forms/tsconfig.json +++ b/packages/forms/tsconfig.json @@ -12,13 +12,14 @@ "outDir": "dist", "rootDir": "src", "declaration": true, - "skipLibCheck": true + "skipLibCheck": true, + "stripInternal": true, + "baseUrl": "." }, "include": [ - "src/**/*.ts" + "src/**/**.ts" ], "exclude": [ - "src/**/*.spec.ts", "node_modules" ] }