From 8e3eacca29bb77076cc43c7fc64ca0deb0a99b18 Mon Sep 17 00:00:00 2001 From: Eric Sitarz Date: Mon, 28 Sep 2026 16:25:01 -0500 Subject: [PATCH 1/5] feat(form)!: switch useOcForm to TanStack Form BREAKING CHANGE: useOcForm returns { form, resourceSchema, parseValues } instead of React Hook Form methods. Validation is Zod. The react-hook-form and @hookform/resolvers peers are removed. @tanstack/react-form ^1.33.5 is required. Save parseValues(), not the raw form state. Empty numbers and null booleans are omitted. An entered integer must be a whole number and still has to meet the spec minimum. --- CHANGELOG.md | 7 + README.md | 81 ++++++++- package-lock.json | 270 ++++++++++++++---------------- package.json | 10 +- src/formSchemaGenerator.utils.tsx | 246 +++++++++++++++------------ src/hooks/index.ts | 1 + src/hooks/useOcForm.ts | 168 +++++++++++-------- vite.config.ts | 2 + 8 files changed, 447 insertions(+), 338 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afa25ba..b45e0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.0.0 + +### Breaking + +- replace `react-hook-form` with `@tanstack/react-form` in `useOcForm`. The hook now returns `{ form, resourceSchema, parseValues }` instead of `{ methods, resourceSchema }`. +- generate form validation with Zod instead of Yup, and remove the `react-hook-form` and `@hookform/resolvers` peer dependencies. + ## 0.3.0 (2025-08-04) ### Feat diff --git a/README.md b/README.md index 18c06bf..7d09925 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ This is a developer library for OrderCloud developers utilizing React for their # Peer Dependencies To keep this package small, there are some required peer dependencies that are typical of an OrderCloud React application that utilizies tanstack libraries: -- @tanstack/react-query@^5.20.1 -- @tanstack/react-table@^8.11.8 -- ordercloud-javascript-sdk@^5.3.0 -- react@^18.2.0 -- react-dom@^18.2.0 +| Package | Supported versions | +| --- | --- | +| `@tanstack/react-form` | `^1.33.5` | +| `@tanstack/react-query` | `^5.62.2` | +| `@tanstack/react-table` | `^8.20.5` | +| `axios` | `^1.1.3` | +| `ordercloud-javascript-sdk` | `^11.1.0` | +| `react` | `^18.3.1` | +| `react-dom` | `^18.3.1` | # Usage @@ -368,7 +372,60 @@ An **optional** list that will be used to sort table header order if provided. An **optional** callback for defining custom elements for `@tanstack/react-table` table cells by data type. ## `useOcForm()` hook -This hook returns a `UseFormReturn` methods object and a "shallowed" resource schema. If a resource's xp schema is included in the `xpSchemas` option for `OrderCloudProvider`, these properties will be included in the form fields. +This hook returns a TanStack Form instance, a shallow OpenAPI resource schema, and `parseValues`. If a resource's xp schema is included in the `xpSchemas` option for `OrderCloudProvider`, these properties will be included in the form fields. + +Form values are always `{ parameters, body }`. `parameters` holds operation parameters such as IDs. `body` holds the resource fields the user edits. `resourceSchema` describes those fields so a screen can render inputs. The hook does not save to OrderCloud. After validation, send `parseValues()` to the OrderCloud SDK. `parseValues()` omits empty numbers, null booleans, mismatched optional values, and read-only fields. A number the merchant entered still has to meet the spec minimum, and an integer field has to be a whole number. TanStack Form keeps the raw field state. + +```tsx +const { form, parseValues } = useOcForm("Products", { + parameters: { productID: product?.ID }, + body: product, +}, undefined, undefined, { + onSubmit: async () => { + const values = parseValues() + await Products.Save(String(values.parameters.productID), values.body) + }, +}) + +return ( +
{ + event.preventDefault() + event.stopPropagation() + void form.handleSubmit() + }} + > + + {(field) => ( + <> + field.handleChange(event.target.value)} + /> + {field.state.meta.errors.length > 0 && ( +

{field.state.meta.errors.map((error) => error?.message ?? String(error)).join(", ")}

+ )} + + )} +
+ +
+) +``` + +### Migrating from React Hook Form + +`useOcForm` previously returned React Hook Form's `methods` object. Replace each call site: + +- `methods.register("body.Name")` / `Controller` becomes `form.Field` with `name="body.Name"`, `field.state.value`, `field.handleChange`, and `field.handleBlur`. +- `methods.formState.errors.body?.Name?.message` becomes `field.state.meta.errors`. +- `methods.getValues("body.Name")` becomes `form.getFieldValue("body.Name")`. +- `methods.watch` becomes `form.Subscribe`. +- `methods.handleSubmit(onValid)` becomes `form.handleSubmit()`. Save `parseValues()` instead of the raw submit value. +- `methods.reset(next)` becomes `form.reset(next)`. +- `useFieldArray({ name })` becomes `form.Field` with `mode="array"` (`pushValue`, `removeValue`, and the other array helpers). +- Validation still defaults to `onBlur`. ### Options @@ -376,10 +433,16 @@ This hook returns a `UseFormReturn` methods object and a "shallowed" resource sc The OrderCloud resource name used for generating column information. #### **initialValues**: `{ parameters?: {[key: string]: unknown}, body?: {[key: string]: unknown} }` -An **optional** object that contains initial parameter and body values for an item of a given resource ID. The absence of body values indicates a new item. +An **optional** object that contains initial parameter and body values for an item of a given resource ID. The absence of body values indicates a new item. When this object later changes (for example, after a resource request resolves), the form resets to those values. + +#### **isAssignment**: `boolean` +An **optional** flag. When true, the hook validates the assignment save operation instead of create or save. + +#### **operationInclusion**: `string` +An **optional** operation-name suffix used to select an assignment operation. -#### **props**: `UseFormProps` -An **optional** `react-hook-form` `useForm` props object +#### **options**: `{ validationTrigger?: "onBlur" | "onChange" | "onSubmit", onSubmit?: (values) => void, formOptions?: object }` +An **optional** settings object. `validationTrigger` defaults to `onBlur`. Submit validation still runs when the trigger is `onBlur` or `onChange`. `onSubmit` is forwarded to TanStack Form. `formOptions` accepts extra TanStack Form `useForm` options, except `defaultValues` and `validators`, which this hook owns. ## `useHasAccess()` hook This hook determines whether a user has read or admin access to an OrderCloud resource by comparing the user's token and application scope to the OAuth roles indicated on the OpenAPI spec. diff --git a/package-lock.json b/package-lock.json index aed010e..3e8e0db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ordercloud/react-sdk", - "version": "0.3.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ordercloud/react-sdk", - "version": "0.3.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "@apidevtools/swagger-parser": "^10.1.0", @@ -19,9 +19,10 @@ "lodash": "^4.17.21", "openapi-types": "^12.1.3", "vite-plugin-node-polyfills": "^0.23.0", - "yup": "^1.4.0" + "zod": "^3.25.76" }, "devDependencies": { + "@tanstack/react-form": "^1.33.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^8.17.0", @@ -35,14 +36,13 @@ "vite-plugin-dts": "^4.5.4" }, "peerDependencies": { - "@hookform/resolvers": "^3.3.4", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.62.2", "@tanstack/react-table": "^8.20.5", "axios": "^1.1.3", "ordercloud-javascript-sdk": "^11.1.0", "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-hook-form": "^7.53.2" + "react-dom": "^18.3.1" } }, "node_modules/@ampproject/remapping": { @@ -985,15 +985,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@hookform/resolvers": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", - "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", - "peer": true, - "peerDependencies": { - "react-hook-form": "^7.0.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1087,17 +1078,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1745,6 +1725,53 @@ "string-argv": "~0.3.1" } }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz", + "integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==", + "dev": true, + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.33.5", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.33.5.tgz", + "integrity": "sha512-3dfx9MBP0aq5sXKteikG629X9oviptrQj0IFRk9YGcb+lB7Kv5x8S17oOSk1wUWgjQZ4xVJEMbKwOAODymocgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-async-storage-persister": { "version": "5.62.2", "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.62.2.tgz", @@ -1778,6 +1805,48 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-form": { + "version": "1.33.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.33.5.tgz", + "integrity": "sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.33.5", + "@tanstack/react-store": "^0.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-form/node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/react-query": { "version": "5.62.2", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.2.tgz", @@ -1830,6 +1899,17 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/table-core": { "version": "8.20.5", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", @@ -1908,16 +1988,6 @@ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==" }, - "node_modules/@types/node": { - "version": "22.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", - "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~6.20.0" - } - }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", @@ -2652,13 +2722,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "optional": true, - "peer": true - }, "node_modules/buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", @@ -4050,17 +4113,6 @@ "node": ">=10" } }, - "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", @@ -4806,11 +4858,6 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, - "node_modules/property-expr": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", - "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4941,22 +4988,6 @@ "react": "^18.3.1" } }, - "node_modules/react-hook-form": { - "version": "7.53.2", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.2.tgz", - "integrity": "sha512-YVel6fW5sOeedd1524pltpHX+jgU2u3DSDtXEaBORNdqiNrsX/nUI/iGXONegttg0mJVnfrIkiV0cmTU6Oo2xw==", - "peer": true, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18 || ^19" - } - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5211,17 +5242,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -5326,32 +5346,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/terser": { - "version": "5.36.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", - "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/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==", - "optional": true, - "peer": true - }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -5363,11 +5357,6 @@ "node": ">=0.6.0" } }, - "node_modules/tiny-case": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", - "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" - }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -5422,11 +5411,6 @@ "node": ">=8.0" } }, - "node_modules/toposort": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" - }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -5477,13 +5461,6 @@ "dev": true, "license": "MIT" }, - "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "optional": true, - "peer": true - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -5555,6 +5532,16 @@ "node": ">= 0.4" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -5795,26 +5782,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yup": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", - "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", - "dependencies": { - "property-expr": "^2.0.5", - "tiny-case": "^1.0.3", - "toposort": "^2.0.2", - "type-fest": "^2.19.0" - } - }, - "node_modules/yup/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "engines": { - "node": ">=12.20" - }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/colinhacks" } } } diff --git a/package.json b/package.json index eea8197..ff9909d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "license": "MIT", "private": false, - "version": "0.3.0", + "version": "1.0.0", "type": "module", "files": [ "package.json", @@ -56,19 +56,19 @@ "lodash": "^4.17.21", "openapi-types": "^12.1.3", "vite-plugin-node-polyfills": "^0.23.0", - "yup": "^1.4.0" + "zod": "^3.25.76" }, "peerDependencies": { - "@hookform/resolvers": "^3.3.4", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.62.2", "@tanstack/react-table": "^8.20.5", "axios": "^1.1.3", "ordercloud-javascript-sdk": "^11.1.0", "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-hook-form": "^7.53.2" + "react-dom": "^18.3.1" }, "devDependencies": { + "@tanstack/react-form": "^1.33.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^8.17.0", diff --git a/src/formSchemaGenerator.utils.tsx b/src/formSchemaGenerator.utils.tsx index 434b0a1..f444d21 100644 --- a/src/formSchemaGenerator.utils.tsx +++ b/src/formSchemaGenerator.utils.tsx @@ -1,7 +1,7 @@ -import * as yup from 'yup' import { isNil, isNull, last } from 'lodash' import _ from 'lodash' import Case from 'case' +import { z, ZodTypeAny } from 'zod' //massages the operation.parameters into a schema object that //can be used by `generateFormSchema` and RequestObject. @@ -50,150 +50,188 @@ function getPropertyLabel(property: string) { } export function generateFormSchema(schemaProps: any, requiredProps?: string[]) { - const schemaShape = {} as any - const schemaKeys = Object.keys(schemaProps) - schemaKeys.forEach((propName) => { - const target = schemaProps[propName] - //Do not add readOnly fields to form schema - if (target?.readOnly) { - return - } - switch (target?.type) { - case 'string': - schemaShape[propName] = ShapeStringProp(propName, target) - break - case 'number': - schemaShape[propName] = ShapeNumberProp(target) - break - case 'integer': - schemaShape[propName] = ShapeNumberProp(target) - break - case 'boolean': - schemaShape[propName] = yup.boolean() - break - case 'object': - schemaShape[propName] = ShapeObjectProp(target) - break - case 'array': - schemaShape[propName] = ShapeArrayProp(propName, target) - break - default: - schemaShape[propName] = yup.string() - break - } - if (schemaShape[propName]) { - schemaShape[propName] = - (requiredProps && requiredProps.includes(propName)) || target.required - ? schemaShape[propName].required(`${getPropertyLabel(propName)} is required`) - : target?.type === 'object' - ? schemaShape[propName].notRequired().default(undefined) - : schemaShape[propName].nullable(true) - if (!SKIP_TRANSFORM_FORMATS.includes(target?.format)) { - schemaShape[propName] = schemaShape[propName].transform( - typeSpecificTransform(target?.type, propName) - ) - } + const shape: Record = {} + Object.keys(schemaProps ?? {}).forEach((propName) => { + const field = buildFieldSchema(propName, schemaProps[propName], requiredProps) + if (field) { + shape[propName] = field } }) - - return schemaShape + return z.object(shape).transform((value) => + Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) + ) } -function hasValueOfType(type: string, val: any, propName: string) { +function hasValueOfType(type: string | undefined, val: any, propName: string) { switch (type) { case 'string': - return Boolean(val.length || val === '') + return typeof val === 'string' case 'number': case 'integer': - return Number(val) === val + return typeof val === 'number' && !Number.isNaN(val) case 'boolean': return typeof val === 'boolean' case 'object': + if (val == null || typeof val !== 'object' || Array.isArray(val)) return false return propName === 'xp' - ? typeof val === 'object' - : Boolean(Object.values(val).filter((v) => !isNil(v)).length) + ? true + : Object.values(val).some((entry) => !isNil(entry)) case 'array': return Array.isArray(val) default: - return false + return typeof val === 'string' } } -function typeSpecificTransform(type: string, propName: string) { - return (_: any, val: any) => (val === null || hasValueOfType(type, val, propName) ? val : undefined) -} - -function ShapeStringProp(propName: string, target: any) { - let propShape - propShape = yup.string() - if (propName?.toLocaleLowerCase() === 'email') { - propShape = propShape.email('Email is invalid') +function coerceValue(type: string | undefined, propName: string, value: unknown) { + // OrderCloud rejects null for numbers and booleans, and rejects 0 when minimum is 1. + // Empty inputs are omitted so a field the merchant never touched is not submitted. + if (type === 'number' || type === 'integer') { + if (typeof value === 'number' && !Number.isNaN(value)) return value + return undefined } - - if (target.maxLength) { - propShape = propShape.max( - target.maxLength, - `${propName} must not exceed ${target.maxLength} characters` - ) + if (type === 'boolean') { + return typeof value === 'boolean' ? value : undefined } + if (value === null || hasValueOfType(type, value, propName)) return value + return undefined +} - return propShape +function castDateTime(value: unknown) { + if (value == null || typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return value } -function ShapeNumberProp(target: any) { - const propShape = yup - .number() - .transform((value) => (isNaN(value) || value === null || value === undefined ? 0 : value)) +function isMissingRequired(type: string | undefined, value: unknown) { + if (type === 'number' || type === 'integer') return value == null + if (value == null) return true + if ((type == null || type === 'string') && value === '') return true + return false +} - if (target.minimum) propShape.min(target.minimum).default(target.minimum) - return propShape +function buildFieldSchema(propName: string, target: any, requiredProps?: string[]) { + if (target?.readOnly) return + const isRequired = Boolean( + (requiredProps && requiredProps.includes(propName)) || target?.required + ) + const label = getPropertyLabel(propName) ?? propName + const base = baseSchema(propName, target, label) + if (!base) return + return applyPresence(base, { + type: target?.type, + propName, + format: target?.format, + isRequired, + label, + }) } -function ShapeArrayProp(propName: string, target: any) { - let itemShape: any - switch (target.items.type) { +function baseSchema(propName: string, target: any, label: string): ZodTypeAny | undefined { + switch (target?.type) { case 'string': - itemShape = ShapeStringProp(propName, target.items) - break + return shapeString(propName, target, label) case 'number': - itemShape = ShapeNumberProp(target.items) - break case 'integer': - itemShape = ShapeNumberProp(target.items) - break + return shapeNumber(target, label) case 'boolean': - itemShape = yup.boolean() - break + return z.boolean({ + required_error: `${label} is required`, + invalid_type_error: `${label} is required`, + }) case 'object': - itemShape = ShapeObjectProp(target.items) - break + return shapeObject(target) case 'array': - itemShape = ShapeArrayProp(propName, target.items) - break + return z.array( + buildFieldSchema(propName, target.items ?? { type: 'string' }) ?? z.string() + ) default: - itemShape = yup.string() - break + return shapeString(propName, target ?? {}, label) } - return yup.array(itemShape) } -function ShapeObjectProp(target: any) { - let propShape - if (target.allOf?.length) { - let childSchemaShape = {} - target.allOf.forEach((p: any) => { - const childPropertyInfo = p.properties - childSchemaShape = generateFormSchema(childPropertyInfo) +function applyPresence( + base: ZodTypeAny, + opts: { + type?: string + propName: string + format?: string + isRequired: boolean + label: string + } +) { + const skipTransform = SKIP_TRANSFORM_FORMATS.includes(opts.format ?? '') + const coerce = (value: unknown) => + skipTransform ? castDateTime(value) : coerceValue(opts.type, opts.propName, value) + + return z + .any() + .superRefine((value, ctx) => { + const coerced = coerce(value) + if (opts.isRequired && isMissingRequired(opts.type, coerced)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${opts.label} is required`, + }) + return + } + if (coerced == null) return + const parsed = base.safeParse(coerced) + if (!parsed.success) { + parsed.error.issues.forEach((issue) => ctx.addIssue(issue)) + } + }) + .transform((value) => { + const coerced = coerce(value) + if (coerced == null) return coerced + const parsed = base.safeParse(coerced) + return parsed.success ? parsed.data : coerced }) - propShape = yup.object(childSchemaShape) - } else if (target.properties) { - let childSchemaShape = {} - childSchemaShape = generateFormSchema(target.properties, target?.required) - propShape = yup.object(childSchemaShape) +} + +function shapeString(propName: string, target: any, label: string) { + let propShape = z.string({ + required_error: `${label} is required`, + invalid_type_error: `${label} is required`, + }) + if (target?.maxLength != null) { + propShape = propShape.max( + target.maxLength, + `${propName} must not exceed ${target.maxLength} characters` + ) + } + if (propName?.toLocaleLowerCase() === 'email') { + return propShape.refine( + (value) => value === '' || z.string().email().safeParse(value).success, + 'Email is invalid' + ) } return propShape } +function shapeNumber(target: any, label: string) { + let propShape = z.number({ + required_error: `${label} is required`, + invalid_type_error: `${label} is required`, + }) + if (target?.type === 'integer') { + propShape = propShape.int(`${label} must be a whole number`) + } + if (target?.minimum != null) { + propShape = propShape.min(target.minimum, `${label} must be at least ${target.minimum}`) + } + return propShape +} + +function shapeObject(target: any) { + if (target.allOf?.length) { + return generateFormSchema(target.allOf[target.allOf.length - 1].properties) + } + if (target.properties) { + return generateFormSchema(target.properties, target?.required) + } +} + export function shallowNestedProperties(obj: any) { const shallowObj = {} as any for (let key in obj) { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 6fc9d66..65939d5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -13,6 +13,7 @@ import { useDeleteAssignment, } from "./useOcResource"; import { useOcForm } from "./useOcForm"; +export type { OcFormValues, OcFormValidationTrigger, UseOcFormOptions } from "./useOcForm"; import useHasAccess from "./useHasAccess"; import { usePromoExpressions } from "./usePromoExpressions"; import useShopper from "./useShopper"; diff --git a/src/hooks/useOcForm.ts b/src/hooks/useOcForm.ts index d1d6ab3..9f05f0a 100644 --- a/src/hooks/useOcForm.ts +++ b/src/hooks/useOcForm.ts @@ -1,35 +1,71 @@ import useOperations from './useOperations' -import { useCallback, useMemo } from 'react' -import * as yup from 'yup' -import { yupResolver } from '@hookform/resolvers/yup' -import { UseFormProps, useForm } from 'react-hook-form' -import _, { cloneDeep } from 'lodash' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { isEqual } from 'lodash' +import { cloneDeep } from 'lodash' +import { z } from 'zod' +import { useForm, FormOptions } from '@tanstack/react-form' import { generateFormSchema, generateParameterSchema, shallowNestedProperties, shapeXpSchema } from '../formSchemaGenerator.utils' import { useOrderCloudContext } from '.' import { OpenAPIV3 } from 'openapi-types' -// For specific fields/values we do not want to take the example from Open API spec -const normalizeExampleValue = (val: any) => { - switch (typeof val) { - case 'string': - if (val === '2018-01-01T00:00:00-06:00') { - return null - } - break - case 'object': - if (Array.isArray(val)) { - return val.join('').length ? val : [] - } - break - default: - return val - } - return val +export type OcFormValues = { + parameters: Record + body: Record +} + +export type OcFormValidationTrigger = 'onBlur' | 'onChange' | 'onSubmit' + +export type UseOcFormOptions = { + /** When generated validation runs. Defaults to onBlur. Submit always validates. */ + validationTrigger?: OcFormValidationTrigger + /** Called by form.handleSubmit after validation. Save parseValues(), not the raw form state. */ + onSubmit?: (values: OcFormValues) => void | Promise + /** Extra TanStack Form options. defaultValues and validators stay owned by this hook. */ + formOptions?: Partial< + Omit< + FormOptions< + OcFormValues, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + >, + 'defaultValues' | 'validators' + > + > } -const getExampleValue = (row: any, key: string) => { - key.split('.').forEach((k) => (row ? (row = row[k]) : undefined)) - return row +const fallbackFormSchema = z.object({ + parameters: z.record(z.any()), + body: z.record(z.any()), +}) + +function validatorsFor(schema: z.ZodTypeAny, trigger: OcFormValidationTrigger) { + const validators: { + onSubmit: z.ZodTypeAny + onBlur?: z.ZodTypeAny + onChange?: z.ZodTypeAny + } = { onSubmit: schema } + if (trigger === 'onBlur') validators.onBlur = schema + if (trigger === 'onChange') validators.onChange = schema + return validators +} + +function toFormValues(initialValues?: { + parameters?: Record + body?: Record +}): OcFormValues { + return { + parameters: { ...(initialValues?.parameters ?? {}) }, + body: { ...(initialValues?.body ?? {}) }, + } } export const useOcForm = ( @@ -37,7 +73,7 @@ export const useOcForm = ( initialValues?: { parameters?: {[key: string]: unknown}, body?: {[key: string]: unknown} }, isAssignment?: boolean, operationInclusion?: string, - props?: UseFormProps, + options?: UseOcFormOptions, ) => { const { saveOperation, createOperation, assignmentSaveOperation } = useOperations(resource, operationInclusion) as any const { xpSchemas } = useOrderCloudContext() @@ -92,58 +128,46 @@ export const useOcForm = ( return shallowNestedProperties(nativeProperties) }, [requestSchema?.required, requestSchema?.allOf, xpSchema, xpSchemas?.properties, resource]) - const getDefaultSchemaValues = useCallback( - (schema: OpenAPIV3.SchemaObject, parentPath?: string) => { - const obj = {} as {[key: string]: unknown} - Object.entries(schema).forEach(([key, value]: [string, OpenAPIV3.SchemaObject]) => { - if (value.readOnly) return - if (value?.properties) { - obj[key] = getDefaultSchemaValues(value.properties, key) - return - } - const path = parentPath ? `${parentPath}.${key}` : key - obj[key] = normalizeExampleValue(getExampleValue(requestSchema?.allOf[0].example, path)) - }) - return obj - }, - [requestSchema?.allOf] - ) - - // Generate the form schema using Yup const formSchema = useMemo(() => { - if (!resourceSchema) return - const generatedSchema = generateFormSchema(resourceSchema, requestSchema?.required) - - let generatedParamSchema = {} - - if (operationParameters) { - generatedParamSchema = generateFormSchema(operationParameters?.schema, operationParameters?.required) - } - - return yup.object().shape({ - parameters: yup.object(generatedParamSchema), - body: yup.object(generatedSchema), + if (!resourceSchema) return fallbackFormSchema + const bodySchema = generateFormSchema(resourceSchema, requestSchema?.required) + const parameterSchema = operationParameters + ? generateFormSchema(operationParameters.schema, operationParameters.required) + : z.object({}) + return z.object({ + parameters: parameterSchema, + body: bodySchema, }) }, [resourceSchema, requestSchema?.required, operationParameters]) - // Initiate the react-hook-form - const methods = useForm({ - defaultValues: props?.defaultValues || {} as any, - values: { - parameters: { - ...initialValues?.parameters, - }, - body: { - ...initialValues?.body || {}, - }, + const validationTrigger = options?.validationTrigger ?? 'onBlur' + const nextValues = useMemo(() => toFormValues(initialValues), [initialValues]) + const seenValues = useRef(nextValues) + + const form = useForm({ + ...options?.formOptions, + defaultValues: nextValues, + validators: validatorsFor(formSchema, validationTrigger), + onSubmit: async ({ value }) => { + await options?.onSubmit?.(value) }, - resolver: yupResolver(formSchema!), - mode: props?.mode || 'onBlur', - ...props }) + // useForm syncs validators through form.update on each render. Reset is separate: + // that update leaves a touched form in place, and this hook keeps values controlled. + useEffect(() => { + if (isEqual(seenValues.current, nextValues)) return + seenValues.current = nextValues + form.reset(nextValues) + }, [form, nextValues]) + + const parseValues = useCallback((values?: OcFormValues) => { + return formSchema.parse(values ?? form.state.values) as OcFormValues + }, [form, formSchema]) + return { - methods, - resourceSchema + form, + resourceSchema, + parseValues, } - }; +} diff --git a/vite.config.ts b/vite.config.ts index 2e2e68a..f704a66 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ "react", "react-dom", "ordercloud-javascript-sdk", + "@tanstack/react-form", "@tanstack/react-query", "@tanstack/react-table", ], @@ -28,6 +29,7 @@ export default defineConfig({ react: "React", "react-dom": "ReactDOM", "ordercloud-javascript-sdk": "ordercloud", + "@tanstack/react-form": "TanStackReactForm", "@tanstack/react-query": "ReactQuery", "@tanstack/react-table": "ReactTable", }, From 3d378b94f780f9124d4305c66a1e4c6266fbf999 Mon Sep 17 00:00:00 2001 From: Eric Sitarz Date: Mon, 28 Sep 2026 16:27:00 -0500 Subject: [PATCH 2/5] test(form): cover Zod schema rules --- package-lock.json | 460 +++++++++++++++++++++- package.json | 6 +- src/__tests__/formSchemaGenerator.test.ts | 243 ++++++++++++ vitest.config.ts | 8 + 4 files changed, 708 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/formSchemaGenerator.test.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 3e8e0db..576e2d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "eslint-plugin-react-refresh": "^0.4.16", "typescript": "^5.2.2", "vite": "^6.3.5", - "vite-plugin-dts": "^4.5.4" + "vite-plugin-dts": "^4.5.4", + "vitest": "^3.2.7" }, "peerDependencies": { "@tanstack/react-form": "^1.33.5", @@ -1050,7 +1051,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "devOptional": true, + "dev": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -1064,7 +1065,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "devOptional": true, + "dev": true, "engines": { "node": ">=6.0.0" } @@ -1073,7 +1074,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "devOptional": true, + "dev": true, "engines": { "node": ">=6.0.0" } @@ -1087,7 +1088,7 @@ "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "devOptional": true, + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -1971,6 +1972,24 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -2236,6 +2255,131 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.14", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.14.tgz", @@ -2337,7 +2481,7 @@ "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "devOptional": true, + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -2457,6 +2601,16 @@ "util": "^0.12.5" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2732,6 +2886,16 @@ "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -2793,6 +2957,23 @@ "node": ">= 0.8.0" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2809,6 +2990,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cipher-base": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz", @@ -3024,6 +3215,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3164,6 +3365,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", @@ -3495,6 +3703,16 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz", @@ -4299,6 +4517,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4755,6 +4980,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pbkdf2": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", @@ -5223,11 +5458,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "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==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -5247,6 +5489,20 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -5323,6 +5579,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5357,6 +5633,20 @@ "node": ">=0.6.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -5399,6 +5689,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5633,6 +5953,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-dts": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz", @@ -5702,6 +6045,92 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -5747,6 +6176,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index ff9909d..7890570 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "scripts": { "dev": "vite build -- --watch", "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "test": "vitest run" }, "dependencies": { "@apidevtools/swagger-parser": "^10.1.0", @@ -79,6 +80,7 @@ "eslint-plugin-react-refresh": "^0.4.16", "typescript": "^5.2.2", "vite": "^6.3.5", - "vite-plugin-dts": "^4.5.4" + "vite-plugin-dts": "^4.5.4", + "vitest": "^3.2.7" } } diff --git a/src/__tests__/formSchemaGenerator.test.ts b/src/__tests__/formSchemaGenerator.test.ts new file mode 100644 index 0000000..cf45b25 --- /dev/null +++ b/src/__tests__/formSchemaGenerator.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + generateFormSchema, + generateParameterSchema, + shapeXpSchema, +} from "../formSchemaGenerator.utils"; + +function parseField(schema: ReturnType, value: unknown) { + return schema.parse(value); +} + +describe("generateFormSchema", () => { + it("requires strings and uses the existing field labels", () => { + const schema = generateFormSchema( + { + Name: { type: "string" }, + productID: { type: "string" }, + CatalogIDs: { type: "string" }, + xp: { type: "object", properties: {} }, + }, + ["Name", "productID", "CatalogIDs", "xp"] + ); + + expect(() => parseField(schema, { Name: "" })).toThrow(/Name is required/); + expect(() => parseField(schema, {})).toThrow(/Product ID is required/); + expect(() => + parseField(schema, { Name: "Hat", productID: "p1", CatalogIDs: "" }) + ).toThrow(/Catalog IDs is required/); + expect(() => + parseField(schema, { Name: "Hat", productID: "p1", CatalogIDs: "c1" }) + ).toThrow(/Extended Properties is required/); + expect( + parseField(schema, { + Name: "Hat", + productID: "p1", + CatalogIDs: "c1", + xp: {}, + }) + ).toMatchObject({ Name: "Hat", xp: {} }); + }); + + it("validates email and maxLength", () => { + const schema = generateFormSchema({ + email: { type: "string" }, + Title: { type: "string", maxLength: 3 }, + }); + + expect(() => parseField(schema, { email: "nope" })).toThrow(/Email is invalid/); + expect(parseField(schema, { email: "a@b.co" })).toMatchObject({ email: "a@b.co" }); + expect(parseField(schema, { email: "" })).toMatchObject({ email: "" }); + expect(parseField(schema, { email: null })).toMatchObject({ email: null }); + expect(parseField(schema, { Title: "ab" })).toMatchObject({ Title: "ab" }); + expect(() => parseField(schema, { Title: "abcd" })).toThrow( + /Title must not exceed 3 characters/ + ); + }); + + it("omits empty numbers and honors minimum only when a number is present", () => { + const schema = generateFormSchema({ + Quantity: { type: "number" }, + Count: { type: "integer", minimum: 1 }, + Floor: { type: "integer", minimum: 0 }, + }); + + const blank = parseField(schema, { Quantity: "", Count: "", Floor: "" }); + expect(blank.Quantity).toBeUndefined(); + expect(blank.Count).toBeUndefined(); + expect(blank.Floor).toBeUndefined(); + expect(JSON.stringify(blank)).toBe("{}"); + + expect(parseField(schema, { Quantity: null, Count: 2, Floor: 0 }).Quantity).toBeUndefined(); + expect(parseField(schema, { Quantity: undefined, Count: 2, Floor: 0 }).Quantity).toBeUndefined(); + expect(parseField(schema, { Quantity: Number.NaN, Count: 2, Floor: 0 }).Quantity).toBeUndefined(); + expect(parseField(schema, { Quantity: 2, Count: 2, Floor: 0 })).toMatchObject({ + Quantity: 2, + Count: 2, + Floor: 0, + }); + expect(() => parseField(schema, { Count: 0, Quantity: 0, Floor: 0 })).toThrow( + /Count must be at least 1/ + ); + expect(() => parseField(schema, { Floor: -1, Quantity: 0, Count: 1 })).toThrow( + /Floor must be at least 0/ + ); + expect(parseField(schema, { Quantity: "2", Count: 1, Floor: 0 }).Quantity).toBeUndefined(); + }); + + it("submits a product name when quantity multiplier and active are blank", () => { + const schema = generateFormSchema( + { + Name: { type: "string" }, + QuantityMultiplier: { type: "integer", minimum: 1 }, + Active: { type: "boolean" }, + }, + ["Name"] + ); + + const parsed = schema.parse({ Name: "Hat", QuantityMultiplier: "", Active: null }); + expect(parsed).toEqual({ Name: "Hat" }); + expect("QuantityMultiplier" in parsed).toBe(false); + expect("Active" in parsed).toBe(false); + expect(() => schema.parse({ Name: "Hat", QuantityMultiplier: 0 })).toThrow( + /Quantity Multiplier must be at least 1/ + ); + expect(() => schema.parse({ Name: "Hat", QuantityMultiplier: 1.5 })).toThrow( + /Quantity Multiplier must be a whole number/ + ); + }); + + it("keeps null on optional non-objects and drops mismatched values", () => { + const schema = generateFormSchema({ + Description: { type: "string" }, + Active: { type: "boolean" }, + }); + + expect(parseField(schema, { Description: null })).toMatchObject({ Description: null }); + expect(parseField(schema, { Description: 5 }).Description).toBeUndefined(); + expect(parseField(schema, { Active: false })).toMatchObject({ Active: false }); + expect(parseField(schema, { Active: null }).Active).toBeUndefined(); + expect(JSON.stringify(parseField(schema, { Active: null }))).not.toContain("Active"); + expect(parseField(schema, { Active: "true" }).Active).toBeUndefined(); + }); + + it("drops empty non-xp objects and keeps empty xp objects", () => { + const schema = generateFormSchema({ + Address: { + type: "object", + properties: { City: { type: "string" } }, + }, + xp: { + type: "object", + properties: { Color: { type: "string" } }, + }, + }); + + expect(parseField(schema, { Address: {} }).Address).toBeUndefined(); + expect(parseField(schema, { Address: { City: null } }).Address).toBeUndefined(); + expect(parseField(schema, { Address: { City: "Austin" } })).toMatchObject({ + Address: { City: "Austin" }, + }); + expect(parseField(schema, { xp: {} })).toMatchObject({ xp: {} }); + expect(parseField(schema, { xp: { Color: "red" } })).toMatchObject({ + xp: { Color: "red" }, + }); + }); + + it("validates nested objects and arrays", () => { + const schema = generateFormSchema({ + Specs: { + type: "array", + items: { + type: "object", + properties: { + Name: { type: "string", required: true }, + }, + }, + }, + Tags: { type: "array", items: { type: "string" } }, + }); + + expect(parseField(schema, { Specs: [{ Name: "Size" }], Tags: ["a"] })).toMatchObject({ + Specs: [{ Name: "Size" }], + Tags: ["a"], + }); + expect(() => parseField(schema, { Specs: [{ Name: "" }] })).toThrow(/Name is required/); + expect(parseField(schema, { Tags: "a" }).Tags).toBeUndefined(); + expect(parseField(schema, { Tags: null })).toMatchObject({ Tags: null }); + }); + + it("omits read-only fields and does not coerce date-time values to undefined", () => { + const schema = generateFormSchema({ + Name: { type: "string", required: true }, + ID: { type: "string", readOnly: true }, + DateCreated: { type: "string", format: "date-time" }, + }); + + expect( + parseField(schema, { + Name: "Hat", + ID: "1", + DateCreated: "2018-01-01T00:00:00-06:00", + }) + ).toEqual({ + Name: "Hat", + DateCreated: "2018-01-01T00:00:00-06:00", + }); + expect(parseField(schema, { Name: "Hat", DateCreated: 5 })).toEqual({ + Name: "Hat", + DateCreated: "5", + }); + }); +}); + +describe("generateParameterSchema", () => { + it("adds a filters object when search is present and tracks required parameters", () => { + const parameters = generateParameterSchema({ + parameters: [ + { + name: "productID", + schema: { type: "string" }, + required: true, + description: "Product", + }, + { name: "search", schema: { type: "string" }, required: false }, + ], + }); + + expect(parameters?.required).toEqual(["productID"]); + expect(parameters?.schema.filters).toEqual({ type: "object" }); + expect(parameters?.schema.productID.required).toBe(true); + + const schema = z.object({ + parameters: generateFormSchema(parameters?.schema, parameters?.required), + body: generateFormSchema({ Name: { type: "string", required: true } }), + }); + expect(() => schema.parse({ parameters: {}, body: { Name: "Hat" } })).toThrow( + /Product ID is required/ + ); + expect( + schema.parse({ parameters: { productID: "p1" }, body: { Name: "Hat" } }) + ).toMatchObject({ + parameters: { productID: "p1" }, + body: { Name: "Hat" }, + }); + }); +}); + +describe("shapeXpSchema", () => { + it("infers xp property types and skips empty values", () => { + expect( + shapeXpSchema([ + { xp: { Color: "red", Empty: [] } }, + { xp: { Color: "blue" } }, + ]) + ).toEqual({ + type: "object", + properties: { + Color: { type: "string" }, + }, + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..eea5e07 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/__tests__/**/*.test.ts"], + }, +}); From 71bbc680ac306443dca62d8aee7b7efe8318f9bc Mon Sep 17 00:00:00 2001 From: Eric Sitarz Date: Mon, 28 Sep 2026 16:27:21 -0500 Subject: [PATCH 3/5] fix(api-spec): load the OpenAPI document once --- src/hooks/useApiSpec.ts | 94 +++++++++++++++++++++++++++++------------ 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/src/hooks/useApiSpec.ts b/src/hooks/useApiSpec.ts index c6f5ff6..a0159df 100644 --- a/src/hooks/useApiSpec.ts +++ b/src/hooks/useApiSpec.ts @@ -122,33 +122,69 @@ const buildOperations = (spec?: OpenAPIV3.Document) => { return [...defaultOperations, ...additionalOperations] as IOrderCloudOperationObject[] } -const useApiSpec = () => { - const { baseApiUrl } = useOrderCloudContext(); - const [spec, setSpec] = useState(() => { - try { - // Get from local storage by key - const item = window.localStorage.getItem(`${localStoragePrefix}${baseApiUrl}`) - // Parse stored json or if none return initialValue - return item ? JSON.parse(item) : undefined - } catch (error) { - // If error also return initialValue - console.log(error) - return undefined - } - }) +const specCache = new Map() +const specListeners = new Map void>>() +const specRequests = new Map>() + +function readStoredSpec(url: string) { + const cached = specCache.get(url) + if (cached) return cached + try { + const item = window.localStorage?.getItem(`${localStoragePrefix}${url}`) + if (!item) return undefined + const parsed = JSON.parse(item) as OpenAPIV3.Document + specCache.set(url, parsed) + return parsed + } catch (error) { + console.log(error) + return undefined + } +} - const retrieveSpec = useCallback(async (url: string) => { +function publishSpec(url: string, spec: OpenAPIV3.Document) { + specCache.set(url, spec) + specListeners.get(url)?.forEach((listener) => listener(spec)) +} + +function loadSpec(url: string, force = false) { + if (!force) { + const cached = specCache.get(url) + if (cached) return Promise.resolve(cached) + const inFlight = specRequests.get(url) + if (inFlight) return inFlight + } + + const request = (async () => { const result = await SwaggerParser.dereference(`${url}/v1/openapi/v3`) const v3doc = result as OpenAPIV3.Document if (v3doc.servers) { v3doc.servers[0].url = `${url}/v1` } - // Clear out swagger specs in localStorage to prevent capacity errors + try { + if (window.localStorage) { const keys = Object.keys(window.localStorage) - keys.filter((k) => k.includes('OcOpenApi')).map((i) => window.localStorage.removeItem(i)) + keys.filter((key) => key.includes('OcOpenApi')).forEach((key) => window.localStorage.removeItem(key)) window.localStorage.setItem(`${localStoragePrefix}${url}`, JSON.stringify(v3doc)) - setSpec(v3doc) - }, []) + } + } catch (error) { + // A dereferenced spec can be too large or cyclic for localStorage. Callers still share the in-memory copy. + console.log(error) + } + publishSpec(url, v3doc) + return v3doc + })() + + specRequests.set(url, request) + return request +} + +const useApiSpec = () => { + const { baseApiUrl } = useOrderCloudContext(); + const [spec, setSpec] = useState(() => + baseApiUrl ? readStoredSpec(baseApiUrl) : undefined + ) + + const retrieveSpec = useCallback(async (url: string) => loadSpec(url, true), []) const specEnv = useQuery({ queryKey: ['env'], @@ -185,16 +221,18 @@ const useApiSpec = () => { useEffect(() => { if (!baseApiUrl) return - try { - // Get from local storage by key - const item = window.localStorage.getItem(`${localStoragePrefix}${baseApiUrl}`) - // Parse stored json or if none return initialValue - item ? setSpec(JSON.parse(item)) : retrieveSpec(baseApiUrl) - } catch (error) { - // If error also return initialValue - retrieveSpec(baseApiUrl) + const stored = readStoredSpec(baseApiUrl) + if (stored) setSpec(stored) + + const listeners = specListeners.get(baseApiUrl) ?? new Set<(spec: OpenAPIV3.Document) => void>() + listeners.add(setSpec) + specListeners.set(baseApiUrl, listeners) + if (!stored) void loadSpec(baseApiUrl) + + return () => { + listeners.delete(setSpec) } - }, [baseApiUrl, retrieveSpec]) + }, [baseApiUrl]) const result = useMemo(() => { const operations = buildOperations(spec) From 8b229119591573f6e046163d1d3276bd9880c3d7 Mon Sep 17 00:00:00 2001 From: Eric Sitarz Date: Mon, 28 Sep 2026 16:27:29 -0500 Subject: [PATCH 4/5] fix(routing): skip missing route params --- src/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 1ac6137..5020a9f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -43,9 +43,8 @@ export const isRouteParam = (operation: any, paramName: string) => { export const getRoutingUrl = (operation: any, params: any)=> { let url = operation?.path if(!url) return "" - const paramsTest = params if (url.indexOf('{') > -1) { - Object.entries(paramsTest) + Object.entries(params ?? {}) .filter(([key]: [string, any]) => { return isRouteParam(operation, key) }) From 4f4878fa4c60b193487318ff61b19ad11fd6e240 Mon Sep 17 00:00:00 2001 From: Eric Sitarz Date: Mon, 28 Sep 2026 16:28:09 -0500 Subject: [PATCH 5/5] test: add Cloud Commerce field checks --- poc/validate-edge-cases.mjs | 361 ++++++++++++++++++++++++++++++++++ poc/validate-marketplace.mjs | 369 +++++++++++++++++++++++++++++++++++ 2 files changed, 730 insertions(+) create mode 100644 poc/validate-edge-cases.mjs create mode 100644 poc/validate-marketplace.mjs diff --git a/poc/validate-edge-cases.mjs b/poc/validate-edge-cases.mjs new file mode 100644 index 0000000..883ae8e --- /dev/null +++ b/poc/validate-edge-cases.mjs @@ -0,0 +1,361 @@ +import SwaggerParser from "@apidevtools/swagger-parser" +import { cloneDeep } from "lodash" +import { + generateFormSchema, + generateParameterSchema, + shallowNestedProperties, +} from "../src/formSchemaGenerator.utils.tsx" + +const baseApiUrl = "https://sandboxapi.ordercloud.io" +const token = process.env.OC_ACCESS_TOKEN ?? "" +const stamp = Date.now() +const productId = `sdk-edge-product-${stamp}` +const priceScheduleId = `sdk-edge-prices-${stamp}` +const catalogId = `sdk-edge-catalog-${stamp}` +const categoryId = `sdk-edge-category-${stamp}` +const specId = `sdk-edge-spec-${stamp}` +const promotionId = `sdk-edge-promo-${stamp}` + +function findOperation(spec, operationId) { + for (const item of Object.values(spec.paths)) { + for (const operation of Object.values(item)) { + if (operation && operation.operationId === operationId) return operation + } + } + return null +} + +function resourceSchemaFor(operation) { + const requestSchema = operation.requestBody.content["application/json"].schema + const nativeProperties = cloneDeep(requestSchema.allOf[0].properties) + requestSchema.required?.forEach((propKey) => { + nativeProperties[propKey].required = true + }) + return { + requestSchema, + resourceSchema: shallowNestedProperties(nativeProperties), + } +} + +function formSchemaFor(operation) { + const { requestSchema, resourceSchema } = resourceSchemaFor(operation) + return { + body: generateFormSchema(resourceSchema, requestSchema.required), + resourceSchema, + required: requestSchema.required ?? [], + } +} + +const results = [] +function record(name, pass, detail) { + results.push({ name, pass }) + console.log(`${pass ? "PASS" : "FAIL"} ${name}: ${detail}`) +} + +function errorDetail(body) { + const errors = body?.Errors + if (!errors) return JSON.stringify(body) + return errors.map((error) => error.Message || error.ErrorCode).join("; ") +} + +if (token.split(".").length !== 3) { + console.error("OC_ACCESS_TOKEN is not a complete JWT.") + process.exit(1) +} + +const spec = await SwaggerParser.dereference(`${baseApiUrl}/v1/openapi/v3`) +const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", +} + +let createdProduct = false +let createdPriceSchedule = false +let createdCatalog = false +let createdCategory = false +let createdSpec = false +let createdPromotion = false +let specAssigned = false + +async function call(method, path, body) { + const response = await fetch(`${baseApiUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await response.text() + let parsed = text + try { + parsed = text ? JSON.parse(text) : null + } catch { + parsed = text + } + return { status: response.status, ok: response.status < 400, body: parsed } +} + +try { + const productForm = formSchemaFor(findOperation(spec, "Products.Save")) + const created = await call("POST", "/v1/products", { ID: productId, Name: "SDK edge product" }) + createdProduct = created.ok + if (!created.ok) { + record("setup product", false, errorDetail(created.body)) + } else { + const loaded = await call("GET", `/v1/products/${productId}`) + const full = productForm.body.safeParse({ + ...loaded.body, + Description: "Keep me", + Inventory: { Enabled: true, QuantityAvailable: 4, NotificationPoint: "" }, + }) + const fullSaved = full.success ? await call("PUT", `/v1/products/${productId}`, full.data) : null + const fullLoaded = fullSaved?.ok ? await call("GET", `/v1/products/${productId}`) : null + const established = + full.success && + fullSaved?.ok && + fullLoaded?.body?.Description === "Keep me" && + fullLoaded.body.Inventory?.QuantityAvailable === 4 + record( + "establish description and inventory", + Boolean(established), + established ? "Description and QuantityAvailable=4" : full.success ? errorDetail(fullSaved?.body) : JSON.stringify(full.error.issues) + ) + + const sparse = productForm.body.safeParse({ ID: productId, Name: "SDK edge product" }) + const sparseSaved = sparse.success ? await call("PUT", `/v1/products/${productId}`, sparse.data) : null + const sparseLoaded = sparseSaved?.ok ? await call("GET", `/v1/products/${productId}`) : null + record( + "omitted description is cleared", + Boolean(sparseSaved?.ok && sparseLoaded?.body?.Description == null), + sparse.success ? `description=${sparseLoaded?.body?.Description}` : JSON.stringify(sparse.error.issues) + ) + record( + "omitted inventory object is kept", + Boolean(sparseSaved?.ok && sparseLoaded?.body?.Inventory?.QuantityAvailable === 4), + sparse.success ? `qty=${sparseLoaded?.body?.Inventory?.QuantityAvailable}` : JSON.stringify(sparse.error.issues) + ) + + const xpSchema = { + type: "object", + properties: { + Color: { type: "string" }, + Rank: { type: "integer" }, + Featured: { type: "boolean" }, + Tags: { type: "array", items: { type: "string" } }, + }, + } + const xpForm = (() => { + const built = resourceSchemaFor(findOperation(spec, "Products.Save")) + built.resourceSchema.xp = xpSchema + return generateFormSchema(built.resourceSchema, built.requestSchema.required) + })() + const xpParsed = xpForm.safeParse({ + ...sparseLoaded.body, + Name: "SDK edge product", + xp: { Color: "red", Rank: 3, Featured: false, Tags: ["summer", "sale"] }, + }) + const xpSaved = xpParsed.success ? await call("PUT", `/v1/products/${productId}`, xpParsed.data) : null + const xpLoaded = xpSaved?.ok ? await call("GET", `/v1/products/${productId}`) : null + const xp = xpLoaded?.body?.xp + record( + "xp string integer boolean and list", + Boolean(xpSaved?.ok && xp?.Color === "red" && xp?.Rank === 3 && xp?.Featured === false && xp?.Tags?.join() === "summer,sale"), + xpParsed.success ? (xpSaved?.ok ? JSON.stringify(xp) : errorDetail(xpSaved.body)) : JSON.stringify(xpParsed.error.issues) + ) + + const decimal = productForm.body.safeParse({ + ID: productId, + Name: "SDK edge product", + QuantityMultiplier: 1.5, + }) + let decimalDetail = decimal.success ? "schema accepted 1.5" : JSON.stringify(decimal.error.issues) + if (decimal.success) { + const decimalSaved = await call("PUT", `/v1/products/${productId}`, decimal.data) + decimalDetail = decimalSaved.ok + ? `api accepted QuantityMultiplier=${(await call("GET", `/v1/products/${productId}`)).body?.QuantityMultiplier}` + : `api rejected: ${errorDetail(decimalSaved.body)}` + } + record( + "integer rejects a decimal", + !decimal.success && /whole number/i.test(decimalDetail), + decimalDetail + ) + } + + const priceForm = formSchemaFor(findOperation(spec, "PriceSchedules.Create")) + const priceSave = formSchemaFor(findOperation(spec, "PriceSchedules.Save")) + const twoBreaks = priceForm.body.safeParse({ + ID: priceScheduleId, + Name: "SDK edge prices", + PriceBreaks: [ + { Quantity: 1, Price: 1.25 }, + { Quantity: 5, Price: 1 }, + ], + }) + const priceCreated = twoBreaks.success ? await call("POST", "/v1/priceschedules", twoBreaks.data) : null + createdPriceSchedule = Boolean(priceCreated?.ok) + const twoLoaded = priceCreated?.ok ? await call("GET", `/v1/priceschedules/${priceScheduleId}`) : null + record( + "two price breaks", + Boolean(twoLoaded?.body?.PriceBreaks?.length === 2 && twoLoaded.body.PriceBreaks[1].Price === 1), + twoBreaks.success ? (priceCreated?.ok ? JSON.stringify(twoLoaded.body.PriceBreaks) : errorDetail(priceCreated.body)) : JSON.stringify(twoBreaks.error.issues) + ) + const oneBreak = priceSave.body.safeParse({ + ID: priceScheduleId, + Name: "SDK edge prices", + PriceBreaks: [{ Quantity: 1, Price: 1.25 }], + }) + const oneSaved = oneBreak.success && createdPriceSchedule ? await call("PUT", `/v1/priceschedules/${priceScheduleId}`, oneBreak.data) : null + const oneLoaded = oneSaved?.ok ? await call("GET", `/v1/priceschedules/${priceScheduleId}`) : null + record( + "remove a price break", + Boolean(oneLoaded?.body?.PriceBreaks?.length === 1), + oneBreak.success ? (oneSaved?.ok ? JSON.stringify(oneLoaded.body.PriceBreaks) : errorDetail(oneSaved?.body)) : JSON.stringify(oneBreak.error.issues) + ) + const emptyBreaks = priceSave.body.safeParse({ + ID: priceScheduleId, + Name: "SDK edge prices", + PriceBreaks: [], + }) + const emptySaved = emptyBreaks.success && createdPriceSchedule ? await call("PUT", `/v1/priceschedules/${priceScheduleId}`, emptyBreaks.data) : null + const emptyLoaded = emptySaved?.ok ? await call("GET", `/v1/priceschedules/${priceScheduleId}`) : null + record( + "empty price breaks", + Boolean(emptySaved?.ok && Array.isArray(emptyLoaded?.body?.PriceBreaks) && emptyLoaded.body.PriceBreaks.length === 0), + emptyBreaks.success + ? emptySaved?.ok + ? JSON.stringify(emptyLoaded.body.PriceBreaks) + : errorDetail(emptySaved.body) + : JSON.stringify(emptyBreaks.error.issues) + ) + + const categoryOperation = findOperation(spec, "Categories.Create") + const categoryForm = formSchemaFor(categoryOperation) + const catalog = await call("POST", "/v1/catalogs", { ID: catalogId, Name: "SDK edge catalog", Active: true }) + createdCatalog = catalog.ok + const categoryParsed = categoryForm.body.safeParse({ ID: categoryId, Name: "SDK edge category", Active: true }) + const category = categoryParsed.success && createdCatalog + ? await call("POST", `/v1/catalogs/${catalogId}/categories`, categoryParsed.data) + : null + createdCategory = Boolean(category?.ok) + const categoryLoaded = category?.ok ? await call("GET", `/v1/catalogs/${catalogId}/categories/${categoryId}`) : null + record( + "category create", + Boolean(category?.ok && categoryLoaded?.body?.Name === "SDK edge category"), + categoryParsed.success + ? category?.ok + ? categoryLoaded.body.ID + : `${catalog.ok ? "" : errorDetail(catalog.body) + " "}${errorDetail(category?.body)}` + : JSON.stringify(categoryParsed.error.issues) + ) + + const specForm = formSchemaFor(findOperation(spec, "Specs.Create")) + const optionForm = formSchemaFor(findOperation(spec, "Specs.CreateOption")) + const specParsed = specForm.body.safeParse({ + ID: specId, + Name: "SDK edge spec", + Required: true, + DefinesVariant: true, + Options: [ + { ID: "small", Value: "Small" }, + { ID: "large", Value: "Large" }, + ], + }) + record( + "spec form omits read-only options", + Boolean(specParsed.success && !Object.prototype.hasOwnProperty.call(specParsed.data, "Options")), + specParsed.success ? JSON.stringify(specParsed.data) : JSON.stringify(specParsed.error.issues) + ) + const specCreated = specParsed.success ? await call("POST", "/v1/specs", specParsed.data) : null + createdSpec = Boolean(specCreated?.ok) + const small = optionForm.body.safeParse({ ID: "small", Value: "Small" }) + const large = optionForm.body.safeParse({ ID: "large", Value: "Large" }) + const smallCreated = small.success && createdSpec ? await call("POST", `/v1/specs/${specId}/options`, small.data) : null + const largeCreated = large.success && createdSpec ? await call("POST", `/v1/specs/${specId}/options`, large.data) : null + const specLoaded = smallCreated?.ok && largeCreated?.ok ? await call("GET", `/v1/specs/${specId}`) : null + record( + "spec with two options", + Boolean(specLoaded?.body?.Options?.length === 2), + specCreated?.ok + ? smallCreated?.ok && largeCreated?.ok + ? JSON.stringify(specLoaded.body.Options) + : `${errorDetail(smallCreated?.body)} ${errorDetail(largeCreated?.body)}` + : errorDetail(specCreated?.body) + ) + const removed = createdSpec ? await call("DELETE", `/v1/specs/${specId}/options/large`) : null + const optionLoaded = removed?.ok ? await call("GET", `/v1/specs/${specId}`) : null + record( + "remove a spec option", + Boolean(optionLoaded?.body?.Options?.length === 1 && optionLoaded.body.Options[0].ID === "small"), + removed?.ok ? JSON.stringify(optionLoaded.body.Options) : errorDetail(removed?.body) + ) + + if (createdProduct && createdSpec) { + const assignmentOperation = findOperation(spec, "Specs.SaveProductAssignment") + const assignmentForm = assignmentOperation ? formSchemaFor(assignmentOperation) : null + const assignmentParsed = assignmentForm?.body.safeParse({ SpecID: specId, ProductID: productId }) + const assigned = assignmentParsed?.success + ? await call("POST", "/v1/specs/productassignments", assignmentParsed.data) + : null + specAssigned = Boolean(assigned?.ok) + record( + "spec product assignment", + Boolean(assigned?.ok), + assignmentParsed?.success ? (assigned?.ok ? `${specId} -> ${productId}` : errorDetail(assigned.body)) : "schema missing" + ) + const variantOperation = findOperation(spec, "Variants.Generate") ?? findOperation(spec, "Products.GenerateVariants") + const generated = specAssigned + ? await call("POST", `/v1/products/${productId}/variants/generate`, { Active: true }) + : null + const variants = generated?.ok ? await call("GET", `/v1/products/${productId}/variants`) : null + record( + "generated variant", + Boolean(generated?.ok && (variants?.body?.Items?.length ?? variants?.body?.length ?? 0) >= 1), + generated?.ok + ? JSON.stringify(variants?.body?.Items ?? variants?.body) + : `${variantOperation?.operationId ?? "POST /products/{productID}/variants/generate"} ${errorDetail(generated?.body)}` + ) + } + + const promotionOperation = findOperation(spec, "Promotions.Create") + const dateField = Object.entries(resourceSchemaFor(promotionOperation).resourceSchema).find( + ([, property]) => property.format === "date-time" && !property.readOnly + ) + const promotionForm = formSchemaFor(promotionOperation) + const start = new Date(Date.now() + 60_000).toISOString() + const promotionInput = { + ID: promotionId, + Name: "SDK edge promo", + Code: `EDGE${stamp}`.slice(0, 50), + Description: "edge", + FinePrint: "edge", + EligibleExpression: "true", + ValueExpression: "5", + } + if (dateField) promotionInput[dateField[0]] = start + const promotionParsed = promotionForm.body.safeParse(promotionInput) + const numberDate = dateField + ? promotionForm.body.safeParse({ ...promotionInput, [dateField[0]]: 5 }) + : null + const promotion = promotionParsed.success ? await call("POST", "/v1/promotions", promotionParsed.data) : null + createdPromotion = Boolean(promotion?.ok) + const promotionLoaded = promotion?.ok ? await call("GET", `/v1/promotions/${promotionId}`) : null + record( + "writable date-time", + Boolean(dateField && promotion?.ok && promotionLoaded?.body?.[dateField[0]]), + [ + dateField ? `${dateField[0]}=${promotionLoaded?.body?.[dateField[0]] ?? "missing"}` : "no writable date-time on promotion", + numberDate?.success ? `numeric 5 became ${JSON.stringify(numberDate.data[dateField[0]])}` : `numeric input rejected by schema`, + promotionParsed.success ? (promotion?.ok ? "" : errorDetail(promotion.body)) : JSON.stringify(promotionParsed.error.issues), + ].filter(Boolean).join("; ") + ) +} finally { + if (specAssigned) await call("DELETE", `/v1/specs/${specId}/productassignments/${productId}`) + if (createdCategory) await call("DELETE", `/v1/catalogs/${catalogId}/categories/${categoryId}`) + if (createdCatalog) await call("DELETE", `/v1/catalogs/${catalogId}`) + if (createdSpec) await call("DELETE", `/v1/specs/${specId}`) + if (createdPromotion) await call("DELETE", `/v1/promotions/${promotionId}`) + if (createdPriceSchedule) await call("DELETE", `/v1/priceschedules/${priceScheduleId}`) + if (createdProduct) await call("DELETE", `/v1/products/${productId}`) + const failed = results.filter((result) => !result.pass) + console.log(`\n${results.length - failed.length}/${results.length} edge cases passed`) + if (failed.length) process.exitCode = 1 +} diff --git a/poc/validate-marketplace.mjs b/poc/validate-marketplace.mjs new file mode 100644 index 0000000..df29320 --- /dev/null +++ b/poc/validate-marketplace.mjs @@ -0,0 +1,369 @@ +import { readFileSync } from "node:fs" +import SwaggerParser from "@apidevtools/swagger-parser" +import { cloneDeep } from "lodash" +import { + generateFormSchema, + generateParameterSchema, + shallowNestedProperties, + shapeXpSchema, +} from "../src/formSchemaGenerator.utils.tsx" + +const baseApiUrl = "https://sandboxapi.ordercloud.io" +const token = process.env.OC_ACCESS_TOKEN ?? "" + +function findOperation(spec, operationId) { + for (const item of Object.values(spec.paths)) { + for (const operation of Object.values(item)) { + if (operation && operation.operationId === operationId) return operation + } + } + throw new Error(`Missing operation ${operationId}`) +} + +function resourceSchemaFor(operation) { + const requestSchema = operation.requestBody.content["application/json"].schema + const nativeProperties = cloneDeep(requestSchema.allOf[0].properties) + requestSchema.required?.forEach((propKey) => { + nativeProperties[propKey].required = true + }) + return { + requestSchema, + resourceSchema: shallowNestedProperties(nativeProperties), + parameters: generateParameterSchema(operation), + } +} + +function formSchemaFor(operation) { + const { requestSchema, resourceSchema, parameters } = resourceSchemaFor(operation) + const body = generateFormSchema(resourceSchema, requestSchema.required) + const parameterSchema = parameters + ? generateFormSchema(parameters.schema, parameters.required) + : null + return { body, parameterSchema, resourceSchema } +} + +function summarize(schema) { + const types = {} + for (const [name, property] of Object.entries(schema)) { + types[property.type || "unknown"] = (types[property.type || "unknown"] || 0) + 1 + if (property.readOnly) types.readOnly = (types.readOnly || 0) + 1 + void name + } + return { fields: Object.keys(schema).length, types } +} + +const spec = await SwaggerParser.dereference(`${baseApiUrl}/v1/openapi/v3`) +const create = formSchemaFor(findOperation(spec, "Products.Create")) +const save = formSchemaFor(findOperation(spec, "Products.Save")) +const assignment = formSchemaFor(findOperation(spec, "Products.SaveAssignment")) + +const createInput = { + Name: "SDK form poc", + ID: "sdk-form-poc", + Description: "", + Active: null, + Inventory: { Enabled: false, QuantityAvailable: "" }, + xp: {}, +} + +function issuesFor(schema, value) { + const parsed = schema.safeParse(value) + if (parsed.success) return { ok: true, data: parsed.data, issues: [] } + return { + ok: false, + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + } +} + +const createAttempt = issuesFor(create.body, createInput) +const createWithMultiplier = issuesFor(create.body, { + ...createInput, + QuantityMultiplier: 1, +}) + +const saveInput = { + parameters: { productID: "" }, + body: { Name: "", ID: "sdk-form-poc" }, +} +let saveError = null +try { + save.parameterSchema.parse({ productID: "" }) +} catch (error) { + saveError = error.issues?.map((issue) => issue.message) ?? [error.message] +} + +const assignmentParsed = issuesFor(assignment.body, { + ProductID: "sdk-form-poc", + BuyerID: "buyer", +}) + +const report = { + marketplace: "Cloud Commerce", + specVersion: spec.info?.version, + create: { + schema: summarize(create.resourceSchema), + untouchedNumberFields: createAttempt, + withQuantityMultiplier: createWithMultiplier.ok + ? createWithMultiplier.data + : createWithMultiplier.issues, + }, + save: { + schema: summarize(save.resourceSchema), + emptyProductId: saveError, + }, + assignment: { + schema: summarize(assignment.resourceSchema), + parsed: assignmentParsed, + }, +} + +console.log(JSON.stringify(report, null, 2)) + +function arrayFields(schema) { + return Object.entries(schema) + .filter(([, property]) => property.type === "array" && !property.readOnly) + .map(([name]) => name) +} + +const fieldResults = [] +function record(name, pass, detail) { + fieldResults.push({ name, pass, detail }) + console.log(`${pass ? "PASS" : "FAIL"} ${name}: ${detail}`) +} + +const jwtParts = token.split(".").length +if (jwtParts !== 3) { + console.error( + `\nOC_ACCESS_TOKEN is not a complete JWT (${token.length} characters). Field tests were not sent.` + ) + process.exitCode = 2 +} else { + const stamp = Date.now() + const productId = `sdk-form-poc-${stamp}` + const buyerId = `sdk-form-buyer-${stamp}` + const catalogId = `sdk-form-catalog-${stamp}` + const priceScheduleId = `sdk-form-prices-${stamp}` + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + let createdProduct = false + let createdBuyer = false + let createdPriceSchedule = false + let createdCatalog = false + let assignmentBuyerId = null + + async function call(method, path, body) { + const response = await fetch(`${baseApiUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await response.text() + let parsed = text + try { + parsed = text ? JSON.parse(text) : null + } catch { + parsed = text + } + return { status: response.status, ok: response.status < 400, body: parsed } + } + + function errorDetail(body) { + const errors = body?.Errors + if (!errors) return JSON.stringify(body) + return errors.map((error) => error.Message || error.ErrorCode).join("; ") + } + + try { + const blank = create.body.safeParse({ + ID: productId, + Name: "SDK form poc", + QuantityMultiplier: "", + Active: null, + ShipWeight: null, + }) + if (!blank.success) { + record("blank product payload", false, JSON.stringify(blank.error.issues)) + } else if ( + Object.prototype.hasOwnProperty.call(blank.data, "QuantityMultiplier") || + Object.prototype.hasOwnProperty.call(blank.data, "Active") || + Object.prototype.hasOwnProperty.call(blank.data, "ShipWeight") + ) { + record("blank product payload", false, JSON.stringify(blank.data)) + } else { + const created = await call("POST", "/v1/products", blank.data) + createdProduct = created.ok + record( + "create with blank numbers and null active", + created.ok, + created.ok ? created.body.ID : errorDetail(created.body) + ) + } + + if (createdProduct) { + const loaded = await call("GET", `/v1/products/${productId}`) + const reparsed = save.body.safeParse(loaded.body) + const saved = reparsed.success + ? await call("PUT", `/v1/products/${productId}`, { + ...reparsed.data, + Description: "Edited after load", + }) + : null + const confirmed = saved?.ok ? await call("GET", `/v1/products/${productId}`) : null + record( + "edit after async load", + Boolean(saved?.ok && confirmed?.body?.Description === "Edited after load" && confirmed.body.Active !== null), + saved?.ok + ? `Name=${confirmed.body.Name} Active=${confirmed.body.Active} QuantityMultiplier=${confirmed.body.QuantityMultiplier}` + : reparsed.success + ? errorDetail(saved.body) + : JSON.stringify(reparsed.error.issues) + ) + + const nestedInput = { + ...loaded.body, + Inventory: { Enabled: true, QuantityAvailable: 4, NotificationPoint: "" }, + } + const nested = save.body.safeParse(nestedInput) + const nestedSaved = nested.success + ? await call("PUT", `/v1/products/${productId}`, nested.data) + : null + const nestedLoaded = nestedSaved?.ok ? await call("GET", `/v1/products/${productId}`) : null + record( + "nested inventory object", + Boolean(nestedSaved?.ok && nestedLoaded?.body?.Inventory?.QuantityAvailable === 4), + nested.success + ? nestedSaved?.ok + ? JSON.stringify(nestedLoaded.body.Inventory) + : errorDetail(nestedSaved.body) + : JSON.stringify(nested.error.issues) + ) + + const xpBodySchema = (() => { + const built = resourceSchemaFor(findOperation(spec, "Products.Create")) + const xpSchema = { + type: "object", + required: ["Color"], + properties: { Color: { type: "string", required: true } }, + } + built.resourceSchema.xp = xpSchema + return generateFormSchema(built.resourceSchema, built.requestSchema.required) + })() + const missingXp = xpBodySchema.safeParse({ ID: productId, Name: "SDK form poc", xp: {} }) + const withXp = xpBodySchema.safeParse({ + ID: productId, + Name: "SDK form poc", + xp: { Color: "red" }, + }) + const xpSaved = withXp.success ? await call("PUT", `/v1/products/${productId}`, withXp.data) : null + const xpLoaded = xpSaved?.ok ? await call("GET", `/v1/products/${productId}`) : null + record( + "provider xp schema", + Boolean(!missingXp.success && xpSaved?.ok && xpLoaded?.body?.xp?.Color === "red"), + xpSaved?.ok ? `xp.Color=${xpLoaded.body.xp?.Color}` : missingXp.success ? "blank xp was accepted" : errorDetail(xpSaved?.body) + ) + + const inferred = shapeXpSchema([xpLoaded?.body ?? { xp: { Color: "red" } }]) + const inferredSchema = generateFormSchema(inferred.properties) + const inferredParsed = inferredSchema.safeParse({ Color: "red" }) + record( + "inferred xp", + Boolean(inferred.properties?.Color?.type === "string" && inferredParsed.success), + JSON.stringify(inferred.properties) + ) + } + + const buyers = await call("GET", "/v1/buyers?pageSize=1") + let assignmentBuyer = buyers.body?.Items?.[0]?.ID + assignmentBuyerId = assignmentBuyer ?? null + if (!assignmentBuyer) { + const buyer = await call("POST", "/v1/buyers", { ID: buyerId, Name: "SDK form buyer", Active: true }) + createdBuyer = buyer.ok + assignmentBuyer = buyer.ok ? buyerId : null + assignmentBuyerId = assignmentBuyer + } + if (createdProduct && assignmentBuyer) { + const catalog = await call("POST", "/v1/catalogs", { + ID: catalogId, + Name: "SDK form catalog", + Active: true, + }) + createdCatalog = catalog.ok + const catalogAssigned = createdCatalog + ? await call("POST", "/v1/catalogs/assignments", { CatalogID: catalogId, BuyerID: assignmentBuyer }) + : null + const productInCatalog = catalogAssigned?.ok + ? await call("POST", "/v1/catalogs/productassignments", { CatalogID: catalogId, ProductID: productId }) + : null + const assignmentBody = assignment.body.safeParse({ + ProductID: productId, + BuyerID: assignmentBuyer, + }) + const assigned = assignmentBody.success && productInCatalog?.ok + ? await call("POST", "/v1/products/assignments", assignmentBody.data) + : null + record( + "assignment save", + Boolean(assigned?.ok), + assigned?.ok + ? `${productId} -> ${assignmentBuyer}` + : [ + `catalog ${catalog.status} ${errorDetail(catalog.body)}`, + `catalogAssignment ${catalogAssigned?.status} ${errorDetail(catalogAssigned?.body)}`, + `productCatalog ${productInCatalog?.status} ${errorDetail(productInCatalog?.body)}`, + `productAssignment ${assigned?.status} ${errorDetail(assigned?.body)}`, + ].join(" | ") + ) + } else { + record("assignment save", false, "no product or buyer") + } + + const priceOperation = findOperation(spec, "PriceSchedules.Create") + const priceForm = formSchemaFor(priceOperation) + record( + "array field present on price schedule", + arrayFields(priceForm.resourceSchema).includes("PriceBreaks"), + arrayFields(priceForm.resourceSchema).join(", ") || "none" + ) + const priceParsed = priceForm.body.safeParse({ + ID: priceScheduleId, + Name: "SDK form prices", + PriceBreaks: [{ Quantity: 1, Price: 1.25 }], + }) + const priceCreated = priceParsed.success + ? await call("POST", "/v1/priceschedules", priceParsed.data) + : null + createdPriceSchedule = Boolean(priceCreated?.ok) + const priceLoaded = priceCreated?.ok ? await call("GET", `/v1/priceschedules/${priceScheduleId}`) : null + record( + "array field save", + Boolean(priceCreated?.ok && priceLoaded?.body?.PriceBreaks?.length === 1 && priceLoaded.body.PriceBreaks[0].Price === 1.25), + priceParsed.success + ? priceCreated?.ok + ? JSON.stringify(priceLoaded.body.PriceBreaks) + : errorDetail(priceCreated.body) + : JSON.stringify(priceParsed.error.issues) + ) + } finally { + if (createdProduct && assignmentBuyerId) { + await call("DELETE", `/v1/products/${productId}/assignments/${assignmentBuyerId}`) + } + if (createdCatalog && createdProduct) { + await call("DELETE", `/v1/catalogs/${catalogId}/productassignments/${productId}`) + } + if (createdCatalog && assignmentBuyerId) { + await call("DELETE", `/v1/catalogs/${catalogId}/assignments/${assignmentBuyerId}`) + } + if (createdCatalog) await call("DELETE", `/v1/catalogs/${catalogId}`) + if (createdProduct) await call("DELETE", `/v1/products/${productId}`) + if (createdBuyer) await call("DELETE", `/v1/buyers/${buyerId}`) + if (createdPriceSchedule) await call("DELETE", `/v1/priceschedules/${priceScheduleId}`) + const failed = fieldResults.filter((result) => !result.pass) + console.log(`\n${fieldResults.length - failed.length}/${fieldResults.length} field tests passed`) + if (failed.length) process.exitCode = 1 + } +}