diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..815ba5e --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# GraphQL-endepunkt for frontend. +# La variabelen være tom ved lokal utvikling for å bruke Vite-proxyen. +# Ved produksjonsbygg kan den settes til /project2/graphql. +VITE_GRAPHQL_URL= \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4c09c27 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +coverage +playwright-report +test-results \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a06a385 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": false, + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 80 +} diff --git a/docs/frontend.md b/docs/frontend.md new file mode 100644 index 0000000..680682e --- /dev/null +++ b/docs/frontend.md @@ -0,0 +1,313 @@ +# Frontend Documentation + +## 1. Introduction + +The frontend of *Spillkveld* is developed using React and TypeScript, with Vite as +the development and build tool. + +The application allows users to browse a catalog of board games, search for specific games, +apply filters, sort results, and view detailed information about individual games. +Users can also create a personal selection of games, which is preserved in the browser +using local storage. + +The frontend uses Apollo Client to manage GraphQL queries and communicate with the backend. +During frontend development, mocked GraphQL responses are used to test functionality +independently of the backend implementation. + +## 2. Technology Stack + +The frontend uses the following technologies: + +- **React:** Component-based user interface. +- **TypeScript:** Static typing for improved maintainability and reliability. +- **Vite:** Development server and production build tool. +- **Apollo Client:** GraphQL queries, caching, and client-side data management. +- **GraphQL:** API interface between the frontend and backend. +- **Vitest:** Unit and component testing. +- **React Testing Library:** Testing user interactions and rendered components. +- **Prettier:** Consistent code formatting. +- **ESLint:** Static code analysis and linting. +- **Playwright:** Dependency and test command prepared for end-to-end testing. + +## 3. Frontend Architecture + +The frontend is organized into separate modules for the user interface, GraphQL operations, +application data, and utility functions. + +### 3.1 Project Structure + +The main frontend files and directories are: + +- `src/App.tsx` – Main application component, including the game catalog, search, filtering, + sorting, pagination, game details, and game selection. +- `src/main.tsx` – Application entry point and Apollo Provider setup. +- `src/lib/apollo.ts` – Apollo Client configuration and GraphQL endpoint. +- `src/graphql/queries.ts` – GraphQL queries used by the frontend. +- `src/graphql/types.ts` – TypeScript definitions for GraphQL data and variables. +- `src/graphql/mocks.ts` – Mocked GraphQL responses used during frontend development and testing. +- `src/data/games.ts` – Sample board game data used by the mock implementation. +- `src/lib/catalog.ts` – Catalog filtering, sorting, and pagination logic for mocked data. +- `src/lib/selection.ts` – Local storage utilities for managing the user's selected games. +- `src/test/setup.ts` – Test environment configuration. + +### 3.2 Apollo Client and GraphQL + +Apollo Client is configured in `src/lib/apollo.ts` and provided to the React application +through `ApolloProvider` in `src/main.tsx`. + +The frontend uses GraphQL queries to retrieve game information instead of accessing + the sample data directly from the UI components. + +The following operations are defined: + +- `GET_GAMES` – Retrieves a paginated game catalog with search, filtering, and sorting parameters. +- `GET_GAME` – Retrieves detailed information about a specific game. +- `GET_CATEGORIES` – Retrieves the available game categories. +- `GET_GAMES_BY_IDS` – Retrieves games belonging to the user's selection. + +The GraphQL endpoint can be configured through the `VITE_GRAPHQL_URL` environment +variable. During local development, the default `/graphql` path is forwarded to the +backend through the Vite development proxy. + +### 3.3 Mocked Data + +The frontend can be tested independently of the backend by using Apollo's `MockLink`. + +Mocked GraphQL responses are generated from the sample games in `src/data/games.ts`. +The mock implementation supports the same queries and input parameters expected +by the frontend. + +This allows frontend functionality + +## 4. User Interface and Features + +### 4.1 Game Catalog + +The main page displays a catalog of board games. Each game is presented with relevant +information, allowing users to browse the available games and open a detailed view. + +The catalog data is retrieved through the `GET_GAMES` GraphQL query using Apollo Client. + +The interface includes loading, error, and empty-result states to provide feedback +when data is being fetched, a request fails, or no games match the selected criteria. + +### 4.2 Search and Filtering + +Users can search for board games by entering text in the search field. + +The search input uses a 300-millisecond debounce to avoid sending a new query for +every keystroke. + +The catalog also supports filtering based on player count, maximum playing time, +and game category. + +These parameters are passed to the `GET_GAMES` GraphQL query, allowing the catalog +results to reflect the user's selected criteria. + +### 4.3 Sorting and Pagination + +Users can sort the game catalog by name or playing time. + +The catalog uses pagination, displaying six games per page. Users can navigate +between pages to browse additional results. + +Search, filtering, sorting, and pagination are handled through GraphQL query variables. + +### 4.4 Game Details + +Users can open a game to view additional information, including its description, +player count, playing time, and category. + +The detailed information is retrieved through the `GET_GAME` GraphQL query. + +The interface also handles loading and error states when retrieving game details. + +### 4.5 Game Selection + +Users can add games to a personal selection and remove games from it. + +The selection is stored in the browser's `localStorage`, allowing it to persist +across page reloads. + +The application stores the selected game IDs rather than complete game objects. +The corresponding game information is retrieved using the `GET_GAMES_BY_IDS` GraphQL query. + +This separates the user's locally stored selection from the game data provided +through GraphQL. + +## 5. Testing and Code Quality + +### 5.1 Unit and Component Testing + +The frontend uses Vitest and React Testing Library for automated testing. + +The tests cover both application logic and user interactions, including: + +- Searching for games and opening game details. +- Displaying an empty state when no games match the search. +- Adding and removing games from the selection. +- Persisting selected games using local storage. +- Navigating between catalog pages. +- Retrieving game details and categories through GraphQL. +- Handling GraphQL request failures and retrying failed requests. +- Catalog filtering, sorting, and pagination logic. + +Apollo's `MockLink` is used to provide controlled GraphQL responses during component + tests, allowing the frontend to be tested independently of a running backend. + +The test files include: + +- `src/App.test.tsx` +- `src/graphql/queries.test.tsx` +- `src/lib/catalog.test.ts` + +The current frontend test suite contains 11 tests. + +Run the tests using: + +```bash +npm test +``` + +### 5.2 Linting + +ESLint is used to identify potential code quality issues and enforce consistent +coding practices. + +Run ESLint using: + +```bash +npm run lint +``` + +### 5.3 Code Formatting + +Prettier is configured using `.prettierrc.json`, with excluded directories defined +in `.prettierignore`. + +To automatically format the frontend source files and Vite configuration, run: + +```bash +npm run format +``` + +To check formatting without modifying files, run: + +```bash +npm run format:check +``` + +### 5.4 Production Build + +The frontend is built using TypeScript and Vite. + +To verify TypeScript compilation and generate the production build, run: + +```bash +npm run build +``` + +### 5.5 End-to-End Testing + +Playwright has been added as a development dependency, and the following command +is available: + +```bash +npm run test:e2e +``` + +The implementation and configuration of Playwright end-to-end tests are handled +separately as part of the team's testing responsibilities. + +The presence of the command does not mean that the end-to-end tests have already +been implemented or executed successfully. + +## 6. Development and Configuration + +### 6.1 Local Development + +The frontend uses Vite as its development server. + +After cloning the repository, install the project dependencies: + +```bash +npm install +``` + +Start the development server: + +```bash +npm run dev +``` + +The development server runs on: + +`http://localhost:5173` + +The Vite configuration uses port `5173` with `strictPort` enabled, meaning the server + will not automatically switch to another port if it is occupied. + +### 6.2 GraphQL Endpoint + +Apollo Client is configured in `src/lib/apollo.ts`. + +By default, the frontend sends GraphQL requests to `/graphql`. During local development, +Vite proxies these requests to: + +`http://127.0.0.1:3001` + +This allows the frontend and backend to run on separate local ports without requiring +the frontend to use the backend address directly. + +The GraphQL endpoint can be overridden using the `VITE_GRAPHQL_URL` environment variable. + +An `.env.example` file is provided to document this configuration. + +### 6.3 Production Build and Base Path + +The Vite configuration uses the following base path: + +`/project2/` + +This allows the frontend to be deployed under a subdirectory instead of the root +of a web server. + +The production build is generated using: + +```bash +npm run build +``` + +The generated files are placed in the `dist` directory. + +For deployment under `/project2/`, the GraphQL endpoint must be configured to match +the backend routing. For example, if the backend is exposed at `/project2/graphql`, +klset `VITE_GRAPHQL_URL=/project2/graphql` before building the frontend. + +The final deployment configuration and backend routing must be coordinated with the +team member responsible for deployment. + +### 6.4 Frontend and Backend Integration + +The frontend defines GraphQL queries and TypeScript types for the expected API responses. + +During frontend development, mocked GraphQL responses allow the interface and tests +to work independently of the backend. + +Integration with the running backend requires the backend schema to support the +expected queries, input parameters, and response fields. + +Once the backend is available, the frontend should be tested against real GraphQL +responses to verify that the agreed API contract is implemented correctly. + +## AI Usage Declaration + +ChatGPT was used as a supporting tool during frontend development, mainly for +discussing implementation approaches, troubleshooting, reviewing code, and +suggesting improvements. + +AI was also used to help with React, TypeScript, Apollo Client, GraphQL +integration, automated tests, and documentation. The suggested solutions were +reviewed, adjusted where necessary, and tested to ensure they worked as +intended. All changes were verified using automated tests, ESLint, and +production builds. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index fc2f342..1d9cf3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,15 @@ "name": "spillkveld", "version": "0.1.0", "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^9.35.0", + "@playwright/test": "^1.64.0", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -24,10 +28,11 @@ "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", "jsdom": "^26.1.0", + "prettier": "^3.9.10", "typescript": "~5.9.2", "typescript-eslint": "^8.42.0", "vite": "^7.1.3", - "vitest": "^3.2.4" + "vitest": "^5.0.4" } }, "node_modules/@adobe/css-tools": { @@ -37,6 +42,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@apollo/client": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.3.tgz", + "integrity": "sha512-wMfOUaFKLgNsu0OMCg50gfhEHTV9/GyO7DxQvut7iSBUoePy0kvll3oWeY8KVPRfnVhYEzcwPJ/Jwz4uSe3fQA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "optimism": "^0.18.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "graphql": "^16.0.0 || ^17.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "rxjs": "^7.3.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1064,6 +1106,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "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/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1197,6 +1248,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2075,40 +2142,24 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2119,75 +2170,62 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", - "dev": true, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "tslib": "^2.3.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=8" } }, - "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", - "dev": true, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" + "tslib": "^2.3.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=8" } }, - "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", - "dev": true, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", "license": "MIT", "dependencies": { - "tinyspy": "^4.0.3" + "tslib": "^2.3.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=8" } }, - "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", - "dev": true, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "tslib": "^2.3.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=8" } }, "node_modules/acorn": { @@ -2359,16 +2397,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2401,18 +2429,11 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2434,16 +2455,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2560,16 +2571,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2616,9 +2617,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "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" }, @@ -3016,6 +3017,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^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/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3328,13 +3353,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3357,13 +3375,13 @@ } }, "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==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/min-indent": { @@ -3439,6 +3457,32 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3535,23 +3579,6 @@ "node": ">=8" } }, - "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/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3572,6 +3599,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -3611,6 +3667,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -3767,6 +3839,15 @@ "dev": true, "license": "MIT" }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3826,13 +3907,6 @@ "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -3843,17 +3917,10 @@ "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/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -3883,26 +3950,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3924,18 +3971,24 @@ "license": "MIT" }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -3954,36 +4007,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", - "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -4043,6 +4066,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4217,89 +4246,73 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@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": { @@ -4310,6 +4323,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -4391,20 +4407,16 @@ } }, "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==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { diff --git a/package.json b/package.json index 9efc2c1..241d09c 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,21 @@ "build": "tsc -b && vite build", "preview": "vite preview --host 0.0.0.0", "lint": "eslint .", - "test": "vitest run" + "test": "vitest run", + "format": "prettier --write \"src/**/*.{ts,tsx}\" \"vite.config.ts\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"vite.config.ts\"", + "test:e2e": "playwright test" }, "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^9.35.0", + "@playwright/test": "^1.64.0", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -27,9 +34,10 @@ "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", "jsdom": "^26.1.0", + "prettier": "^3.9.10", "typescript": "~5.9.2", "typescript-eslint": "^8.42.0", "vite": "^7.1.3", - "vitest": "^3.2.4" + "vitest": "^5.0.4" } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 9ddf0a9..0fc6e5b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,45 +1,278 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { cleanup, render, screen, within } from '@testing-library/react' +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { + ApolloClient, + ApolloLink, + InMemoryCache, + Observable, +} from '@apollo/client' +import { ApolloProvider } from '@apollo/client/react' import App from './App' +import { createCatalogMockLink } from './graphql/mocks' beforeEach(() => localStorage.clear()) afterEach(cleanup) +// Gir App en Apollo Client med simulerte GraphQL-svar. +function renderApp(link: ApolloLink = createCatalogMockLink()) { + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }) + + return render( + + + , + ) +} + describe('Spillkveld', () => { it('keeps search state when opening and closing a game', async () => { const user = userEvent.setup() - render() - await user.type(screen.getByRole('searchbox', { name: 'Søk etter spill' }), 'Azul') - expect(screen.getByRole('status')).toHaveTextContent('1 spill funnet') + renderApp() + + await user.type( + screen.getByRole('searchbox', { name: 'Søk etter spill' }), + 'Azul', + ) + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent('1 spill funnet') + }) + await user.click(screen.getByRole('button', { name: 'Azul' })) - expect(screen.getByRole('heading', { name: 'Azul', level: 1 })).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: /Tilbake til resultatene/ })) - expect(screen.getByRole('searchbox', { name: 'Søk etter spill' })).toHaveValue('Azul') + + expect( + await screen.findByRole('heading', { name: 'Azul', level: 1 }), + ).toBeInTheDocument() + + await user.click( + screen.getByRole('button', { name: /Tilbake til resultatene/ }), + ) + + expect( + screen.getByRole('searchbox', { name: 'Søk etter spill' }), + ).toHaveValue('Azul') + expect(screen.getByRole('button', { name: 'Azul' })).toHaveFocus() }) it('shows an actionable empty state', async () => { const user = userEvent.setup() - render() - await user.type(screen.getByRole('searchbox', { name: 'Søk etter spill' }), 'finnesikke') - expect(screen.getByRole('heading', { name: 'Ingen spill passer søket' })).toBeInTheDocument() + renderApp() + + await user.type( + screen.getByRole('searchbox', { name: 'Søk etter spill' }), + 'finnesikke', + ) + + expect( + await screen.findByRole('heading', { + name: 'Ingen spill passer søket', + }), + ).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Vis alle spill' })) - expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') + }) }) it('adds, persists and removes a game from the selection', async () => { const user = userEvent.setup() - const view = render() - await user.click(screen.getByRole('button', { name: 'Legg til Azul i Kveldens utvalg' })) - const selection = screen.getByRole('region', { name: /Kveldens utvalg/ }) - expect(within(selection).getByText('Azul')).toBeInTheDocument() - expect(JSON.parse(localStorage.getItem('spillkveld-selection-v1') ?? '[]')).toEqual(['azul']) + const view = renderApp() + + // Legg til Azul i utvalget. + await user.click( + await screen.findByRole('button', { + name: 'Legg til Azul i Kveldens utvalg', + }), + ) + + const selection = screen.getByRole('region', { + name: /Kveldens utvalg/, + }) + + // Vent på GraphQL-responsen. + expect(await within(selection).findByText('Azul')).toBeInTheDocument() + + // Kontroller at ID-en er lagret. + expect( + JSON.parse(localStorage.getItem('spillkveld-selection-v1') ?? '[]'), + ).toEqual(['azul']) + + // Simuler at appen åpnes på nytt. view.unmount() - render() - const restored = screen.getByRole('region', { name: /Kveldens utvalg/ }) - expect(within(restored).getByText('Azul')).toBeInTheDocument() - await user.click(within(restored).getByRole('button', { name: 'Fjern Azul fra Kveldens utvalg' })) - expect(within(restored).queryByText('Azul')).not.toBeInTheDocument() + renderApp() + + const restored = screen.getByRole('region', { + name: /Kveldens utvalg/, + }) + + // Vent på at GraphQL henter det lagrede spillet. + expect(await within(restored).findByText('Azul')).toBeInTheDocument() + + // Fjern Azul fra utvalget. + await user.click( + within(restored).getByRole('button', { + name: 'Fjern Azul fra Kveldens utvalg', + }), + ) + + await waitFor(() => { + expect(within(restored).queryByText('Azul')).not.toBeInTheDocument() + }) + + expect( + JSON.parse(localStorage.getItem('spillkveld-selection-v1') ?? '[]'), + ).toEqual([]) + }) + + it('paginates games using GraphQL', async () => { + const user = userEvent.setup() + renderApp() + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') + }) + + expect(screen.getAllByText('Side 1 av 2').length).toBeGreaterThan(0) + + await user.click(screen.getByRole('button', { name: /Neste/ })) + + await waitFor(() => { + expect(screen.getAllByText('Side 2 av 2').length).toBeGreaterThan(0) + }) + + expect(await screen.findByRole('button', { name: /Forrige/ })).toBeEnabled() + }) + + it('loads game details through GraphQL', async () => { + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: 'Azul' })) + + expect( + await screen.findByRole('heading', { + name: 'Azul', + level: 1, + }), + ).toBeInTheDocument() + }) + + it('focuses the game heading after details have loaded', async () => { + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: 'Azul' })) + + const heading = await screen.findByRole('heading', { + name: 'Azul', + level: 1, + }) + + await waitFor(() => { + expect(heading).toHaveFocus() + }) + }) + + it('shows a not found message when GraphQL returns game null', async () => { + const user = userEvent.setup() + + const notFoundLink = new ApolloLink((operation, forward) => { + if (operation.operationName === 'GetGame') { + return new Observable((observer) => { + observer.next({ data: { game: null } }) + observer.complete() + }) + } + + return forward(operation) + }) + + renderApp(ApolloLink.from([notFoundLink, createCatalogMockLink()])) + + await user.click(await screen.findByRole('button', { name: 'Azul' })) + + expect( + await screen.findByRole('heading', { + name: 'Spillet ble ikke funnet', + }), + ).toBeInTheDocument() + + expect( + screen.queryByRole('heading', { + name: 'Kunne ikke hente spilldetaljer', + }), + ).not.toBeInTheDocument() + + await user.click( + screen.getByRole('button', { name: /Tilbake til resultatene/ }), + ) + + expect( + await screen.findByRole('button', { name: 'Azul' }), + ).toBeInTheDocument() + }) + + it('loads categories through GraphQL', async () => { + renderApp() + + const categorySelect = screen.getByRole('combobox', { + name: /Kategori/, + }) + + await waitFor(() => { + expect( + within(categorySelect).getAllByRole('option').length, + ).toBeGreaterThan(1) + }) + }) + + it('shows an error and retries loading games', async () => { + const user = userEvent.setup() + let attempts = 0 + + const retryLink = new ApolloLink((operation, forward) => { + if (operation.operationName === 'GetGames') { + attempts += 1 + + if (attempts === 1) { + return new Observable((observer) => { + observer.error(new Error('Simulert nettverksfeil')) + }) + } + } + + return forward(operation) + }) + + const link = ApolloLink.from([retryLink, createCatalogMockLink()]) + + renderApp(link) + + expect( + await screen.findByRole('heading', { + name: 'Kunne ikke hente spill', + }), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Prøv igjen' })) + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent('12 spill funnet') + }) + + expect(attempts).toBe(2) }) }) diff --git a/src/App.tsx b/src/App.tsx index 0ffa795..f57415f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,71 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { games, type Game } from './data/games' -import { filterGames, pageGames, PAGE_SIZE, type Filters } from './lib/catalog' +import { useEffect, useRef, useState } from 'react' +import type { Game } from './data/games' +import { PAGE_SIZE, type Filters } from './lib/catalog' import { readSelection, saveSelection } from './lib/selection' +import { useQuery } from '@apollo/client/react' +import { + GET_CATEGORIES, + GET_GAME, + GET_GAMES, + GET_GAMES_BY_IDS, +} from './graphql/queries' +import type { + CategoriesData, + GameData, + GameVariables, + GamesByIdsData, + GamesByIdsVariables, + GamesData, + GamesVariables, +} from './graphql/types' -const initialFilters: Filters = { query: '', players: null, maxMinutes: null, category: '', sort: 'name' } -const categories = [...new Set(games.map((game) => game.category))].sort((a, b) => a.localeCompare(b, 'no')) +const initialFilters: Filters = { + query: '', + players: null, + maxMinutes: null, + category: '', + sort: 'name', +} +function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timeout = window.setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => window.clearTimeout(timeout) + }, [value, delay]) + + return debouncedValue +} function GameArt({ game }: { game: Game }) { - return + return ( + + ) } -function SelectionButton({ game, selected, onToggle }: { game: Game; selected: boolean; onToggle: (id: string) => void }) { +function SelectionButton({ + game, + selected, + onToggle, +}: { + game: Game + selected: boolean + onToggle: (id: string) => void +}) { return ( - ) } @@ -22,24 +73,77 @@ function SelectionButton({ game, selected, onToggle }: { game: Game; selected: b export default function App() { const [filters, setFilters] = useState(initialFilters) const [page, setPage] = useState(1) + + const debouncedQuery = useDebouncedValue(filters.query, 300) + + const { data, previousData, loading, error, refetch } = useQuery< + GamesData, + GamesVariables + >(GET_GAMES, { + variables: { + input: { + query: debouncedQuery, + players: filters.players, + maxMinutes: filters.maxMinutes, + category: filters.category, + sort: filters.sort === 'name' ? 'NAME' : 'TIME', + page, + pageSize: PAGE_SIZE, + }, + }, + }) + + const { + data: categoriesData, + loading: categoriesLoading, + error: categoriesError, + } = useQuery(GET_CATEGORIES) + + const categories = categoriesData?.categories ?? [] + const [selectedId, setSelectedId] = useState(null) - const [selection, setSelection] = useState(() => readSelection(games.map((game) => game.id))) + + const { + data: detailData, + loading: detailLoading, + error: detailError, + } = useQuery(GET_GAME, { + variables: { id: selectedId ?? '' }, + skip: selectedId === null, + }) + + const [selection, setSelection] = useState(() => readSelection()) + + const { + data: selectionData, + loading: selectionLoading, + error: selectionError, + } = useQuery(GET_GAMES_BY_IDS, { + variables: { ids: selection }, + skip: selection.length === 0, + }) + const headingRef = useRef(null) const returningGameRef = useRef(null) - const results = useMemo(() => filterGames(games, filters), [filters]) - const pageCount = Math.max(1, Math.ceil(results.length / PAGE_SIZE)) - const visibleGames = pageGames(results, page) - const detail = games.find((game) => game.id === selectedId) - const selectedGames = selection.map((id) => games.find((game) => game.id === id)).filter((game): game is Game => Boolean(game)) + const totalCount = + data?.games.totalCount ?? previousData?.games.totalCount ?? 0 + const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) + const visibleGames = data?.games.items ?? [] + const detail = selectedId ? detailData?.game : null + + const selectedGames = selectionData?.gamesByIds ?? [] useEffect(() => { - if (selectedId) headingRef.current?.focus() - else if (returningGameRef.current) { + if (selectedId) { + if (!detailLoading && detail) { + headingRef.current?.focus() + } + } else if (returningGameRef.current) { document.getElementById(`open-${returningGameRef.current}`)?.focus() returningGameRef.current = null } - }, [selectedId]) + }, [selectedId, detailLoading, detail]) function updateFilters(patch: Partial) { setFilters((current) => ({ ...current, ...patch })) @@ -47,7 +151,10 @@ export default function App() { } function toggleSelection(id: string) { - const next = selection.includes(id) ? selection.filter((item) => item !== id) : [...selection, id] + const next = selection.includes(id) + ? selection.filter((item) => item !== id) + : [...selection, id] + setSelection(next) saveSelection(next) } @@ -59,82 +166,456 @@ export default function App() { return (
- Hopp til innhold + + Hopp til innhold + +
- - Kveldens utvalg {selection.length} + + + Kveldens utvalg {selection.length} +
- {detail ? ( -
- -
- -
- {detail.category} · Eksempeldata -

{detail.name}

-

{detail.description}

-
-
Spillere
{detail.minPlayers === detail.maxPlayers ? detail.minPlayers : `${detail.minPlayers}–${detail.maxPlayers}`}
-
Spilletid
Ca. {detail.minutes} min
-
Kategori
{detail.category}
-
- -
+ {selectedId ? ( + detailLoading ? ( +
+

Laster spilldetaljer …

+
+ ) : detailError ? ( +
+

Kunne ikke hente spilldetaljer

+
-
+ ) : !detail ? ( +
+

Spillet ble ikke funnet

+ +
+ ) : ( +
+ + +
+ + +
+ + {detail.category} · Eksempeldata + + +

+ {detail.name} +

+ +

{detail.description}

+ +
+
+
Spillere
+
+ {detail.minPlayers === detail.maxPlayers + ? detail.minPlayers + : `${detail.minPlayers}–${detail.maxPlayers}`} +
+
+ +
+
Spilletid
+
Ca. {detail.minutes} min
+
+ +
+
Kategori
+
{detail.category}
+
+
+ + +
+
+
+ ) ) : ( <>
- ✳ Finn noe fint å spille -

Neste spillkveld starter her.

-

Finn et spill som passer gjengen og tiden dere har. Samle favorittene til kvelden på ett sted.

+ + ✳ Finn noe fint å spille + + +

+ Neste spillkveld starter her. +

+ +

+ Finn et spill som passer gjengen og tiden dere har. Samle + favorittene til kvelden på ett sted. +

+
+ + -
-
Utforsk katalogen

Finn deres neste favoritt

Statiske eksempeldata
+
+
+ Utforsk katalogen +

Finn deres neste favoritt

+
+ + Statiske eksempeldata +
+
- -
updateFilters({ query: event.target.value })} />
+ + +
+ + + updateFilters({ query: event.target.value }) + } + /> +
+
-
-
-
-
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
- + +
-

{results.length === 1 ? '1 spill funnet' : `${results.length} spill funnet`}

{results.length > 0 && Side {page} av {pageCount}}
- {results.length === 0 ? ( -

Ingen spill passer søket

Prøv et annet ord eller endre filtrene.

+
+

+ {totalCount === 1 + ? '1 spill funnet' + : `${totalCount} spill funnet`} +

+ + {totalCount > 0 && ( + + Side {page} av {pageCount} + + )} +
+ + {loading ? ( +
+

Laster spill …

+
+ ) : error ? ( +
+

Kunne ikke hente spill

+

Noe gikk galt under henting av spill.

+ +
+ ) : totalCount === 0 ? ( +
+ +

Ingen spill passer søket

+

Prøv et annet ord eller endre filtrene.

+ + +
) : ( <> -
{visibleGames.map((game) =>
- -
{game.category}

{game.description}

♟ {game.minPlayers === game.maxPlayers ? game.minPlayers : `${game.minPlayers}–${game.maxPlayers}`} spillere◷ {game.minutes} min
-
)}
- {pageCount > 1 && } +
+ {visibleGames.map((game) => ( +
+ + +
+ {game.category} + +

+ +

+ +

{game.description}

+ +
+ + ♟{' '} + {game.minPlayers === game.maxPlayers + ? game.minPlayers + : `${game.minPlayers}–${game.maxPlayers}`}{' '} + spillere + + ◷ {game.minutes} min +
+ + +
+
+ ))} +
+ + {pageCount > 1 && ( + + )} )}
)} -
-
Din liste

Kveldens utvalg ({selection.length})

Lagret i denne nettleseren

- {selectedGames.length === 0 ?

Ingen spill lagt til ennå. Velg noen favoritter fra katalogen over.

:
    {selectedGames.map((game) =>
  • {game.name}{game.minPlayers === game.maxPlayers ? game.minPlayers : `${game.minPlayers}–${game.maxPlayers}`} spillere · {game.minutes} min
  • )}
} +
+
+
+ Din liste +

+ Kveldens utvalg ({selection.length}) +

+
+

Lagret i denne nettleseren

+
+ + {selectionLoading ? ( +
+

Laster utvalgte spill …

+
+ ) : selectionError ? ( +
+

Kunne ikke hente utvalgte spill.

+
+ ) : selectedGames.length === 0 ? ( +
+ +

+ Ingen spill lagt til ennå. Velg noen favoritter fra katalogen + over. +

+
+ ) : ( +
    + {selectedGames.map((game) => ( +
  • + + +
    + {game.name} + + {game.minPlayers === game.maxPlayers + ? game.minPlayers + : `${game.minPlayers}–${game.maxPlayers}`}{' '} + spillere · {game.minutes} min + +
    + + +
  • + ))} +
+ )}
-
spillkveld.

En prototype med statiske eksempeldata · IT2810

+ +
+
+ + spillkveld. + +

En prototype med statiske eksempeldata · IT2810

+
+
) } diff --git a/src/data/games.ts b/src/data/games.ts index fd59ee5..64cc01e 100644 --- a/src/data/games.ts +++ b/src/data/games.ts @@ -13,16 +13,140 @@ export type Game = { } export const games: Game[] = [ - { id: 'azul', name: 'Azul', description: 'Legg fargerike fliser og bygg et mønster som gir flest poeng.', minPlayers: 2, maxPlayers: 4, minutes: 40, category: 'Strategi', icon: '▦', tone: 'blue' }, - { id: 'codenames', name: 'Codenames', description: 'Gi smarte ledetråder og finn lagets hemmelige ord.', minPlayers: 2, maxPlayers: 8, minutes: 20, category: 'Selskap', icon: '✳', tone: 'purple' }, - { id: 'carcassonne', name: 'Carcassonne', description: 'Bygg landskap med brikker og plasser følgere på byer og veier.', minPlayers: 2, maxPlayers: 5, minutes: 45, category: 'Strategi', icon: '⌂', tone: 'green' }, - { id: 'dixit', name: 'Dixit', description: 'Fortell en liten historie til et bilde og gjett hvilket kort som er riktig.', minPlayers: 3, maxPlayers: 6, minutes: 30, category: 'Kreativt', icon: '✦', tone: 'pink' }, - { id: 'ticket-to-ride', name: 'Ticket to Ride', description: 'Bygg togruter mellom byer og fullfør reisemålene dine.', minPlayers: 2, maxPlayers: 5, minutes: 60, category: 'Familie', icon: '↗', tone: 'orange' }, - { id: 'pandemic', name: 'Pandemic', description: 'Samarbeid om å stoppe utbrudd før tiden renner ut.', minPlayers: 2, maxPlayers: 4, minutes: 45, category: 'Samarbeid', icon: '✚', tone: 'teal' }, - { id: 'splendor', name: 'Splendor', description: 'Samle edelstener, kjøp kort og bygg en verdifull samling.', minPlayers: 2, maxPlayers: 4, minutes: 30, category: 'Strategi', icon: '◆', tone: 'purple' }, - { id: 'just-one', name: 'Just One', description: 'Skriv ett hint hver og hjelp en spiller å gjette ordet.', minPlayers: 3, maxPlayers: 7, minutes: 20, category: 'Samarbeid', icon: '☝', tone: 'yellow' }, - { id: 'kingdomino', name: 'Kingdomino', description: 'Velg dominobrikker og sett sammen ditt eget kongerike.', minPlayers: 2, maxPlayers: 4, minutes: 25, category: 'Familie', icon: '♛', tone: 'green' }, - { id: '7-wonders', name: '7 Wonders', description: 'Bygg en sivilisasjon med kort og samle ressurser over tre epoker.', minPlayers: 3, maxPlayers: 7, minutes: 30, category: 'Strategi', icon: '◭', tone: 'orange' }, - { id: 'the-crew', name: 'The Crew', description: 'Løs oppdrag sammen ved å spille kort uten å si for mye.', minPlayers: 3, maxPlayers: 5, minutes: 20, category: 'Samarbeid', icon: '✧', tone: 'blue' }, - { id: 'patchwork', name: 'Patchwork', description: 'Sy sammen et lappeteppe ved å velge og plassere brikker.', minPlayers: 2, maxPlayers: 2, minutes: 30, category: 'Strategi', icon: '▧', tone: 'pink' }, + { + id: 'azul', + name: 'Azul', + description: + 'Legg fargerike fliser og bygg et mønster som gir flest poeng.', + minPlayers: 2, + maxPlayers: 4, + minutes: 40, + category: 'Strategi', + icon: '▦', + tone: 'blue', + }, + { + id: 'codenames', + name: 'Codenames', + description: 'Gi smarte ledetråder og finn lagets hemmelige ord.', + minPlayers: 2, + maxPlayers: 8, + minutes: 20, + category: 'Selskap', + icon: '✳', + tone: 'purple', + }, + { + id: 'carcassonne', + name: 'Carcassonne', + description: + 'Bygg landskap med brikker og plasser følgere på byer og veier.', + minPlayers: 2, + maxPlayers: 5, + minutes: 45, + category: 'Strategi', + icon: '⌂', + tone: 'green', + }, + { + id: 'dixit', + name: 'Dixit', + description: + 'Fortell en liten historie til et bilde og gjett hvilket kort som er riktig.', + minPlayers: 3, + maxPlayers: 6, + minutes: 30, + category: 'Kreativt', + icon: '✦', + tone: 'pink', + }, + { + id: 'ticket-to-ride', + name: 'Ticket to Ride', + description: 'Bygg togruter mellom byer og fullfør reisemålene dine.', + minPlayers: 2, + maxPlayers: 5, + minutes: 60, + category: 'Familie', + icon: '↗', + tone: 'orange', + }, + { + id: 'pandemic', + name: 'Pandemic', + description: 'Samarbeid om å stoppe utbrudd før tiden renner ut.', + minPlayers: 2, + maxPlayers: 4, + minutes: 45, + category: 'Samarbeid', + icon: '✚', + tone: 'teal', + }, + { + id: 'splendor', + name: 'Splendor', + description: 'Samle edelstener, kjøp kort og bygg en verdifull samling.', + minPlayers: 2, + maxPlayers: 4, + minutes: 30, + category: 'Strategi', + icon: '◆', + tone: 'purple', + }, + { + id: 'just-one', + name: 'Just One', + description: 'Skriv ett hint hver og hjelp en spiller å gjette ordet.', + minPlayers: 3, + maxPlayers: 7, + minutes: 20, + category: 'Samarbeid', + icon: '☝', + tone: 'yellow', + }, + { + id: 'kingdomino', + name: 'Kingdomino', + description: 'Velg dominobrikker og sett sammen ditt eget kongerike.', + minPlayers: 2, + maxPlayers: 4, + minutes: 25, + category: 'Familie', + icon: '♛', + tone: 'green', + }, + { + id: '7-wonders', + name: '7 Wonders', + description: + 'Bygg en sivilisasjon med kort og samle ressurser over tre epoker.', + minPlayers: 3, + maxPlayers: 7, + minutes: 30, + category: 'Strategi', + icon: '◭', + tone: 'orange', + }, + { + id: 'the-crew', + name: 'The Crew', + description: 'Løs oppdrag sammen ved å spille kort uten å si for mye.', + minPlayers: 3, + maxPlayers: 5, + minutes: 20, + category: 'Samarbeid', + icon: '✧', + tone: 'blue', + }, + { + id: 'patchwork', + name: 'Patchwork', + description: 'Sy sammen et lappeteppe ved å velge og plassere brikker.', + minPlayers: 2, + maxPlayers: 2, + minutes: 30, + category: 'Strategi', + icon: '▧', + tone: 'pink', + }, ] diff --git a/src/graphql/mocks.ts b/src/graphql/mocks.ts new file mode 100644 index 0000000..241a3b6 --- /dev/null +++ b/src/graphql/mocks.ts @@ -0,0 +1,104 @@ +import { MockLink } from '@apollo/client/testing' +import { games } from '../data/games' +import { + GET_CATEGORIES, + GET_GAME, + GET_GAMES, + GET_GAMES_BY_IDS, +} from './queries' +import type { CatalogInput } from './types' +import { filterGames, PAGE_SIZE } from '../lib/catalog' + +function getCatalogResult(input: CatalogInput) { + const filteredGames = filterGames(games, { + query: input.query, + players: input.players, + maxMinutes: input.maxMinutes, + category: input.category, + sort: input.sort === 'NAME' ? 'name' : 'minutes', + }) + + const pageSize = input.pageSize || PAGE_SIZE + const start = (input.page - 1) * pageSize + + return { + games: { + items: filteredGames.slice(start, start + pageSize), + totalCount: filteredGames.length, + page: input.page, + pageSize, + }, + } +} + +export function createCatalogMockLink() { + return new MockLink([ + { + request: { + query: GET_GAMES, + variables: (variables) => { + const input = variables.input as CatalogInput + return Boolean(input) + }, + }, + result: (variables) => { + const input = variables.input as CatalogInput + return { + data: getCatalogResult(input), + } + }, + maxUsageCount: Number.POSITIVE_INFINITY, + }, + { + request: { + query: GET_GAME, + variables: (variables) => { + return typeof variables.id === 'string' + }, + }, + result: (variables) => { + const game = games.find((item) => item.id === variables.id) + + return { + data: { + game: game ?? null, + }, + } + }, + maxUsageCount: Number.POSITIVE_INFINITY, + }, + { + request: { + query: GET_CATEGORIES, + }, + result: { + data: { + categories: [...new Set(games.map((game) => game.category))].sort( + (a, b) => a.localeCompare(b, 'no'), + ), + }, + }, + maxUsageCount: Number.POSITIVE_INFINITY, + }, + { + request: { + query: GET_GAMES_BY_IDS, + variables: (variables) => { + return Array.isArray(variables.ids) + }, + }, + result: (variables) => { + const ids = variables.ids as string[] + + return { + data: { + gamesByIds: ids + .map((id) => games.find((game) => game.id === id)) + .filter((game) => game !== undefined), + }, + } + }, + maxUsageCount: Number.POSITIVE_INFINITY, + }, + ]) +} diff --git a/src/graphql/queries.test.tsx b/src/graphql/queries.test.tsx new file mode 100644 index 0000000..fbb9043 --- /dev/null +++ b/src/graphql/queries.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react' +import { ApolloClient, InMemoryCache } from '@apollo/client' +import { MockLink } from '@apollo/client/testing' +import { ApolloProvider, useQuery } from '@apollo/client/react' +import { describe, expect, it } from 'vitest' +import { GET_GAMES } from './queries' +import type { GamesData, GamesVariables } from './types' + +const input = { + query: '', + players: null, + maxMinutes: null, + category: '', + sort: 'NAME' as const, + page: 1, + pageSize: 6, +} + +function TestCatalog() { + const { data, loading, error } = useQuery( + GET_GAMES, + { variables: { input } }, + ) + + if (loading) return

Laster spill...

+ if (error) return

Kunne ikke hente spill

+ + return

{data?.games.items[0]?.name ?? 'Ingen spill'}

+} + +describe('GET_GAMES', () => { + it('henter spill fra en simulert GraphQL-server', async () => { + const client = new ApolloClient({ + link: new MockLink([ + { + request: { + query: GET_GAMES, + variables: { input }, + }, + result: { + data: { + games: { + items: [ + { + id: 'azul', + name: 'Azul', + description: 'Legg fargerike fliser.', + category: 'Strategi', + minPlayers: 2, + maxPlayers: 4, + minutes: 40, + icon: '▦', + tone: 'blue', + }, + ], + totalCount: 1, + page: 1, + pageSize: 6, + }, + }, + }, + }, + ]), + cache: new InMemoryCache(), + }) + + render( + + + , + ) + + expect(await screen.findByText('Azul')).toBeInTheDocument() + }) +}) diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts new file mode 100644 index 0000000..48acf33 --- /dev/null +++ b/src/graphql/queries.ts @@ -0,0 +1,59 @@ +import { gql } from '@apollo/client' + +export const GET_GAMES = gql` + query GetGames($input: CatalogInput) { + games(input: $input) { + items { + id + name + description + minPlayers + maxPlayers + minutes + category + icon + tone + } + totalCount + page + pageSize + } + } +` +export const GET_GAME = gql` + query GetGame($id: ID!) { + game(id: $id) { + id + name + description + minPlayers + maxPlayers + minutes + category + icon + tone + } + } +` + +export const GET_GAMES_BY_IDS = gql` + query GetGamesByIds($ids: [ID!]!) { + gamesByIds(ids: $ids) { + id + name + description + minPlayers + maxPlayers + minutes + category + icon + tone + } + } +` + +export const GET_CATEGORIES = gql` + query GetCategories { + categories + } +` diff --git a/src/graphql/types.ts b/src/graphql/types.ts new file mode 100644 index 0000000..4259c0d --- /dev/null +++ b/src/graphql/types.ts @@ -0,0 +1,46 @@ +import type { Game } from '../data/games' + +export type { Game } from '../data/games' + +export type CatalogInput = { + query: string + players: number | null + maxMinutes: number | null + category: string + sort: 'NAME' | 'TIME' + page: number + pageSize: number +} + +export type GamesData = { + games: { + items: Game[] + totalCount: number + page: number + pageSize: number + } +} + +export type GamesVariables = { + input: CatalogInput +} + +export type GameData = { + game: Game | null +} + +export type GameVariables = { + id: string +} + +export type GamesByIdsData = { + gamesByIds: Game[] +} + +export type GamesByIdsVariables = { + ids: string[] +} + +export type CategoriesData = { + categories: string[] +} diff --git a/src/lib/apollo.ts b/src/lib/apollo.ts new file mode 100644 index 0000000..e4eb110 --- /dev/null +++ b/src/lib/apollo.ts @@ -0,0 +1,8 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client' + +export const apolloClient = new ApolloClient({ + link: new HttpLink({ + uri: import.meta.env.VITE_GRAPHQL_URL || '/graphql', + }), + cache: new InMemoryCache(), +}) diff --git a/src/lib/catalog.test.ts b/src/lib/catalog.test.ts index 599351d..8e2e826 100644 --- a/src/lib/catalog.test.ts +++ b/src/lib/catalog.test.ts @@ -2,16 +2,33 @@ import { describe, expect, it } from 'vitest' import { games } from '../data/games' import { filterGames, pageGames, PAGE_SIZE, type Filters } from './catalog' -const defaults: Filters = { query: '', players: null, maxMinutes: null, category: '', sort: 'name' } +const defaults: Filters = { + query: '', + players: null, + maxMinutes: null, + category: '', + sort: 'name', +} describe('catalog', () => { it('searches title and description without case sensitivity', () => { - expect(filterGames(games, { ...defaults, query: 'AZUL' }).map((game) => game.id)).toEqual(['azul']) - expect(filterGames(games, { ...defaults, query: 'utbrudd' }).map((game) => game.id)).toEqual(['pandemic']) + expect( + filterGames(games, { ...defaults, query: 'AZUL' }).map((game) => game.id), + ).toEqual(['azul']) + expect( + filterGames(games, { ...defaults, query: 'utbrudd' }).map( + (game) => game.id, + ), + ).toEqual(['pandemic']) }) it('combines player, time and category filters', () => { - const result = filterGames(games, { ...defaults, players: 4, maxMinutes: 30, category: 'Samarbeid' }) + const result = filterGames(games, { + ...defaults, + players: 4, + maxMinutes: 30, + category: 'Samarbeid', + }) expect(result.map((game) => game.id)).toEqual(['just-one', 'the-crew']) }) @@ -21,6 +38,12 @@ describe('catalog', () => { expect(result[1].name).toBe('Just One') expect(pageGames(result, 1)).toHaveLength(PAGE_SIZE) expect(pageGames(result, 2)).toHaveLength(games.length - PAGE_SIZE) - expect(new Set([...pageGames(result, 1), ...pageGames(result, 2)].map((game) => game.id)).size).toBe(games.length) + expect( + new Set( + [...pageGames(result, 1), ...pageGames(result, 2)].map( + (game) => game.id, + ), + ).size, + ).toBe(games.length) }) }) diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts index a4d0d44..626ccec 100644 --- a/src/lib/catalog.ts +++ b/src/lib/catalog.ts @@ -15,15 +15,26 @@ export function filterGames(games: Game[], filters: Filters): Game[] { return games .filter((game) => { - const matchesQuery = !query || `${game.name} ${game.description}`.toLocaleLowerCase('no').includes(query) - const matchesPlayers = filters.players === null || (game.minPlayers <= filters.players && game.maxPlayers >= filters.players) - const matchesTime = filters.maxMinutes === null || game.minutes <= filters.maxMinutes - const matchesCategory = !filters.category || game.category === filters.category + const matchesQuery = + !query || + `${game.name} ${game.description}` + .toLocaleLowerCase('no') + .includes(query) + const matchesPlayers = + filters.players === null || + (game.minPlayers <= filters.players && + game.maxPlayers >= filters.players) + const matchesTime = + filters.maxMinutes === null || game.minutes <= filters.maxMinutes + const matchesCategory = + !filters.category || game.category === filters.category return matchesQuery && matchesPlayers && matchesTime && matchesCategory }) - .sort((a, b) => filters.sort === 'minutes' - ? a.minutes - b.minutes || a.name.localeCompare(b.name, 'no') - : a.name.localeCompare(b.name, 'no')) + .sort((a, b) => + filters.sort === 'minutes' + ? a.minutes - b.minutes || a.name.localeCompare(b.name, 'no') + : a.name.localeCompare(b.name, 'no'), + ) } export function pageGames(games: Game[], page: number): Game[] { diff --git a/src/lib/selection.ts b/src/lib/selection.ts index a463878..44906a2 100644 --- a/src/lib/selection.ts +++ b/src/lib/selection.ts @@ -1,10 +1,20 @@ const STORAGE_KEY = 'spillkveld-selection-v1' -export function readSelection(validIds: string[]): string[] { +export function readSelection(): string[] { try { - const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as unknown + const saved = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? '[]', + ) as unknown + if (!Array.isArray(saved)) return [] - return [...new Set(saved.filter((id): id is string => typeof id === 'string' && validIds.includes(id)))] + + return [ + ...new Set( + saved.filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ), + ), + ] } catch { return [] } diff --git a/src/main.tsx b/src/main.tsx index 6906b28..0038eec 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,14 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { ApolloProvider } from '@apollo/client/react' import App from './App' +import { apolloClient } from './lib/apollo' import './styles.css' ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vite.config.ts b/vite.config.ts index cc4cfed..28af35b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,19 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + base: '/project2/', + + server: { + port: 5173, + strictPort: true, + proxy: { + '/graphql': { + target: 'http://127.0.0.1:3001', + changeOrigin: true, + }, + }, + }, + test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'],