diff --git a/.changeset/config.json b/.changeset/config.json index cfba6f9a..9f190563 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,6 +4,7 @@ "commit": false, "fixed": [ [ + "@conform-to/class-validator", "@conform-to/dom", "@conform-to/react", "@conform-to/yup", diff --git a/package.json b/package.json index e5095d3a..10a4edda 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "test:api": "vitest --watch", "test:e2e": "playwright test --ui", "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx .", + "format": "prettier --write ./packages", "prepare": "husky install" }, "devDependencies": { "@changesets/cli": "^2.27.5", + "@conform-to/class-validator": "workspace:*", "@conform-to/dom": "workspace:*", "@conform-to/react": "workspace:*", "@conform-to/validitystate": "workspace:*", @@ -25,6 +27,7 @@ "@types/node": "^20.10.4", "@typescript-eslint/eslint-plugin": "^7.11.0", "@typescript-eslint/parser": "^7.11.0", + "class-validator": "0.14.1", "eslint": "^8.57.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.1", diff --git a/packages/conform-class-validator/.gitignore b/packages/conform-class-validator/.gitignore new file mode 100644 index 00000000..12c3f951 --- /dev/null +++ b/packages/conform-class-validator/.gitignore @@ -0,0 +1,7 @@ +_virtual +*.cjs +*.d.ts +*.js +*.mjs +README +!rollup.config.js diff --git a/packages/conform-class-validator/.npmignore b/packages/conform-class-validator/.npmignore new file mode 100644 index 00000000..0ece78dd --- /dev/null +++ b/packages/conform-class-validator/.npmignore @@ -0,0 +1,9 @@ +# ignore all .ts, .tsx files except .d.ts +*.ts +*.tsx +!*.d.ts + +# config / build result +rollup.config.js +tsconfig.json +*.tsbuildinfo diff --git a/packages/conform-class-validator/index.ts b/packages/conform-class-validator/index.ts new file mode 100644 index 00000000..93066b56 --- /dev/null +++ b/packages/conform-class-validator/index.ts @@ -0,0 +1 @@ +export { parseWithClassValidator } from './parse'; diff --git a/packages/conform-class-validator/package.json b/packages/conform-class-validator/package.json new file mode 100644 index 00000000..a791d851 --- /dev/null +++ b/packages/conform-class-validator/package.json @@ -0,0 +1,62 @@ +{ + "name": "@conform-to/class-validator", + "description": "Conform helpers for integrating with class-validator", + "homepage": "https://conform.guide", + "license": "MIT", + "version": "0.1.0", + "main": "index.js", + "module": "index.mjs", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "module": "./index.mjs", + "import": "./index.mjs", + "require": "./index.js", + "default": "./index.mjs" + } + }, + "scripts": { + "build:js": "rollup -c", + "build:ts": "tsc", + "build": "pnpm run \"/^build:.*/\"", + "dev:js": "pnpm run build:js --watch", + "dev:ts": "pnpm run build:ts --watch", + "dev": "pnpm run \"/^dev:.*/\"", + "prepare": "pnpm run build" + }, + "repository": { + "type": "git", + "url": "https://github.com/edmundhung/conform", + "directory": "packages/conform-class-validator" + }, + "bugs": { + "url": "https://github.com/edmundhung/conform/issues" + }, + "dependencies": { + "@conform-to/dom": "workspace:*" + }, + "peerDependencies": { + "class-validator": "^0.14.1" + }, + "devDependencies": { + "@babel/core": "^7.17.8", + "@babel/preset-env": "^7.20.2", + "@babel/preset-typescript": "^7.20.2", + "@rollup/plugin-babel": "^5.3.1", + "@rollup/plugin-node-resolve": "^13.3.0", + "rollup-plugin-copy": "^3.4.0", + "rollup": "^2.79.1", + "class-validator": "^0.14.1" + }, + "keywords": [ + "constraint-validation", + "form", + "form-validation", + "html", + "progressive-enhancement", + "validation", + "class-validator" + ], + "sideEffects": false +} diff --git a/packages/conform-class-validator/parse.ts b/packages/conform-class-validator/parse.ts new file mode 100644 index 00000000..a0a49f87 --- /dev/null +++ b/packages/conform-class-validator/parse.ts @@ -0,0 +1,87 @@ +import { type Submission, parse } from '@conform-to/dom'; +import { type ValidationError, validate, validateSync } from 'class-validator'; + +class ConformClassValidatorModel> { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(data: T) { + // dummy class just for typing purposes + } +} + +type TConformClassValidatorModelConstructor> = + new (data: T, ...args: any[]) => ConformClassValidatorModel; + +export function parseWithClassValidator>( + payload: FormData, + config: { + schema: TConformClassValidatorModelConstructor; + async?: false; + }, +): Submission, string[]>; + +export function parseWithClassValidator>( + payload: FormData, + config: { + schema: TConformClassValidatorModelConstructor; + async: true; + }, +): Promise, string[]>>; + +export function parseWithClassValidator>( + payload: FormData, + config: { + schema: TConformClassValidatorModelConstructor; + async?: boolean; + }, +): + | Submission, string[]> + | Promise, string[]>> { + return parse, string[]>(payload, { + resolve(payload) { + const { schema: Model } = config; + + const resolveError = (errors: ValidationError[]) => + errors.reduce( + (acc: Record, current: ValidationError) => { + acc[current.property] = current.constraints + ? Object.values(current.constraints) + : []; + + return acc; + }, + {}, + ); + + try { + const model = new Model(payload as T); + + const resolveSubmission = (errors: ValidationError[]) => { + if (errors.length > 0) { + return { value: undefined, error: resolveError(errors) }; + } + + return { + value: Object.getOwnPropertyNames(model).reduce( + (acc: Record, modelPublicKey: string) => { + // @ts-ignore + acc[modelPublicKey] = model[modelPublicKey]; + + return acc; + }, + {}, + ), + error: undefined, + }; + }; + + if (!config.async) { + return resolveSubmission(validateSync(model)); + } + + return validate(model).then(resolveSubmission); + } catch { + throw new Error('Bad validation model passed!'); + } + }, + }); +} diff --git a/packages/conform-class-validator/rollup.config.js b/packages/conform-class-validator/rollup.config.js new file mode 100644 index 00000000..bbe6ded3 --- /dev/null +++ b/packages/conform-class-validator/rollup.config.js @@ -0,0 +1,97 @@ +import path from 'node:path'; +import babel from '@rollup/plugin-babel'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import copy from 'rollup-plugin-copy'; + +/** @returns {import("rollup").RollupOptions[]} */ +function configurePackage() { + let sourceDir = '.'; + let outputDir = sourceDir; + + /** @type {import("rollup").RollupOptions} */ + let ESM = { + external(id) { + return !id.startsWith('.') && !path.isAbsolute(id); + }, + input: `${sourceDir}/index.ts`, + output: { + dir: outputDir, + format: 'esm', + preserveModules: true, + entryFileNames: '[name].mjs', + }, + plugins: [ + babel({ + babelrc: false, + configFile: false, + presets: [ + [ + '@babel/preset-env', + { + targets: { + node: '16', + esmodules: true, + }, + }, + ], + '@babel/preset-typescript', + ], + plugins: [], + babelHelpers: 'bundled', + exclude: /node_modules/, + extensions: ['.ts', '.tsx'], + }), + nodeResolve({ + extensions: ['.ts', '.tsx'], + }), + copy({ + targets: [{ src: `../../README`, dest: sourceDir }], + }), + ], + }; + + /** @type {import("rollup").RollupOptions} */ + let CJS = { + external(id) { + return !id.startsWith('.') && !path.isAbsolute(id); + }, + input: `${sourceDir}/index.ts`, + output: { + dir: outputDir, + format: 'cjs', + preserveModules: true, + exports: 'auto', + }, + plugins: [ + babel({ + babelrc: false, + configFile: false, + presets: [ + [ + '@babel/preset-env', + { + targets: { + node: '16', + esmodules: true, + }, + }, + ], + '@babel/preset-typescript', + ], + plugins: [], + babelHelpers: 'bundled', + exclude: /node_modules/, + extensions: ['.ts', '.tsx'], + }), + nodeResolve({ + extensions: ['.ts', '.tsx'], + }), + ], + }; + + return [ESM, CJS]; +} + +export default function rollup() { + return configurePackage(); +} diff --git a/packages/conform-class-validator/tsconfig.json b/packages/conform-class-validator/tsconfig.json new file mode 100644 index 00000000..75296d3b --- /dev/null +++ b/packages/conform-class-validator/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "noUncheckedIndexedAccess": true, + "strict": true, + "declaration": true, + "emitDeclarationOnly": true, + "composite": true, + "skipLibCheck": true, + "experimentalDecorators": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92f196a0..faaa4f7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.27.5 version: 2.27.5 + '@conform-to/class-validator': + specifier: workspace:* + version: link:packages/conform-class-validator '@conform-to/dom': specifier: workspace:* version: link:packages/conform-dom @@ -38,6 +41,9 @@ importers: '@typescript-eslint/parser': specifier: ^7.11.0 version: 7.11.0(eslint@8.57.0)(typescript@5.4.5) + class-validator: + specifier: 0.14.1 + version: 0.14.1 eslint: specifier: ^8.57.0 version: 8.57.0 @@ -580,7 +586,7 @@ importers: version: 12.4.0 '@remix-run/dev': specifier: ^2.5.1 - version: 2.9.1(@remix-run/react@2.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.4.5))(@remix-run/serve@2.9.1(typescript@5.4.5))(@types/node@20.11.20)(terser@5.26.0)(typescript@5.4.5)(vite@5.1.4(@types/node@20.11.20)(terser@5.26.0))(wrangler@3.53.1) + version: 2.9.1(@remix-run/react@2.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.4.5))(@remix-run/serve@2.9.1(typescript@5.4.5))(@types/node@20.11.20)(terser@5.26.0)(typescript@5.4.5)(vite@5.1.4(@types/node@20.11.20)(terser@5.26.0))(wrangler@3.53.1(@cloudflare/workers-types@4.20240502.0)) '@tailwindcss/forms': specifier: ^0.5.7 version: 0.5.7(tailwindcss@3.4.3) @@ -606,6 +612,37 @@ importers: specifier: ^3.28.2 version: 3.53.1(@cloudflare/workers-types@4.20240502.0) + packages/conform-class-validator: + dependencies: + '@conform-to/dom': + specifier: workspace:* + version: link:../conform-dom + devDependencies: + '@babel/core': + specifier: ^7.17.8 + version: 7.23.5 + '@babel/preset-env': + specifier: ^7.20.2 + version: 7.23.5(@babel/core@7.23.5) + '@babel/preset-typescript': + specifier: ^7.20.2 + version: 7.23.3(@babel/core@7.23.5) + '@rollup/plugin-babel': + specifier: ^5.3.1 + version: 5.3.1(@babel/core@7.23.5)(@types/babel__core@7.20.5)(rollup@2.79.1) + '@rollup/plugin-node-resolve': + specifier: ^13.3.0 + version: 13.3.0(rollup@2.79.1) + class-validator: + specifier: ^0.14.1 + version: 0.14.1 + rollup: + specifier: ^2.79.1 + version: 2.79.1 + rollup-plugin-copy: + specifier: ^3.4.0 + version: 3.5.0 + packages/conform-dom: devDependencies: '@babel/core': @@ -809,7 +846,7 @@ importers: devDependencies: '@remix-run/dev': specifier: ^2.9.1 - version: 2.9.1(@remix-run/react@2.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.4.5))(@remix-run/serve@2.9.1(typescript@5.4.5))(@types/node@20.11.20)(terser@5.26.0)(typescript@5.4.5)(vite@5.1.4(@types/node@20.11.20)(terser@5.26.0))(wrangler@3.53.1) + version: 2.9.1(@remix-run/react@2.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.4.5))(@remix-run/serve@2.9.1(typescript@5.4.5))(@types/node@20.11.20)(terser@5.26.0)(typescript@5.4.5)(vite@5.1.4(@types/node@20.11.20)(terser@5.26.0))(wrangler@3.53.1(@cloudflare/workers-types@4.20240502.0)) '@tailwindcss/forms': specifier: ^0.5.2 version: 0.5.7(tailwindcss@3.4.3) @@ -4501,6 +4538,9 @@ packages: '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} + '@types/validator@13.12.0': + resolution: {integrity: sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==} + '@types/ws@8.5.10': resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} @@ -5375,6 +5415,9 @@ packages: cjs-module-lexer@1.2.3: resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + class-validator@0.14.1: + resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==} + class-variance-authority@0.7.0: resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} @@ -7756,6 +7799,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.11.5: + resolution: {integrity: sha512-TwHR5BZxGRODtAfz03szucAkjT5OArXr+94SMtAM2pYXIlQNVMrxvb6uSCbnaJJV6QXEyICk7+l6QPgn72WHhg==} + lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -10727,6 +10773,10 @@ packages: resolution: {integrity: sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + validator@13.12.0: + resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -14643,7 +14693,7 @@ snapshots: - ts-node - utf-8-validate - '@remix-run/dev@2.9.1(@remix-run/react@2.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.4.5))(@remix-run/serve@2.9.1(typescript@5.4.5))(@types/node@20.11.20)(terser@5.26.0)(typescript@5.4.5)(vite@5.1.4(@types/node@20.11.20)(terser@5.26.0))(wrangler@3.53.1)': + '@remix-run/dev@2.9.1(@remix-run/react@2.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.4.5))(@remix-run/serve@2.9.1(typescript@5.4.5))(@types/node@20.11.20)(terser@5.26.0)(typescript@5.4.5)(vite@5.1.4(@types/node@20.11.20)(terser@5.26.0))(wrangler@3.53.1(@cloudflare/workers-types@4.20240502.0))': dependencies: '@babel/core': 7.23.5 '@babel/generator': 7.23.5 @@ -15437,6 +15487,8 @@ snapshots: '@types/unist@2.0.10': {} + '@types/validator@13.12.0': {} + '@types/ws@8.5.10': dependencies: '@types/node': 20.11.20 @@ -16596,6 +16648,12 @@ snapshots: cjs-module-lexer@1.2.3: {} + class-validator@0.14.1: + dependencies: + '@types/validator': 13.12.0 + libphonenumber-js: 1.11.5 + validator: 13.12.0 + class-variance-authority@0.7.0: dependencies: clsx: 2.0.0 @@ -17671,7 +17729,7 @@ snapshots: debug: 4.3.4 enhanced-resolve: 5.15.0 eslint: 8.57.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.11.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.11.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.11.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.11.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.11.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 @@ -17727,6 +17785,16 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-module-utils@2.8.0(@typescript-eslint/parser@7.11.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.11.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 7.11.0(eslint@8.57.0)(typescript@5.4.5) + eslint: 8.57.0 + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.11.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0) + transitivePeerDependencies: + - supports-color + eslint-plugin-es@3.0.1(eslint@8.57.0): dependencies: eslint: 8.57.0 @@ -19787,6 +19855,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.11.5: {} + lilconfig@2.1.0: {} lilconfig@3.0.0: {} @@ -23269,6 +23339,8 @@ snapshots: dependencies: builtins: 5.0.1 + validator@13.12.0: {} + vary@1.1.2: {} vfile-message@3.1.4: diff --git a/tests/conform-class-validator.spec.ts b/tests/conform-class-validator.spec.ts new file mode 100644 index 00000000..f9b2074b --- /dev/null +++ b/tests/conform-class-validator.spec.ts @@ -0,0 +1,95 @@ +import { beforeEach, vi, describe, test, expect } from 'vitest'; +import { parseWithClassValidator } from '@conform-to/class-validator'; +import { IsNotEmpty, Min, MinLength } from 'class-validator'; +import { createFormData } from './helpers'; + +beforeEach(() => { + vi.unstubAllGlobals(); +}); + +interface IPayload { + name: string; + qty: string; +} + +class TestModel { + constructor(data: IPayload) { + this.name = data.name; + this.qty = Number(data.qty); + } + + @IsNotEmpty({ message: 'IsNotEmpty' }) + @MinLength(3, { message: 'MinLength' }) + name: string; + + @Min(1, { message: 'Min' }) + qty: number; +} + +describe('conform-class-validator', () => { + describe('parseWithClassValidator', () => { + test('no data', () => { + expect( + parseWithClassValidator( + createFormData([ + ['name', ''], + ['qty', ''], + ]), + { + schema: TestModel, + }, + ), + ).toEqual({ + status: 'error', + payload: { name: '', qty: '' }, + error: { name: ['MinLength', 'IsNotEmpty'], qty: ['Min'] }, + reply: expect.any(Function), + }); + }); + + test('no name', () => { + expect( + parseWithClassValidator(createFormData([['qty', '5']]), { + schema: TestModel, + }), + ).toEqual({ + status: 'error', + payload: { qty: '5' }, + error: { name: ['MinLength', 'IsNotEmpty'] }, + reply: expect.any(Function), + }); + }); + + test('no qty', () => { + expect( + parseWithClassValidator(createFormData([['name', 'John']]), { + schema: TestModel, + }), + ).toEqual({ + status: 'error', + payload: { name: 'John' }, + error: { qty: ['Min'] }, + reply: expect.any(Function), + }); + }); + + test('all good', () => { + expect( + parseWithClassValidator( + createFormData([ + ['name', 'John'], + ['qty', '5'], + ]), + { + schema: TestModel, + }, + ), + ).toEqual({ + status: 'success', + payload: { name: 'John', qty: '5' }, + value: { name: 'John', qty: 5 }, + reply: expect.any(Function), + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 386964ba..fc2960f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,7 @@ { + "compilerOptions": { + "experimentalDecorators": true + }, "references": [ { "path": "./packages/conform-dom" }, { "path": "./packages/conform-yup" },