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 (
+
+ {(field) => (
+ <>
+ field.handleChange(event.target.value)}
+ />
+ {field.state.meta.errors.length > 0 && (
+ {field.state.meta.errors.map((error) => error?.message ?? String(error)).join(", ")}
+ )}
+ >
+ )}
+
+ Save product
+
+)
+```
+
+### 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..576e2d9 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",
@@ -32,17 +33,17 @@
"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": {
- "@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 +986,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",
@@ -1059,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",
@@ -1073,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"
}
@@ -1082,22 +1074,11 @@
"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"
}
},
- "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",
@@ -1107,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"
@@ -1745,6 +1726,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 +1806,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 +1900,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",
@@ -1891,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",
@@ -1908,16 +2007,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",
@@ -2166,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",
@@ -2267,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"
},
@@ -2387,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",
@@ -2652,13 +2876,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",
@@ -2669,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",
@@ -2730,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",
@@ -2746,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",
@@ -2961,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",
@@ -3101,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",
@@ -3432,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",
@@ -4050,17 +4331,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",
@@ -4247,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",
@@ -4703,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",
@@ -4806,11 +5093,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 +5223,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",
@@ -5192,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"
@@ -5211,22 +5484,25 @@
"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",
"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",
@@ -5303,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",
@@ -5326,32 +5622,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,10 +5633,19 @@
"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/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",
@@ -5410,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",
@@ -5422,11 +5731,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 +5781,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 +5852,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",
@@ -5646,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",
@@ -5715,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",
@@ -5760,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",
@@ -5795,26 +6228,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..7890570 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",
@@ -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",
@@ -56,19 +57,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",
@@ -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/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
+ }
+}
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/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/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)
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/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)
})
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",
},
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"],
+ },
+});