From 9e6791aa5a4bdd6a95b19481ac8bed12dda85c3f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:38:44 +0000 Subject: [PATCH 1/5] chore: verify push access From dbaa216c9d6457dd3dae249657ef242ff8d1a2da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:50:21 +0000 Subject: [PATCH 2/5] Implement condition editor UI with React, TypeScript, Vite, and mocked GraphQL Reimplements the datastore.js dataset (properties, operators, products, including the sparse `wireless` property on products 3-5) behind a mocked GraphQL API using MSW, fetched via graphql-request. The condition editor builds a single [property] [operator] [value] filter with operator options constrained to the selected property's type, and the product list updates live from a pure, unit-tested filterProducts/evaluateCondition function as the condition changes; clearing restores the full list. Adds Vitest + React Testing Library coverage: a full property-type x operator validity matrix and all README worked examples at the domain layer, plus MSW-backed component tests exercising live filtering and clear-filter through the real App. typecheck, lint, test, and build all pass. See SOLUTION.md for the guided tour, architecture rationale, and assumptions. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LgpEckQKhFj9SXzRKmpwe6 --- .gitignore | 24 + .oxlintrc.json | 8 + README.md | 79 +- SOLUTION.md | 67 + index.html | 12 + package-lock.json | 3112 ++++++++++++++++++++++ package.json | 41 + public/mockServiceWorker.js | 361 +++ reference/EXERCISE_README.md | 78 + datastore.js => reference/datastore.js | 0 wireframe.pdf => reference/wireframe.pdf | Bin src/App.css | 102 + src/App.test.tsx | 183 ++ src/App.tsx | 64 + src/api/client.ts | 14 + src/api/queries.ts | 28 + src/api/types.ts | 53 + src/api/useCatalog.ts | 44 + src/components/ConditionEditor.tsx | 120 + src/components/ProductList.tsx | 50 + src/components/ValueInput.tsx | 139 + src/domain/filter.test.ts | 403 +++ src/domain/filter.ts | 101 + src/domain/operators.test.ts | 84 + src/domain/operators.ts | 41 + src/index.css | 30 + src/main.tsx | 21 + src/mocks/browser.ts | 5 + src/mocks/data.ts | 97 + src/mocks/handlers.ts | 14 + src/mocks/server.ts | 5 + src/setupTests.ts | 16 + tsconfig.app.json | 26 + tsconfig.json | 7 + tsconfig.node.json | 23 + vite.config.ts | 13 + 36 files changed, 5389 insertions(+), 76 deletions(-) create mode 100644 .gitignore create mode 100644 .oxlintrc.json create mode 100644 SOLUTION.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/mockServiceWorker.js create mode 100644 reference/EXERCISE_README.md rename datastore.js => reference/datastore.js (100%) rename wireframe.pdf => reference/wireframe.pdf (100%) create mode 100644 src/App.css create mode 100644 src/App.test.tsx create mode 100644 src/App.tsx create mode 100644 src/api/client.ts create mode 100644 src/api/queries.ts create mode 100644 src/api/types.ts create mode 100644 src/api/useCatalog.ts create mode 100644 src/components/ConditionEditor.tsx create mode 100644 src/components/ProductList.tsx create mode 100644 src/components/ValueInput.tsx create mode 100644 src/domain/filter.test.ts create mode 100644 src/domain/filter.ts create mode 100644 src/domain/operators.test.ts create mode 100644 src/domain/operators.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/mocks/browser.ts create mode 100644 src/mocks/data.ts create mode 100644 src/mocks/handlers.ts create mode 100644 src/mocks/server.ts create mode 100644 src/setupTests.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/README.md b/README.md index b91837a..015a857 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,5 @@ -# Product Filtering Condition Editor UI -A Coding Exercise for UI Developers - -# Introduction - -Many capabilities of Salsify are built around filtered sets of products. Products at Salsify consist of properties and their values. Properties have a datatype. - -In order to create filtered sets of products in Salsify we created a condition editor. This editor is used to build a filter that Salsify applies to the full set of products. The resulting set of products, presented as a list, is updated as filters are added or changed. - -In order to create a filter users must choose a property, an operator, and one or more values. Due to the differences in property datatypes, not all operators apply to all properties. - -To complete this exercise please build a user interface to create a filter and update a list of products to reflect the results. Use the exercise to demonstrate not only a solution to the problem but your approach to software design and testing. - -Provide us with an archive containing the results of your work and a README file with a guided tour of your work, notes on your development process, how long you spent on the exercise, what assumptions you made, etc. If you wish, this may also be presented as a live site. In that case simply provide a link to the site and the README file mentioned above. - -# Specification - -This repository contains a mock `datastore` which includes sample products, property definitions including data types, and the complete set of filter operator. Using this datastore please create a web user interface with the following behavior: - -* A user can create a single filter -* Filters have the form `[property] [operator] [property value]` -* Creating or updating a filter causes the the list of products to update -* A user can clear the filter to see all products - -Included are [wireframes](http://salsify.github.io/condition-editor-coding-exercise/docs/wireframe.pdf) to illustrate a potential implementation. Feel free to approach this solution in the manner you see fit, but keep in mind we will evaluate your submission more on software design than user experience. - -# Tips and Recommendations -- No other Operators or data types will be introduced; they are static. -- Properties and Products vary from customer to customer, you cannot depend on having the same properties or products available each time this application loads - -## Properties Types/Operators - -Operators define the relationship between properties and property values. Certain operators are only valid for certain property types. The behavior of each operator and the valid operators for each property type are defined as follows: - -| Operator | Description | ------------|-------------- -| Equals | Value exactly matches | -| Is greater than | Value is greater than | -| Is less than | Value is less than | -| Has any value | Value is present | -| Has no value | Value is absent | -| Is any of | Value exactly matches one of several values | -| Contains | Value contains the specified text | - - -| Property Type | Valid Operators | ----------------- | ---------------- -| string | Equals | -| | Has any value | -| | Has no value | -| | Is any of | -| | Contains | -| number | Equals | -| | Is greater than | -| | Is less than | -| | Has any value | -| | Has no value | -| | Is any of | -| enumerated | equals | -| | Has any value | -| | Has no value | -| | Is any of | - -### Examples - -Here are some example property & input combinations and a description of their expected output. This table is meant to further clarify the expected behavior of the aforementioned operators. - -| Operator | Example Property | Example Value | Expected Output | -| -------- | ---------------- | ------------------- | --------------- | -| Equals | `Name` | `Headphones` | Products where `Name` is exactly `Headphones` | -| Is greater than | `Price` | `20` | Products where the `Price` > `20` | -| Is less than | `Price` | `20` | Products where `Price` < `20` | -| Has any value | `Description` | --- | Products where `Description` is defined/is NOT null | -| Has no value | `Description` | --- | Products where the `Description` is not defined/IS null | -| Is any of | `Name` | `Headphones, Keys` | Products where the Name is either exactly `Headphones` OR exactly `Keys` | -| Contains | `Name` | `phone` | Products where the Name string CONTAINS `phone` (e.g. `Headphones`, `Telephone`, `Cell Phone`, `Phone`) | +# Product Filtering Condition Editor +A React + TypeScript + Vite implementation of the [condition editor coding exercise](reference/EXERCISE_README.md), backed by a mocked GraphQL API (MSW). +See **[SOLUTION.md](SOLUTION.md)** for setup/run/test commands, a guided tour of the codebase, architecture decisions, and testing notes. diff --git a/SOLUTION.md b/SOLUTION.md new file mode 100644 index 0000000..5b7eefc --- /dev/null +++ b/SOLUTION.md @@ -0,0 +1,67 @@ +# Solution: Product Filtering Condition Editor + +A guided tour of this solution to the [condition editor coding exercise](reference/EXERCISE_README.md). + +## Setup / run / test + +```sh +npm install +npm run dev # http://localhost:5173 — MSW mocks the GraphQL API in the browser +npm run test # unit + component tests (Vitest + React Testing Library, MSW-mocked) +npm run typecheck # tsc -b, no emit +npm run lint # oxlint +npm run build # tsc -b && vite build — production bundle in dist/ +``` + +All four of `test`, `typecheck`, `lint`, and `build` pass as of this commit. + +## Guided tour + +- **`reference/`** — the original exercise files (`EXERCISE_README.md`, `datastore.js`, `wireframe.pdf`), untouched, kept for context. Nothing in `src/` imports from here; the dataset is reimplemented behind a GraphQL API instead (see below). +- **`src/api/`** — the GraphQL client layer. + - `types.ts` — TypeScript types for `Property`, `Product`, `Operator`, etc., describing what the mocked API returns. + - `queries.ts` — the single `Catalog` query (properties + operators + products). + - `client.ts` — a `graphql-request` `GraphQLClient` pointed at a (placeholder) GraphQL endpoint. + - `useCatalog.ts` — a small hook that fetches the catalog once on mount and exposes `{status: 'loading'|'error'|'success', ...}`. +- **`src/domain/`** — pure, framework-free business logic, and the most heavily tested part of the codebase. + - `operators.ts` — the property-type → valid-operators validity matrix from the README. + - `filter.ts` — `evaluateCondition` (does one product match one condition?) and `filterProducts`. +- **`src/mocks/`** — the reimplemented dataset and its MSW GraphQL handlers. + - `data.ts` — the same products/properties/operators as `datastore.js`, camelCased for GraphQL, including products 3-5 deliberately omitting their `wireless` value. + - `handlers.ts` — an MSW `graphql.query('Catalog', ...)` handler resolving with that data. + - `browser.ts` / `server.ts` — the MSW worker (used in dev, wired up in `main.tsx`) and MSW server (used in tests, wired up in `setupTests.ts`), sharing the same `handlers`. +- **`src/components/`** — `ConditionEditor` (property/operator/value selection), `ValueInput` (renders the right input for the selected property type + operator), `ProductList` (the live-updating table). +- **`src/App.tsx`** — fetches the catalog, holds the current `Condition`, and derives the filtered product list with `useMemo`. + +## Architecture / stack choices + +- **Vite + React + TypeScript**: as specified. `npm create vite@latest -- --template react-ts` scaffold, since the exercise didn't ask for a hand-rolled build. +- **`graphql-request` over `urql`**: the app makes exactly one query, once, with no variables, no caching strategy, and no mutations — `urql`'s normalized cache and exchange pipeline would be pure overhead here. `graphql-request` is a thin `fetch` wrapper, so the "client-side machinery" the exercise asks for is a ~15-line `useCatalog` hook rather than a library's internals, which felt like the more honest demonstration of the plumbing for a dataset this size. +- **MSW (`msw/browser` in dev, `msw/node` in tests) sharing one `handlers.ts`**: the same mocked GraphQL API backs both the running app and the test suite, so a component test failure means the *real* UI code has a bug, not that a hand-rolled test double drifted from what the app actually calls. +- **No real GraphQL schema/executor**: `handlers.ts` matches requests by operation name (`graphql.query('Catalog', ...)`) and returns a plain JS object shaped like the query — there's no `graphql-js` schema validating it against SDL. For a mocked, read-only, single-query API this is the standard MSW pattern and kept the surface area proportional to the exercise; a real backend would obviously need an actual executable schema. +- **Filtering is client-side**, not a new GraphQL request per keystroke: the catalog (properties, operators, and all products) is fetched once; `ConditionEditor` edits a `Condition` value in React state, and `filterProducts` (pure, synchronous) recomputes the visible list via `useMemo`. This mirrors the original vanilla-JS exercise, where `datastore.js` was also just an in-memory dataset filtered in the browser — swapping it for a mocked GraphQL layer changes *how the data arrives*, not the fact that filtering is a client-side computation over an already-fetched list. +- **Data-driven UI**: the property dropdown, operator options (filtered per type via `getValidOperatorIds`), and the `ProductList` table's columns are all built from whatever `properties`/`operators`/`products` come back from the query — nothing hardcodes "5 properties" or specific property names/ids. Swap the mock dataset for a different customer's data and the UI adapts without code changes. + +## Testing + +- **`domain/filter.test.ts`** (32 tests) — `evaluateCondition` and `filterProducts` against: + - Each README worked example (`equals`, `greater_than`/`less_than`, `any`/`none`, `in`, `contains`), run against the real mock dataset. + - The `contains` example specifically, including product names the mock dataset doesn't contain (`Telephone`, `Phone`) via a small synthetic fixture, so the exact README wording is covered even where the shipped dataset only has two of the four example matches. + - `any`/`none` against products 3-5, which is the dataset's designed test case for a missing property value. + - A synthetic-fixture pass over the *entire* property-type × operator matrix (string/number/enumerated × all 7 operators, restricted to what's valid for each), independent of the specific mock dataset. +- **`domain/operators.test.ts`** (18 tests) — asserts the validity matrix itself: exactly which operators are valid for each of the three property types, including the negative cases (`contains` invalid for number/enumerated, `greater_than`/`less_than` invalid for string/enumerated). +- **`App.test.tsx`** (7 tests) — component/integration tests rendering the real `App` against the MSW-mocked GraphQL response: initial full list, live filtering as property/operator/value change (numeric, string `contains`, enumerated `any`/`none`, enumerated `in` via checkboxes), operator options narrowing per property type, and clear-filter restoring the full list. + +Run `npm run test` for all 57. + +## Assumptions and deviations worth a second look + +- **`contains` is case-insensitive**; `equals` and `in` are exact/case-sensitive matches. The README doesn't state case sensitivity explicitly; case-insensitive substring search felt like the more useful default for a "contains" search box, while `equals`/`in` staying exact preserves "exactly matches" as written. +- **`in`'s value input is a comma-separated text field** for string/number properties (matching the README's `Headphones, Keys` example verbatim) and a checkbox group for enumerated properties (so users pick from the actual allowed values rather than typing them). +- **The GraphQL endpoint (`http://localhost/graphql`) is a placeholder**, not a real reachable host — MSW intercepts by operation name regardless of the request URL, and Node's global `fetch` (which is what runs under Vitest even with `environment: "jsdom"`) rejects a bare relative path like `/graphql` with "Invalid URL". Pointing this at a real backend would just mean making the endpoint configurable (e.g. via an env var) and dropping the MSW `worker.start()` call in `main.tsx`. +- **No routing, no persistence** of the condition across reloads — out of scope per the spec ("a single filter"). +- **Numbers compare numerically even if a raw value arrives as a string** (`toNumber` coerces); this only matters if a future dataset stores numeric properties as strings, but it's cheap defensiveness given the mocked layer already treats `value` as `string | number`. + +## Time spent + +Roughly 2-3 hours: reading the spec and datastore closely, scaffolding, building the GraphQL/MSW plumbing, the domain logic and its tests, the UI, and the component tests. diff --git a/index.html b/index.html new file mode 100644 index 0000000..3e40cd3 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Condition Editor + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aa20ce2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3112 @@ +{ + "name": "app", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "app", + "version": "0.0.0", + "dependencies": { + "graphql": "^16.14.2", + "graphql-request": "^7.4.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "jsdom": "^30.0.1", + "msw": "^2.15.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2", + "vitest": "^4.1.11" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.10.tgz", + "integrity": "sha512-xBja6gaAaH2R2c7eNyl0TY4dhnnZ2uhj+KXpLdEQ6M/wuk9bYFZM8wY0ykw3VO4TgEJ56KGlerXS/9KBKVR/Cg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.0.tgz", + "integrity": "sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.1", + "@inquirer/type": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", + "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.1.0", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz", + "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.0.tgz", + "integrity": "sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz", + "integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz", + "integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz", + "integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz", + "integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz", + "integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz", + "integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz", + "integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz", + "integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz", + "integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz", + "integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz", + "integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz", + "integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz", + "integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz", + "integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz", + "integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz", + "integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz", + "integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz", + "integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz", + "integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/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/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-request": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-7.4.0.tgz", + "integrity": "sha512-xfr+zFb/QYbs4l4ty0dltqiXIp07U6sl+tOKAb0t50/EnQek6CVVBLjETXi+FghElytvgaAWtIOt3EV7zLzIAQ==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/msw": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/oxlint": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz", + "integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.80.0", + "@oxlint/binding-android-arm64": "1.80.0", + "@oxlint/binding-darwin-arm64": "1.80.0", + "@oxlint/binding-darwin-x64": "1.80.0", + "@oxlint/binding-freebsd-x64": "1.80.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.80.0", + "@oxlint/binding-linux-arm-musleabihf": "1.80.0", + "@oxlint/binding-linux-arm64-gnu": "1.80.0", + "@oxlint/binding-linux-arm64-musl": "1.80.0", + "@oxlint/binding-linux-ppc64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-musl": "1.80.0", + "@oxlint/binding-linux-s390x-gnu": "1.80.0", + "@oxlint/binding-linux-x64-gnu": "1.80.0", + "@oxlint/binding-linux-x64-musl": "1.80.0", + "@oxlint/binding-openharmony-arm64": "1.80.0", + "@oxlint/binding-win32-arm64-msvc": "1.80.0", + "@oxlint/binding-win32-ia32-msvc": "1.80.0", + "@oxlint/binding-win32-x64-msvc": "1.80.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "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/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz", + "integrity": "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "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/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2876db6 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "condition-editor", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b", + "lint": "oxlint", + "test": "vitest run", + "test:watch": "vitest", + "preview": "vite preview" + }, + "dependencies": { + "graphql": "^16.14.2", + "graphql-request": "^7.4.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "jsdom": "^30.0.1", + "msw": "^2.15.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2", + "vitest": "^4.1.11" + }, + "msw": { + "workerDirectory": [ + "public" + ] + } +} \ No newline at end of file diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..0c970ef --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,361 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/reference/EXERCISE_README.md b/reference/EXERCISE_README.md new file mode 100644 index 0000000..b91837a --- /dev/null +++ b/reference/EXERCISE_README.md @@ -0,0 +1,78 @@ +# Product Filtering Condition Editor UI +A Coding Exercise for UI Developers + +# Introduction + +Many capabilities of Salsify are built around filtered sets of products. Products at Salsify consist of properties and their values. Properties have a datatype. + +In order to create filtered sets of products in Salsify we created a condition editor. This editor is used to build a filter that Salsify applies to the full set of products. The resulting set of products, presented as a list, is updated as filters are added or changed. + +In order to create a filter users must choose a property, an operator, and one or more values. Due to the differences in property datatypes, not all operators apply to all properties. + +To complete this exercise please build a user interface to create a filter and update a list of products to reflect the results. Use the exercise to demonstrate not only a solution to the problem but your approach to software design and testing. + +Provide us with an archive containing the results of your work and a README file with a guided tour of your work, notes on your development process, how long you spent on the exercise, what assumptions you made, etc. If you wish, this may also be presented as a live site. In that case simply provide a link to the site and the README file mentioned above. + +# Specification + +This repository contains a mock `datastore` which includes sample products, property definitions including data types, and the complete set of filter operator. Using this datastore please create a web user interface with the following behavior: + +* A user can create a single filter +* Filters have the form `[property] [operator] [property value]` +* Creating or updating a filter causes the the list of products to update +* A user can clear the filter to see all products + +Included are [wireframes](http://salsify.github.io/condition-editor-coding-exercise/docs/wireframe.pdf) to illustrate a potential implementation. Feel free to approach this solution in the manner you see fit, but keep in mind we will evaluate your submission more on software design than user experience. + +# Tips and Recommendations +- No other Operators or data types will be introduced; they are static. +- Properties and Products vary from customer to customer, you cannot depend on having the same properties or products available each time this application loads + +## Properties Types/Operators + +Operators define the relationship between properties and property values. Certain operators are only valid for certain property types. The behavior of each operator and the valid operators for each property type are defined as follows: + +| Operator | Description | +-----------|-------------- +| Equals | Value exactly matches | +| Is greater than | Value is greater than | +| Is less than | Value is less than | +| Has any value | Value is present | +| Has no value | Value is absent | +| Is any of | Value exactly matches one of several values | +| Contains | Value contains the specified text | + + +| Property Type | Valid Operators | +---------------- | ---------------- +| string | Equals | +| | Has any value | +| | Has no value | +| | Is any of | +| | Contains | +| number | Equals | +| | Is greater than | +| | Is less than | +| | Has any value | +| | Has no value | +| | Is any of | +| enumerated | equals | +| | Has any value | +| | Has no value | +| | Is any of | + +### Examples + +Here are some example property & input combinations and a description of their expected output. This table is meant to further clarify the expected behavior of the aforementioned operators. + +| Operator | Example Property | Example Value | Expected Output | +| -------- | ---------------- | ------------------- | --------------- | +| Equals | `Name` | `Headphones` | Products where `Name` is exactly `Headphones` | +| Is greater than | `Price` | `20` | Products where the `Price` > `20` | +| Is less than | `Price` | `20` | Products where `Price` < `20` | +| Has any value | `Description` | --- | Products where `Description` is defined/is NOT null | +| Has no value | `Description` | --- | Products where the `Description` is not defined/IS null | +| Is any of | `Name` | `Headphones, Keys` | Products where the Name is either exactly `Headphones` OR exactly `Keys` | +| Contains | `Name` | `phone` | Products where the Name string CONTAINS `phone` (e.g. `Headphones`, `Telephone`, `Cell Phone`, `Phone`) | + + diff --git a/datastore.js b/reference/datastore.js similarity index 100% rename from datastore.js rename to reference/datastore.js diff --git a/wireframe.pdf b/reference/wireframe.pdf similarity index 100% rename from wireframe.pdf rename to reference/wireframe.pdf diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..f0ba1e6 --- /dev/null +++ b/src/App.css @@ -0,0 +1,102 @@ +.app { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +.app h1 { + font-size: 1.5rem; + margin-bottom: 1.5rem; +} + +.condition-editor { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 1rem; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.25rem 1.5rem 1.5rem; + margin-bottom: 2rem; +} + +.condition-editor legend { + font-weight: 600; + padding: 0 0.25rem; +} + +.condition-editor__field { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 10rem; + font-size: 0.85rem; +} + +.condition-editor select, +.condition-editor input[type="text"], +.condition-editor input[type="number"] { + padding: 0.4rem 0.5rem; + font-size: 0.95rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text-h); +} + +.condition-editor button { + align-self: flex-end; + padding: 0.5rem 1rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + cursor: pointer; +} + +.condition-editor button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.value-input--checkbox-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.value-input__checkbox-label { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; + font-weight: normal; +} + +.product-list__count { + font-size: 0.85rem; + color: var(--text); + margin-bottom: 0.5rem; +} + +.product-list__table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +th, +td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +th { + font-weight: 600; + color: var(--text); +} diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..9394ad1 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,183 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import App from "./App"; + +/** + * These are component/integration tests: `App` renders for real, fetches + * the catalog through the real `graphql-request` client, and MSW (wired up + * globally in `setupTests.ts`) intercepts that GraphQL request and returns + * the mock catalog — nothing about the datastore is stubbed at the React + * level. + */ + +function getProductNames(): string[] { + const table = screen.getByRole("table"); + const rows = within(table).getAllByRole("row").slice(1); // skip header row + return rows.map((row) => within(row).getAllByRole("cell")[0].textContent!); +} + +async function waitForCatalog() { + await screen.findByRole("combobox", { name: "Property" }); +} + +describe("App", () => { + it("shows every product before any filter is applied", async () => { + render(); + await waitForCatalog(); + + expect(getProductNames().sort()).toEqual( + [ + "Cell Phone", + "Cup", + "Hammer", + "Headphones", + "Key", + "Keyboard", + ].sort(), + ); + }); + + it("filters live as a numeric condition is built (weight > 4)", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "weight (oz)", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Is greater than", + ); + await user.type(screen.getByRole("spinbutton", { name: "Value" }), "4"); + + expect(getProductNames().sort()).toEqual( + ["Hammer", "Headphones", "Keyboard"].sort(), + ); + }); + + it("filters using contains, matching the README worked example", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "Product Name", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Contains", + ); + await user.type(screen.getByRole("textbox", { name: "Value" }), "phone"); + + expect(getProductNames().sort()).toEqual(["Cell Phone", "Headphones"]); + }); + + it("has no value input for 'Has any value' / 'Has no value', and filters correctly", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "wireless", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Has any value", + ); + + expect(screen.queryByLabelText("Value")).not.toBeInTheDocument(); + expect(getProductNames().sort()).toEqual( + ["Cell Phone", "Headphones", "Keyboard"].sort(), + ); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Has no value", + ); + expect(getProductNames().sort()).toEqual(["Cup", "Hammer", "Key"].sort()); + }); + + it("filters an enumerated property with 'Is any of' via a checkbox group", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "category", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Is any of", + ); + + const group = screen.getByRole("group", { name: "Value" }); + await user.click(within(group).getByLabelText("tools")); + await user.click(within(group).getByLabelText("kitchenware")); + + expect(getProductNames().sort()).toEqual(["Cup", "Hammer", "Key"].sort()); + }); + + it("only offers operators valid for the selected property's type", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "category", + ); + const operatorSelect = screen.getByRole("combobox", { name: "Operator" }); + const optionLabels = within(operatorSelect) + .getAllByRole("option") + .map((o) => o.textContent); + + expect(optionLabels).toEqual([ + "Equals", + "Has any value", + "Has no value", + "Is any of", + ]); + }); + + it("clearing the filter restores the full product list", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "Product Name", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Equals", + ); + await user.type( + screen.getByRole("textbox", { name: "Value" }), + "Headphones", + ); + expect(getProductNames()).toEqual(["Headphones"]); + + await user.click(screen.getByRole("button", { name: "Clear filter" })); + + expect(getProductNames().sort()).toEqual( + [ + "Cell Phone", + "Cup", + "Hammer", + "Headphones", + "Key", + "Keyboard", + ].sort(), + ); + expect( + screen.queryByRole("combobox", { name: "Operator" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ce51f5f --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,64 @@ +import { useMemo, useState } from "react"; +import { useCatalog } from "./api/useCatalog"; +import { ConditionEditor } from "./components/ConditionEditor"; +import { ProductList } from "./components/ProductList"; +import type { Operator, Product, Property } from "./api/types"; +import { filterProducts, type Condition } from "./domain/filter"; +import "./App.css"; + +// Stable empty-array fallbacks (rather than `[]` literals) so `useMemo` +// below doesn't see a "new" dependency on every render while loading/erroring. +const NO_PROPERTIES: Property[] = []; +const NO_OPERATORS: Operator[] = []; +const NO_PRODUCTS: Product[] = []; + +function App() { + const catalog = useCatalog(); + const [condition, setCondition] = useState(null); + + const properties = + catalog.status === "success" ? catalog.data.properties : NO_PROPERTIES; + const operators = + catalog.status === "success" ? catalog.data.operators : NO_OPERATORS; + const products = + catalog.status === "success" ? catalog.data.products : NO_PRODUCTS; + + // Always call useMemo (even before we know the catalog loaded) so hook + // order stays stable across the loading -> success/error transition. + const filteredProducts = useMemo( + () => filterProducts(products, condition, properties), + [products, condition, properties], + ); + + if (catalog.status === "loading") { + return ( +
+

Loading catalog…

+
+ ); + } + + if (catalog.status === "error") { + return ( +
+

Failed to load catalog: {catalog.error.message}

+
+ ); + } + + return ( +
+

Product Filter

+ setCondition(null)} + /> + +
+ ); +} + +export default App; diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..2579910 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,14 @@ +import { GraphQLClient } from "graphql-request"; + +/** + * `fetch` requires an absolute URL both in the browser under some + * environments and, notably, under Node (which is what Vitest runs in, + * even with `environment: "jsdom"`) — a bare "/graphql" throws "Invalid + * URL" there. MSW's `graphql.query(...)` handlers match by GraphQL + * operation name regardless of the request URL, so this placeholder host + * never actually needs to be reachable; swap it for a real endpoint (e.g. + * via an env var) when pointing this app at a real GraphQL server. + */ +export const GRAPHQL_ENDPOINT = "http://localhost/graphql"; + +export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT); diff --git a/src/api/queries.ts b/src/api/queries.ts new file mode 100644 index 0000000..b2b3c93 --- /dev/null +++ b/src/api/queries.ts @@ -0,0 +1,28 @@ +/** + * A single query fetches the full catalog (properties, operators, and + * products) once. The dataset is small and static per session — filtering + * as the user builds a condition happens client-side (see `domain/filter.ts`) + * rather than round-tripping to the server on every keystroke, matching the + * spirit of the original in-browser `datastore.js` exercise. + */ +export const CATALOG_QUERY = /* GraphQL */ ` + query Catalog { + properties { + id + name + type + values + } + operators { + id + text + } + products { + id + propertyValues { + propertyId + value + } + } + } +`; diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..78ad4f3 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,53 @@ +/** + * Types describing the shape of data returned by the mocked GraphQL API. + * These mirror `reference/schema.graphql` and, in turn, the original + * `datastore.js` dataset (see `reference/datastore.js`), reimplemented + * behind a GraphQL layer per the exercise instructions. + */ + +export type PropertyType = "string" | "number" | "enumerated"; + +export interface Property { + id: number; + name: string; + type: PropertyType; + /** Only present (and only meaningful) for `type: "enumerated"`. */ + values?: string[]; +} + +export interface PropertyValue { + propertyId: number; + /** Numeric properties carry a number; string/enumerated carry a string. */ + value: string | number; +} + +export interface Product { + id: number; + /** + * Sparse by design: a product may omit a `PropertyValue` entirely for a + * given property (see products 3-5 in the mock dataset), which is what + * makes the `any` ("has any value") / `none` ("has no value") operators + * meaningful. + */ + propertyValues: PropertyValue[]; +} + +export type OperatorId = + | "equals" + | "greater_than" + | "less_than" + | "any" + | "none" + | "in" + | "contains"; + +export interface Operator { + id: OperatorId; + text: string; +} + +export interface CatalogData { + properties: Property[]; + operators: Operator[]; + products: Product[]; +} diff --git a/src/api/useCatalog.ts b/src/api/useCatalog.ts new file mode 100644 index 0000000..f14dc1c --- /dev/null +++ b/src/api/useCatalog.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { graphqlClient } from "./client"; +import { CATALOG_QUERY } from "./queries"; +import type { CatalogData } from "./types"; + +export type CatalogState = + | { status: "loading" } + | { status: "error"; error: Error } + | { status: "success"; data: CatalogData }; + +/** + * Fetches the full product catalog (properties, operators, products) from + * the GraphQL API exactly once on mount. There are no arguments/variables + * and no refetching — the condition editor filters the already-fetched + * product list client-side as the user edits it. + */ +export function useCatalog(): CatalogState { + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let isMounted = true; + setState({ status: "loading" }); + + graphqlClient + .request(CATALOG_QUERY) + .then((data) => { + if (isMounted) setState({ status: "success", data }); + }) + .catch((error: unknown) => { + if (isMounted) { + setState({ + status: "error", + error: error instanceof Error ? error : new Error(String(error)), + }); + } + }); + + return () => { + isMounted = false; + }; + }, []); + + return state; +} diff --git a/src/components/ConditionEditor.tsx b/src/components/ConditionEditor.tsx new file mode 100644 index 0000000..833a61a --- /dev/null +++ b/src/components/ConditionEditor.tsx @@ -0,0 +1,120 @@ +import type { Operator, OperatorId, Property } from "../api/types"; +import type { Condition, ConditionValue } from "../domain/filter"; +import { getValidOperatorIds } from "../domain/operators"; +import { ValueInput } from "./ValueInput"; + +interface ConditionEditorProps { + properties: Property[]; + operators: Operator[]; + condition: Condition | null; + onConditionChange: (condition: Condition | null) => void; + onClear: () => void; +} + +/** + * The `[property] [operator] [value]` filter builder. The operator options + * are always filtered down to what's valid for the currently selected + * property's data type, and the value input adapts to both. + */ +export function ConditionEditor({ + properties, + operators, + condition, + onConditionChange, + onClear, +}: ConditionEditorProps) { + const selectedProperty = + properties.find((p) => p.id === condition?.propertyId) ?? null; + + const availableOperators = selectedProperty + ? operators.filter((op) => + getValidOperatorIds(selectedProperty.type).includes(op.id), + ) + : []; + + const handlePropertyChange = (propertyIdText: string) => { + if (propertyIdText === "") { + onConditionChange(null); + return; + } + const property = properties.find((p) => p.id === Number(propertyIdText)); + if (!property) return; + // Reset the operator/value whenever the property changes, since the + // previous operator may no longer be valid for the new property's type. + const [defaultOperatorId] = getValidOperatorIds(property.type); + onConditionChange({ + propertyId: property.id, + operatorId: defaultOperatorId, + value: undefined, + }); + }; + + const handleOperatorChange = (operatorId: string) => { + if (!condition) return; + onConditionChange({ + ...condition, + operatorId: operatorId as OperatorId, + value: undefined, + }); + }; + + const handleValueChange = (value: ConditionValue) => { + if (!condition) return; + onConditionChange({ ...condition, value }); + }; + + return ( +
+ Filter products + + + + {selectedProperty && condition && ( + + )} + + {selectedProperty && condition && ( +
+ Value + +
+ )} + + +
+ ); +} diff --git a/src/components/ProductList.tsx b/src/components/ProductList.tsx new file mode 100644 index 0000000..dccf6de --- /dev/null +++ b/src/components/ProductList.tsx @@ -0,0 +1,50 @@ +import type { Product, Property } from "../api/types"; + +interface ProductListProps { + products: Product[]; + properties: Property[]; +} + +/** + * Renders the (already-filtered) product list as a table with one column + * per property, driven entirely by whatever properties were fetched — no + * column is hardcoded to a specific property name. + */ +export function ProductList({ products, properties }: ProductListProps) { + const orderedProperties = [...properties].sort((a, b) => a.id - b.id); + + return ( +
+

+ {products.length} product{products.length === 1 ? "" : "s"} +

+
+ + + + {orderedProperties.map((property) => ( + + ))} + + + + {products.map((product) => ( + + {orderedProperties.map((property) => { + const propertyValue = product.propertyValues.find( + (pv) => pv.propertyId === property.id, + ); + return ( + + ); + })} + + ))} + +
{property.name}
+ {propertyValue ? String(propertyValue.value) : "—"} +
+
+
+ ); +} diff --git a/src/components/ValueInput.tsx b/src/components/ValueInput.tsx new file mode 100644 index 0000000..29c7fc5 --- /dev/null +++ b/src/components/ValueInput.tsx @@ -0,0 +1,139 @@ +import type { OperatorId, Property } from "../api/types"; +import type { ConditionValue } from "../domain/filter"; + +interface ValueInputProps { + property: Property; + operatorId: OperatorId; + value: ConditionValue; + onChange: (value: ConditionValue) => void; +} + +function parseStringList(text: string): string[] { + return text + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +function parseNumberList(text: string): number[] { + return text + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map(Number) + .filter((n) => !Number.isNaN(n)); +} + +/** + * Renders the value input appropriate for the selected property's data + * type and operator: no input for `any`/`none`, a ` toggle(option)} + /> + {option} + + ))} + + ); + } + + return ( + + ); + } + + if (property.type === "number") { + if (operatorId === "in") { + const text = Array.isArray(value) ? value.join(", ") : ""; + return ( + onChange(parseNumberList(e.target.value))} + /> + ); + } + return ( + + onChange(e.target.value === "" ? undefined : Number(e.target.value)) + } + /> + ); + } + + // string + if (operatorId === "in") { + const text = Array.isArray(value) ? value.join(", ") : ""; + return ( + onChange(parseStringList(e.target.value))} + /> + ); + } + return ( + onChange(e.target.value)} + /> + ); +} diff --git a/src/domain/filter.test.ts b/src/domain/filter.test.ts new file mode 100644 index 0000000..45afa0f --- /dev/null +++ b/src/domain/filter.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from "vitest"; +import type { Product, Property } from "../api/types"; +import { evaluateCondition, filterProducts } from "./filter"; +import { mockCatalog } from "../mocks/data"; + +const { properties, products } = mockCatalog; + +const propertyName = properties.find((p) => p.id === 0)!; // string +const propertyColor = properties.find((p) => p.id === 1)!; // string +const propertyWeight = properties.find((p) => p.id === 2)!; // number +const propertyCategory = properties.find((p) => p.id === 3)!; // enumerated +const propertyWireless = properties.find((p) => p.id === 4)!; // enumerated, sparse + +function nameOf(product: Product): string { + return String( + product.propertyValues.find((pv) => pv.propertyId === 0)!.value, + ); +} + +describe("evaluateCondition", () => { + describe("equals", () => { + it("matches a string property exactly (README example: Name equals Headphones)", () => { + const headphones = products.find((p) => nameOf(p) === "Headphones")!; + const keyboard = products.find((p) => nameOf(p) === "Keyboard")!; + const condition = { + propertyId: 0, + operatorId: "equals" as const, + value: "Headphones", + }; + expect(evaluateCondition(headphones, condition, propertyName)).toBe(true); + expect(evaluateCondition(keyboard, condition, propertyName)).toBe(false); + }); + + it("matches a number property numerically, not by string identity", () => { + const weighs5 = products.find((p) => nameOf(p) === "Headphones")!; + const condition = { + propertyId: 2, + operatorId: "equals" as const, + value: 5, + }; + expect(evaluateCondition(weighs5, condition, propertyWeight)).toBe(true); + }); + + it("matches an enumerated property", () => { + const cup = products.find((p) => nameOf(p) === "Cup")!; + const condition = { + propertyId: 3, + operatorId: "equals" as const, + value: "kitchenware", + }; + expect(evaluateCondition(cup, condition, propertyCategory)).toBe(true); + }); + + it("does not match when the property is missing on the product", () => { + const cup = products.find((p) => nameOf(p) === "Cup")!; // no wireless value + const condition = { + propertyId: 4, + operatorId: "equals" as const, + value: "true", + }; + expect(evaluateCondition(cup, condition, propertyWireless)).toBe(false); + }); + }); + + describe("greater_than / less_than (README example: Price/weight)", () => { + it("greater_than matches products whose numeric value is strictly greater", () => { + const condition = { + propertyId: 2, + operatorId: "greater_than" as const, + value: 4, + }; + const matches = products + .filter((p) => evaluateCondition(p, condition, propertyWeight)) + .map(nameOf); + expect(matches.sort()).toEqual(["Hammer", "Headphones", "Keyboard"]); + }); + + it("less_than matches products whose numeric value is strictly less", () => { + const condition = { + propertyId: 2, + operatorId: "less_than" as const, + value: 4, + }; + const matches = products + .filter((p) => evaluateCondition(p, condition, propertyWeight)) + .map(nameOf); + expect(matches.sort()).toEqual(["Cell Phone", "Cup", "Key"]); + }); + }); + + describe("any / none (README example: Description any/none)", () => { + it("any matches only products that have the property at all", () => { + const condition = { propertyId: 4, operatorId: "any" as const }; + const matches = products + .filter((p) => evaluateCondition(p, condition, propertyWireless)) + .map(nameOf); + expect(matches.sort()).toEqual(["Cell Phone", "Headphones", "Keyboard"]); + }); + + it("none matches only products missing the property entirely", () => { + const condition = { propertyId: 4, operatorId: "none" as const }; + const matches = products + .filter((p) => evaluateCondition(p, condition, propertyWireless)) + .map(nameOf); + expect(matches.sort()).toEqual(["Cup", "Hammer", "Key"]); + }); + + it("any/none work the same way for properties every product has", () => { + const anyCondition = { propertyId: 0, operatorId: "any" as const }; + const noneCondition = { propertyId: 0, operatorId: "none" as const }; + for (const product of products) { + expect(evaluateCondition(product, anyCondition, propertyName)).toBe( + true, + ); + expect(evaluateCondition(product, noneCondition, propertyName)).toBe( + false, + ); + } + }); + }); + + describe("in (README example: Name is any of Headphones, Keys)", () => { + it("matches a string property against a list of exact values", () => { + const condition = { + propertyId: 0, + operatorId: "in" as const, + value: ["Headphones", "Key"], + }; + const matches = products + .filter((p) => evaluateCondition(p, condition, propertyName)) + .map(nameOf); + expect(matches.sort()).toEqual(["Headphones", "Key"]); + }); + + it("matches a number property against a list of numeric values", () => { + const condition = { + propertyId: 2, + operatorId: "in" as const, + value: [1, 19], + }; + const matches = products + .filter((p) => evaluateCondition(p, condition, propertyWeight)) + .map(nameOf); + expect(matches.sort()).toEqual(["Hammer", "Key"]); + }); + + it("matches an enumerated property against a list of values", () => { + const condition = { + propertyId: 3, + operatorId: "in" as const, + value: ["tools", "kitchenware"], + }; + const matches = products + .filter((p) => evaluateCondition(p, condition, propertyCategory)) + .map(nameOf); + expect(matches.sort()).toEqual(["Cup", "Hammer", "Key"]); + }); + + it("matches nothing when the value list is empty", () => { + const condition = { propertyId: 0, operatorId: "in" as const, value: [] }; + const matches = products.filter((p) => + evaluateCondition(p, condition, propertyName), + ); + expect(matches).toHaveLength(0); + }); + }); + + describe("contains (README worked example)", () => { + // The README's own example ("Headphones, Telephone, Cell Phone, Phone") + // includes products not present in this dataset, so it's verified + // against a small synthetic fixture in addition to the real dataset. + const syntheticProducts: Product[] = [ + { id: 100, propertyValues: [{ propertyId: 0, value: "Headphones" }] }, + { id: 101, propertyValues: [{ propertyId: 0, value: "Telephone" }] }, + { id: 102, propertyValues: [{ propertyId: 0, value: "Cell Phone" }] }, + { id: 103, propertyValues: [{ propertyId: 0, value: "Phone" }] }, + { id: 104, propertyValues: [{ propertyId: 0, value: "Keyboard" }] }, + ]; + + it("matches every product whose name contains the substring", () => { + const condition = { + propertyId: 0, + operatorId: "contains" as const, + value: "phone", + }; + const matches = syntheticProducts + .filter((p) => evaluateCondition(p, condition, propertyName)) + .map(nameOf); + expect(matches.sort()).toEqual([ + "Cell Phone", + "Headphones", + "Phone", + "Telephone", + ]); + }); + + it("is case-insensitive", () => { + const condition = { + propertyId: 0, + operatorId: "contains" as const, + value: "PHONE", + }; + expect( + evaluateCondition(syntheticProducts[0], condition, propertyName), + ).toBe(true); + }); + + it("applies against the real dataset too", () => { + const condition = { + propertyId: 0, + operatorId: "contains" as const, + value: "phone", + }; + const matches = products + .filter((p) => evaluateCondition(p, condition, propertyName)) + .map(nameOf); + expect(matches.sort()).toEqual(["Cell Phone", "Headphones"]); + }); + + it("also works on non-Name string properties (color)", () => { + const condition = { + propertyId: 1, + operatorId: "contains" as const, + value: "lac", + }; + const matches = products + .filter((p) => evaluateCondition(p, condition, propertyColor)) + .map(nameOf); + expect(matches.sort()).toEqual(["Cell Phone", "Headphones"]); + }); + }); +}); + +describe("filterProducts", () => { + it("returns every product when there is no condition (clear filter)", () => { + expect(filterProducts(products, null, properties)).toEqual(products); + expect(filterProducts(products, undefined, properties)).toEqual(products); + }); + + it("returns every product when the condition's property can't be resolved", () => { + const condition = { + propertyId: 999, + operatorId: "any" as const, + }; + expect(filterProducts(products, condition, properties)).toEqual(products); + }); + + it("filters down to matching products for a real condition", () => { + const condition = { + propertyId: 3, + operatorId: "equals" as const, + value: "electronics", + }; + const result = filterProducts(products, condition, properties); + expect(result.map(nameOf).sort()).toEqual([ + "Cell Phone", + "Headphones", + "Keyboard", + ]); + }); +}); + +describe("full property type x operator validity matrix (behavioral)", () => { + // These fixtures exist purely to exercise the exhaustive matrix from the + // README, independent of the `mockCatalog` data used above. + const stringProperty: Property = { id: 10, name: "Label", type: "string" }; + const numberProperty: Property = { id: 11, name: "Count", type: "number" }; + const enumProperty: Property = { + id: 12, + name: "Status", + type: "enumerated", + values: ["open", "closed"], + }; + + const withValue = (propertyId: number, value: string | number): Product => ({ + id: 1, + propertyValues: [{ propertyId, value }], + }); + const withoutValue = (): Product => ({ id: 2, propertyValues: [] }); + + it("string: equals, contains, in, any, none all behave correctly", () => { + const present = withValue(10, "hello world"); + const absent = withoutValue(); + + expect( + evaluateCondition( + present, + { propertyId: 10, operatorId: "equals", value: "hello world" }, + stringProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + present, + { propertyId: 10, operatorId: "contains", value: "world" }, + stringProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + present, + { propertyId: 10, operatorId: "in", value: ["nope", "hello world"] }, + stringProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + present, + { propertyId: 10, operatorId: "any" }, + stringProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + absent, + { propertyId: 10, operatorId: "none" }, + stringProperty, + ), + ).toBe(true); + }); + + it("number: equals, greater_than, less_than, in, any, none all behave correctly", () => { + const present = withValue(11, 10); + const absent = withoutValue(); + + expect( + evaluateCondition( + present, + { propertyId: 11, operatorId: "equals", value: 10 }, + numberProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + present, + { propertyId: 11, operatorId: "greater_than", value: 5 }, + numberProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + present, + { propertyId: 11, operatorId: "less_than", value: 20 }, + numberProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + present, + { propertyId: 11, operatorId: "in", value: [1, 10] }, + numberProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + present, + { propertyId: 11, operatorId: "any" }, + numberProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + absent, + { propertyId: 11, operatorId: "none" }, + numberProperty, + ), + ).toBe(true); + }); + + it("enumerated: equals, in, any, none all behave correctly (no contains/greater/less)", () => { + const present = withValue(12, "open"); + const absent = withoutValue(); + + expect( + evaluateCondition( + present, + { propertyId: 12, operatorId: "equals", value: "open" }, + enumProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + present, + { propertyId: 12, operatorId: "in", value: ["closed", "open"] }, + enumProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + present, + { propertyId: 12, operatorId: "any" }, + enumProperty, + ), + ).toBe(true); + expect( + evaluateCondition( + absent, + { propertyId: 12, operatorId: "none" }, + enumProperty, + ), + ).toBe(true); + }); +}); diff --git a/src/domain/filter.ts b/src/domain/filter.ts new file mode 100644 index 0000000..d748b39 --- /dev/null +++ b/src/domain/filter.ts @@ -0,0 +1,101 @@ +import type { OperatorId, Product, Property } from "../api/types"; + +export type ConditionValue = string | number | (string | number)[] | undefined; + +export interface Condition { + propertyId: number; + operatorId: OperatorId; + value?: ConditionValue; +} + +function getRawValue( + product: Product, + propertyId: number, +): string | number | undefined { + return product.propertyValues.find((pv) => pv.propertyId === propertyId) + ?.value; +} + +function toNumber(value: string | number): number { + return typeof value === "number" ? value : Number(value); +} + +/** + * Evaluates a single condition against a single product for the property it + * targets. `property` must be the `Property` definition that + * `condition.propertyId` refers to (its `type` drives how values are + * compared — e.g. numeric vs. string comparison for `equals`/`in`). + */ +export function evaluateCondition( + product: Product, + condition: Condition, + property: Property, +): boolean { + const raw = getRawValue(product, condition.propertyId); + + switch (condition.operatorId) { + case "any": + return raw !== undefined; + + case "none": + return raw === undefined; + + case "equals": { + if (raw === undefined || condition.value === undefined) return false; + if (property.type === "number") { + return toNumber(raw) === toNumber(condition.value as string | number); + } + return String(raw) === String(condition.value); + } + + case "contains": { + if (raw === undefined || condition.value === undefined) return false; + // Case-insensitive substring match — see SOLUTION.md "Assumptions". + return String(raw) + .toLowerCase() + .includes(String(condition.value).toLowerCase()); + } + + case "greater_than": { + if (raw === undefined || condition.value === undefined) return false; + return toNumber(raw) > toNumber(condition.value as string | number); + } + + case "less_than": { + if (raw === undefined || condition.value === undefined) return false; + return toNumber(raw) < toNumber(condition.value as string | number); + } + + case "in": { + if (raw === undefined) return false; + const candidates = Array.isArray(condition.value) ? condition.value : []; + if (candidates.length === 0) return false; + if (property.type === "number") { + const rawNumber = toNumber(raw); + return candidates.some((candidate) => toNumber(candidate) === rawNumber); + } + return candidates.some((candidate) => String(candidate) === String(raw)); + } + + default: + return false; + } +} + +/** + * Filters `products` down to those matching `condition`, or returns them + * unchanged when there is no condition (the "clear filter" state) or the + * condition's property can't be resolved. + */ +export function filterProducts( + products: Product[], + condition: Condition | null | undefined, + properties: Property[], +): Product[] { + if (!condition) return products; + const property = properties.find((p) => p.id === condition.propertyId); + if (!property) return products; + return products.filter((product) => + evaluateCondition(product, condition, property), + ); +} diff --git a/src/domain/operators.test.ts b/src/domain/operators.test.ts new file mode 100644 index 0000000..bee013a --- /dev/null +++ b/src/domain/operators.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + getValidOperatorIds, + isOperatorValidForType, + operatorTakesMultipleValues, + operatorTakesValue, +} from "./operators"; +import type { OperatorId } from "../api/types"; + +const ALL_OPERATORS: OperatorId[] = [ + "equals", + "greater_than", + "less_than", + "any", + "none", + "in", + "contains", +]; + +describe("getValidOperatorIds", () => { + it("string: equals, contains, any, none, in — no greater_than/less_than", () => { + expect(new Set(getValidOperatorIds("string"))).toEqual( + new Set(["equals", "contains", "any", "none", "in"]), + ); + }); + + it("number: equals, greater_than, less_than, any, none, in — no contains", () => { + expect(new Set(getValidOperatorIds("number"))).toEqual( + new Set(["equals", "greater_than", "less_than", "any", "none", "in"]), + ); + }); + + it("enumerated: equals, any, none, in — no contains/greater_than/less_than", () => { + expect(new Set(getValidOperatorIds("enumerated"))).toEqual( + new Set(["equals", "any", "none", "in"]), + ); + }); +}); + +describe("isOperatorValidForType — full matrix", () => { + const expected: Record> = { + equals: { string: true, number: true, enumerated: true }, + greater_than: { string: false, number: true, enumerated: false }, + less_than: { string: false, number: true, enumerated: false }, + any: { string: true, number: true, enumerated: true }, + none: { string: true, number: true, enumerated: true }, + in: { string: true, number: true, enumerated: true }, + contains: { string: true, number: false, enumerated: false }, + }; + + for (const operatorId of ALL_OPERATORS) { + for (const type of ["string", "number", "enumerated"] as const) { + it(`${operatorId} x ${type} => ${expected[operatorId][type]}`, () => { + expect(isOperatorValidForType(operatorId, type)).toBe( + expected[operatorId][type], + ); + }); + } + } +}); + +describe("operatorTakesValue", () => { + it("any and none take no value", () => { + expect(operatorTakesValue("any")).toBe(false); + expect(operatorTakesValue("none")).toBe(false); + }); + + it("every other operator takes a value", () => { + for (const operatorId of ALL_OPERATORS) { + if (operatorId === "any" || operatorId === "none") continue; + expect(operatorTakesValue(operatorId)).toBe(true); + } + }); +}); + +describe("operatorTakesMultipleValues", () => { + it("only 'in' takes multiple values", () => { + for (const operatorId of ALL_OPERATORS) { + expect(operatorTakesMultipleValues(operatorId)).toBe( + operatorId === "in", + ); + } + }); +}); diff --git a/src/domain/operators.ts b/src/domain/operators.ts new file mode 100644 index 0000000..3413314 --- /dev/null +++ b/src/domain/operators.ts @@ -0,0 +1,41 @@ +import type { OperatorId, PropertyType } from "../api/types"; + +/** + * The validity matrix from the exercise README: + * + * | Property Type | Valid Operators | + * | -------------- | ------------------------------------------------- | + * | string | equals, contains, any, none, in | + * | number | equals, greater_than, less_than, any, none, in | + * | enumerated | equals, any, none, in | + * + * `contains`, `greater_than`, and `less_than` never apply to `enumerated`; + * `greater_than`/`less_than` never apply to `string`; `contains` never + * applies to `number`. + */ +const VALID_OPERATORS_BY_TYPE: Record = { + string: ["equals", "contains", "any", "none", "in"], + number: ["equals", "greater_than", "less_than", "any", "none", "in"], + enumerated: ["equals", "any", "none", "in"], +}; + +export function getValidOperatorIds(type: PropertyType): OperatorId[] { + return VALID_OPERATORS_BY_TYPE[type]; +} + +export function isOperatorValidForType( + operatorId: OperatorId, + type: PropertyType, +): boolean { + return VALID_OPERATORS_BY_TYPE[type].includes(operatorId); +} + +/** Operators that take no value at all ("Has any value" / "Has no value"). */ +export function operatorTakesValue(operatorId: OperatorId): boolean { + return operatorId !== "any" && operatorId !== "none"; +} + +/** Operators whose value is a list of values ("Is any of"). */ +export function operatorTakesMultipleValues(operatorId: OperatorId): boolean { + return operatorId === "in"; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..f20f824 --- /dev/null +++ b/src/index.css @@ -0,0 +1,30 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + + --sans: system-ui, "Segoe UI", Roboto, sans-serif; + + font: 16px/145% var(--sans); + color-scheme: light dark; + color: var(--text-h); + background: var(--bg); +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + } +} + +body { + margin: 0; +} + +h1 { + color: var(--text-h); +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..1528fcf --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; + +async function enableMocking() { + // The mocked GraphQL API only needs to run in dev — production builds + // would point `GRAPHQL_ENDPOINT` at a real server, and tests wire up + // their own MSW `setupServer` instance (see `setupTests.ts`). + if (!import.meta.env.DEV) return; + const { worker } = await import("./mocks/browser"); + return worker.start({ onUnhandledRequest: "bypass" }); +} + +enableMocking().then(() => { + createRoot(document.getElementById("root")!).render( + + + , + ); +}); diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts new file mode 100644 index 0000000..6030809 --- /dev/null +++ b/src/mocks/browser.ts @@ -0,0 +1,5 @@ +import { setupWorker } from "msw/browser"; +import { handlers } from "./handlers"; + +/** Used only in the Vite dev server (see `main.tsx`). */ +export const worker = setupWorker(...handlers); diff --git a/src/mocks/data.ts b/src/mocks/data.ts new file mode 100644 index 0000000..eee8255 --- /dev/null +++ b/src/mocks/data.ts @@ -0,0 +1,97 @@ +import type { CatalogData } from "../api/types"; + +/** + * Reimplements the dataset from `reference/datastore.js` behind the mocked + * GraphQL API (see `handlers.ts`). Field names are camelCased for GraphQL + * convention (`property_id` -> `propertyId`) but the ids, names, types, and + * values are otherwise identical — including products 3-5 (Cup, Key, + * Hammer) deliberately omitting a `wireless` property value, which is the + * dataset's test case for the `any`/`none` operators. + */ +export const mockCatalog: CatalogData = { + properties: [ + { id: 0, name: "Product Name", type: "string" }, + { id: 1, name: "color", type: "string" }, + { id: 2, name: "weight (oz)", type: "number" }, + { + id: 3, + name: "category", + type: "enumerated", + values: ["tools", "electronics", "kitchenware"], + }, + { id: 4, name: "wireless", type: "enumerated", values: ["true", "false"] }, + ], + + operators: [ + { id: "equals", text: "Equals" }, + { id: "greater_than", text: "Is greater than" }, + { id: "less_than", text: "Is less than" }, + { id: "any", text: "Has any value" }, + { id: "none", text: "Has no value" }, + { id: "in", text: "Is any of" }, + { id: "contains", text: "Contains" }, + ], + + products: [ + { + id: 0, + propertyValues: [ + { propertyId: 0, value: "Headphones" }, + { propertyId: 1, value: "black" }, + { propertyId: 2, value: 5 }, + { propertyId: 3, value: "electronics" }, + { propertyId: 4, value: "false" }, + ], + }, + { + id: 1, + propertyValues: [ + { propertyId: 0, value: "Cell Phone" }, + { propertyId: 1, value: "black" }, + { propertyId: 2, value: 3 }, + { propertyId: 3, value: "electronics" }, + { propertyId: 4, value: "true" }, + ], + }, + { + id: 2, + propertyValues: [ + { propertyId: 0, value: "Keyboard" }, + { propertyId: 1, value: "grey" }, + { propertyId: 2, value: 5 }, + { propertyId: 3, value: "electronics" }, + { propertyId: 4, value: "false" }, + ], + }, + { + id: 3, + propertyValues: [ + { propertyId: 0, value: "Cup" }, + { propertyId: 1, value: "white" }, + { propertyId: 2, value: 3 }, + { propertyId: 3, value: "kitchenware" }, + // no `wireless` value on purpose + ], + }, + { + id: 4, + propertyValues: [ + { propertyId: 0, value: "Key" }, + { propertyId: 1, value: "silver" }, + { propertyId: 2, value: 1 }, + { propertyId: 3, value: "tools" }, + // no `wireless` value on purpose + ], + }, + { + id: 5, + propertyValues: [ + { propertyId: 0, value: "Hammer" }, + { propertyId: 1, value: "brown" }, + { propertyId: 2, value: 19 }, + { propertyId: 3, value: "tools" }, + // no `wireless` value on purpose + ], + }, + ], +}; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts new file mode 100644 index 0000000..067b52b --- /dev/null +++ b/src/mocks/handlers.ts @@ -0,0 +1,14 @@ +import { graphql, HttpResponse } from "msw"; +import { mockCatalog } from "./data"; + +/** + * MSW intercepts the `Catalog` GraphQL operation (matched by operation + * name, regardless of endpoint URL) and resolves it with the mock dataset. + * These handlers are shared between the browser worker (dev) and the node + * server (tests) so both exercise the exact same mocked API surface. + */ +export const handlers = [ + graphql.query("Catalog", () => { + return HttpResponse.json({ data: mockCatalog }); + }), +]; diff --git a/src/mocks/server.ts b/src/mocks/server.ts new file mode 100644 index 0000000..dbab645 --- /dev/null +++ b/src/mocks/server.ts @@ -0,0 +1,5 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +/** Used only under Vitest/node (see `setupTests.ts`). */ +export const server = setupServer(...handlers); diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..760cd23 --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,16 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { server } from "./mocks/server"; + +// Enable API mocking before all tests, reset any request handlers added in +// individual tests so they don't leak between tests, and clean up once +// tests are done. +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +// With `test.globals: false`, RTL's own auto-cleanup (which relies on a +// global `afterEach`) never registers, so each rendered component would +// otherwise leak into the next test. +afterEach(() => cleanup()); diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..41c1371 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./src/setupTests.ts"], + globals: false, + css: true, + }, +}); From d580c88054b55a78d1db367360307dda9e33295e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:26:15 +0000 Subject: [PATCH 3/5] Only apply the product filter once a condition is fully set filterProducts previously ran evaluateCondition as soon as a property was selected (ConditionEditor auto-fills a default operator), which for value-requiring operators like equals/contains/greater_than/less_than/in matched nothing until a value was typed - showing an empty or wrongly narrowed list instead of the full catalog. Add isConditionComplete() to domain/filter.ts: true immediately for any/none (no value needed), and only once a non-empty value (or non-empty list for `in`) is present for the other operators. filterProducts now short-circuits to the unfiltered list until the condition is complete, so partial conditions - and conditions cleared back to partial, e.g. by changing the operator - show the full list, matching the existing clear-filter behavior. --- src/App.test.tsx | 91 +++++++++++++++++++++++++ src/domain/filter.test.ts | 140 +++++++++++++++++++++++++++++++++++++- src/domain/filter.ts | 30 +++++++- 3 files changed, 258 insertions(+), 3 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 9394ad1..8d5066b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -38,6 +38,58 @@ describe("App", () => { ); }); + it("does not filter until the condition is fully set (property only)", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "weight (oz)", + ); + + // Property selected, but no operator chosen by the user yet (the + // default operator is pre-filled with no value) — full list still. + expect(getProductNames().sort()).toEqual( + [ + "Cell Phone", + "Cup", + "Hammer", + "Headphones", + "Key", + "Keyboard", + ].sort(), + ); + }); + + it("does not filter until a value-requiring operator's value is entered", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "weight (oz)", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Is greater than", + ); + + // Operator needs a value ("Is greater than") but none has been typed + // yet — still the full list, not an empty (or wrongly filtered) one. + expect(getProductNames().sort()).toEqual( + [ + "Cell Phone", + "Cup", + "Hammer", + "Headphones", + "Key", + "Keyboard", + ].sort(), + ); + }); + it("filters live as a numeric condition is built (weight > 4)", async () => { const user = userEvent.setup(); render(); @@ -180,4 +232,43 @@ describe("App", () => { screen.queryByRole("combobox", { name: "Operator" }), ).not.toBeInTheDocument(); }); + + it("restores the full list when a completed condition is changed back to an incomplete one", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "Product Name", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Equals", + ); + await user.type( + screen.getByRole("textbox", { name: "Value" }), + "Headphones", + ); + expect(getProductNames()).toEqual(["Headphones"]); + + // Switching to an operator that still needs a value (but hasn't got + // one yet) should behave like a partial condition again, not keep + // the previous filtered result around. + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Contains", + ); + + expect(getProductNames().sort()).toEqual( + [ + "Cell Phone", + "Cup", + "Hammer", + "Headphones", + "Key", + "Keyboard", + ].sort(), + ); + }); }); diff --git a/src/domain/filter.test.ts b/src/domain/filter.test.ts index 45afa0f..1f4e062 100644 --- a/src/domain/filter.test.ts +++ b/src/domain/filter.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { Product, Property } from "../api/types"; -import { evaluateCondition, filterProducts } from "./filter"; +import { + evaluateCondition, + filterProducts, + isConditionComplete, +} from "./filter"; import { mockCatalog } from "../mocks/data"; const { properties, products } = mockCatalog; @@ -260,6 +264,140 @@ describe("filterProducts", () => { }); }); +describe("isConditionComplete", () => { + it("is false for no condition", () => { + expect(isConditionComplete(null)).toBe(false); + expect(isConditionComplete(undefined)).toBe(false); + }); + + it("is false for a property-only condition awaiting a value-requiring operator's value", () => { + // Mirrors what ConditionEditor produces right after a property is + // selected: propertyId + a default operator, no value yet. + expect( + isConditionComplete({ propertyId: 0, operatorId: "equals" }), + ).toBe(false); + expect( + isConditionComplete({ + propertyId: 0, + operatorId: "equals", + value: undefined, + }), + ).toBe(false); + }); + + it("is false for an empty-string value", () => { + expect( + isConditionComplete({ + propertyId: 0, + operatorId: "contains", + value: "", + }), + ).toBe(false); + }); + + it("is false for 'in' with no values selected yet", () => { + expect(isConditionComplete({ propertyId: 0, operatorId: "in" })).toBe( + false, + ); + expect( + isConditionComplete({ propertyId: 0, operatorId: "in", value: [] }), + ).toBe(false); + }); + + it("is true for 'any'/'none' as soon as property + operator are set (no value needed)", () => { + expect(isConditionComplete({ propertyId: 4, operatorId: "any" })).toBe( + true, + ); + expect(isConditionComplete({ propertyId: 4, operatorId: "none" })).toBe( + true, + ); + }); + + it("is true once a value-requiring operator has a value", () => { + expect( + isConditionComplete({ + propertyId: 0, + operatorId: "equals", + value: "Headphones", + }), + ).toBe(true); + expect( + isConditionComplete({ + propertyId: 2, + operatorId: "greater_than", + value: 0, + }), + ).toBe(true); // 0 is a valid, "complete" numeric value + expect( + isConditionComplete({ + propertyId: 0, + operatorId: "in", + value: ["Headphones"], + }), + ).toBe(true); + }); +}); + +describe("filterProducts (condition completeness)", () => { + it("shows the full list when only a property is selected (no operator picked yet)", () => { + const condition = { propertyId: 0, operatorId: "equals" as const }; + expect(filterProducts(products, condition, properties)).toEqual(products); + }); + + it("shows the full list while a value-requiring operator is missing its value", () => { + const conditions = [ + { propertyId: 0, operatorId: "equals" as const }, + { propertyId: 0, operatorId: "contains" as const, value: "" }, + { propertyId: 2, operatorId: "greater_than" as const }, + { propertyId: 2, operatorId: "less_than" as const }, + { propertyId: 0, operatorId: "in" as const, value: [] }, + ]; + for (const condition of conditions) { + expect(filterProducts(products, condition, properties)).toEqual( + products, + ); + } + }); + + it("filters immediately for 'any'/'none' once property + operator are set", () => { + const anyCondition = { propertyId: 4, operatorId: "any" as const }; + const result = filterProducts(products, anyCondition, properties); + expect(result.map(nameOf).sort()).toEqual([ + "Cell Phone", + "Headphones", + "Keyboard", + ]); + }); + + it("filters once the condition becomes fully set (property + operator + value)", () => { + const condition = { + propertyId: 0, + operatorId: "equals" as const, + value: "Headphones", + }; + const result = filterProducts(products, condition, properties); + expect(result.map(nameOf)).toEqual(["Headphones"]); + }); + + it("restores the full list when a fully-set condition is cleared back to incomplete", () => { + const complete = { + propertyId: 0, + operatorId: "equals" as const, + value: "Headphones", + }; + expect(filterProducts(products, complete, properties).map(nameOf)).toEqual( + ["Headphones"], + ); + + // Clearing the operator (as ConditionEditor's operator reset does) + // drops the value too, going back to an incomplete condition. + const clearedOperator = { propertyId: 0, operatorId: "equals" as const }; + expect( + filterProducts(products, clearedOperator, properties), + ).toEqual(products); + }); +}); + describe("full property type x operator validity matrix (behavioral)", () => { // These fixtures exist purely to exercise the exhaustive matrix from the // README, independent of the `mockCatalog` data used above. diff --git a/src/domain/filter.ts b/src/domain/filter.ts index d748b39..7bea03a 100644 --- a/src/domain/filter.ts +++ b/src/domain/filter.ts @@ -1,4 +1,5 @@ import type { OperatorId, Product, Property } from "../api/types"; +import { operatorTakesMultipleValues, operatorTakesValue } from "./operators"; export type ConditionValue = string | number | (string | number)[] | undefined; @@ -82,10 +83,34 @@ export function evaluateCondition( } } +/** + * Whether `condition` has everything it needs to actually filter with: + * a property, an operator, and — for operators that take one (everything + * but `any`/`none`) — a value. Used to hold off filtering (showing the + * full list instead) while the user is still mid-way through building a + * condition, e.g. right after picking a property but before an operator + * needing a value has one entered. + */ +export function isConditionComplete( + condition: Condition | null | undefined, +): boolean { + if (!condition) return false; + if (!operatorTakesValue(condition.operatorId)) return true; + + if (operatorTakesMultipleValues(condition.operatorId)) { + return Array.isArray(condition.value) && condition.value.length > 0; + } + + if (condition.value === undefined) return false; + if (typeof condition.value === "string") return condition.value.length > 0; + return true; +} + /** * Filters `products` down to those matching `condition`, or returns them - * unchanged when there is no condition (the "clear filter" state) or the - * condition's property can't be resolved. + * unchanged when there is no condition (the "clear filter" state), the + * condition isn't complete enough to evaluate yet (see + * `isConditionComplete`), or the condition's property can't be resolved. */ export function filterProducts( products: Product[], @@ -93,6 +118,7 @@ export function filterProducts( properties: Property[], ): Product[] { if (!condition) return products; + if (!isConditionComplete(condition)) return products; const property = properties.find((p) => p.id === condition.propertyId); if (!property) return products; return products.filter((product) => From 236510656077c5c4b351b9a8cec9dc48ed2d1113 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:33:33 +0000 Subject: [PATCH 4/5] Fix typing commas into the `in` operator's value input; hide orphaned Value label ValueInput rendered the `in` operator's comma-separated text input's `value` as `parsedArray.join(", ")` instead of tracking the raw text the user was typing. Since parseStringList/parseNumberList trim and drop empty segments, a trailing or in-progress comma (e.g. "Headphones,") round-tripped through parse -> join and vanished the instant it was typed, making it look impossible to enter more than one value. ValueInput now keeps its own `listText` state as the source of truth for what's displayed, updating it directly from the input event while still calling onChange with the parsed array. ConditionEditor now keys ValueInput by `${property.id}-${operatorId}` so that state resets cleanly whenever the property or operator changes (matching the existing value-reset behavior), rather than carrying over stale text. Also: hide the "Value" field's label whenever operatorTakesValue(operatorId) is false (any/none), so no orphaned "Value" text is left rendered next to a value input that ValueInput itself already omits. Adds ValueInput/App tests covering: typing "Headphones," and "5," without the comma disappearing, the resulting filtered list once the list is complete, the free text resetting on operator/property change, and the "Value" label being absent (not just the input) for any/none operators. --- src/App.test.tsx | 92 ++++++++++++++++++++++++++++++ src/components/ConditionEditor.tsx | 32 +++++++---- src/components/ValueInput.tsx | 32 +++++++++-- 3 files changed, 138 insertions(+), 18 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 8d5066b..136e3e1 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -143,6 +143,10 @@ describe("App", () => { ); expect(screen.queryByLabelText("Value")).not.toBeInTheDocument(); + // The "Value" field label itself must also be gone — not just its + // input — so there's no orphaned "Value" text left dangling above a + // hidden input. + expect(screen.queryByText("Value")).not.toBeInTheDocument(); expect(getProductNames().sort()).toEqual( ["Cell Phone", "Headphones", "Keyboard"].sort(), ); @@ -151,6 +155,7 @@ describe("App", () => { screen.getByRole("combobox", { name: "Operator" }), "Has no value", ); + expect(screen.queryByText("Value")).not.toBeInTheDocument(); expect(getProductNames().sort()).toEqual(["Cup", "Hammer", "Key"].sort()); }); @@ -233,6 +238,93 @@ describe("App", () => { ).not.toBeInTheDocument(); }); + it("allows typing a comma-separated list into a string 'Is any of' value, including mid-typing commas", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "Product Name", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Is any of", + ); + + const valueInput = screen.getByRole("textbox", { name: "Value" }); + await user.type(valueInput, "Headphones,"); + + // The trailing comma the user just typed must still be visible — it + // must not be silently stripped mid-typing, which is what made typing + // a second value look impossible. + expect(valueInput).toHaveValue("Headphones,"); + + await user.type(valueInput, " Key"); + expect(valueInput).toHaveValue("Headphones, Key"); + + expect(getProductNames().sort()).toEqual(["Headphones", "Key"].sort()); + }); + + it("allows typing a comma-separated list into a number 'Is any of' value, including mid-typing commas", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "weight (oz)", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Is any of", + ); + + const valueInput = screen.getByRole("textbox", { name: "Value" }); + await user.type(valueInput, "5,"); + + expect(valueInput).toHaveValue("5,"); + + await user.type(valueInput, " 1"); + expect(valueInput).toHaveValue("5, 1"); + + expect(getProductNames().sort()).toEqual( + ["Headphones", "Keyboard", "Key"].sort(), + ); + }); + + it("resets the 'Is any of' free-text value when switching operators or properties", async () => { + const user = userEvent.setup(); + render(); + await waitForCatalog(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Property" }), + "Product Name", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Is any of", + ); + await user.type( + screen.getByRole("textbox", { name: "Value" }), + "Headphones, Key", + ); + + // Switching away and back to "Is any of" should not carry over the + // previous free text (the condition's value was reset to undefined). + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Contains", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Operator" }), + "Is any of", + ); + + expect(screen.getByRole("textbox", { name: "Value" })).toHaveValue(""); + }); + it("restores the full list when a completed condition is changed back to an incomplete one", async () => { const user = userEvent.setup(); render(); diff --git a/src/components/ConditionEditor.tsx b/src/components/ConditionEditor.tsx index 833a61a..f704659 100644 --- a/src/components/ConditionEditor.tsx +++ b/src/components/ConditionEditor.tsx @@ -1,6 +1,6 @@ import type { Operator, OperatorId, Property } from "../api/types"; import type { Condition, ConditionValue } from "../domain/filter"; -import { getValidOperatorIds } from "../domain/operators"; +import { getValidOperatorIds, operatorTakesValue } from "../domain/operators"; import { ValueInput } from "./ValueInput"; interface ConditionEditorProps { @@ -100,17 +100,25 @@ export function ConditionEditor({ )} - {selectedProperty && condition && ( -
- Value - -
- )} + {selectedProperty && + condition && + operatorTakesValue(condition.operatorId) && ( +
+ Value + +
+ )}