├── .env.template ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── Procfile ├── README.md ├── apollo.config.js ├── craco.config.js ├── docker-compose.yml ├── graphql.config.yml ├── jest.config.js ├── package.json ├── prisma ├── migrations │ ├── 20210601111442_init │ │ └── migration.sql │ ├── 20220412203040_cascade_delete_counters │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── index.html ├── logo-192.png ├── logo-512.png ├── manifest.json ├── robots.txt └── safari-pinned-tab.svg ├── screenshot.png ├── server ├── __tests__ │ ├── __utils.ts │ ├── generated │ │ ├── gql.ts │ │ ├── graphql.ts │ │ └── index.ts │ └── integration.spec.ts ├── graphql │ ├── generated │ │ └── graphql.ts │ ├── index.ts │ ├── resolvers.ts │ └── schema.graphql ├── prisma.ts └── server.ts ├── src ├── App.tsx ├── assets │ ├── logo.svg │ └── main.css ├── components │ ├── Counter.tsx │ ├── CounterModal.tsx │ ├── Modal.tsx │ ├── ShareModal.tsx │ └── TextField.tsx ├── generated │ └── graphql.ts ├── hooks │ ├── counter.ts │ └── space.ts ├── index.tsx ├── react-app-env.d.ts ├── setupProxy.js └── views │ ├── home.tsx │ └── space.tsx ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.server.json └── yarn.lock /.env.template: -------------------------------------------------------------------------------- 1 | # PostgreSQL connection string for Prisma 2 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 3 | DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # production 5 | /build 6 | /dist 7 | 8 | # misc 9 | .DS_Store 10 | .env 11 | .env.local 12 | .env.development.local 13 | .env.test.local 14 | .env.production.local 15 | 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | 20 | # docker-compose volumes 21 | /data -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts 2 | build 3 | dist 4 | 5 | # Ignore docker volumes 6 | data 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 100, 6 | tabWidth: 2, 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["GraphQL.vscode-graphql"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Paul Nta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: yarn start 2 | 3 | release: npx prisma migrate deploy -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | tinycounter logo 5 | 6 |

7 |
8 |

9 | heroku deployment 10 | license 11 |

12 |
13 | 14 | # tinycounter 15 | 16 | > Collaborative counter web app to keep track of multiple values 17 | 18 | I created this project because I was looking for a simple counter app that can share its state in real-time between multiple devices — no account required, no app to download, and especially no ads. 19 | 20 | This project is built with [TypeScript](https://www.typescriptlang.org/), [React](https://reactjs.org/), [Apollo GraphQL](https://www.apollographql.com/) and [Prisma](https://www.prisma.io/). Combining those technologies offers a great developper experience 🚀 21 | 22 | ![Demo](./screenshot.png) 23 | 24 | ## Getting started 25 | 26 | ### Prerequisites 27 | 28 | To run the app locally, you'll need the following tools: 29 | 30 | - [Node 16.x](https://nodejs.org/en/) 31 | - [Docker](https://docs.docker.com/get-docker/) 32 | 33 | ### Installation 34 | 35 | 1. Install project dependencies 36 | ```sh 37 | yarn install 38 | ``` 39 | 2. Make a copy of [`.env.template`](./.env.template) into a `.env` file. 40 | 41 | ### Start the database 42 | 43 | Run the following command to start the Postgres database inside of a docker container 44 | 45 | ```sh 46 | yarn db:start 47 | ``` 48 | 49 | Then, you can initialize tables in the database with this command: 50 | sh 51 | 52 | ``` 53 | npx prisma migrate dev 54 | ``` 55 | 56 | This command will the data model defined in [schema.prisma](./prisma/schema.prisma) to the database schema and generate type definitions for prisma client in `node_modules/@prisma/client` folder. 57 | 58 | ### Start the app 59 | 60 | Launch the GraphQL server with this command: 61 | 62 | ``` 63 | yarn dev:server 64 | ``` 65 | 66 | Then start the React application with the following command: 67 | 68 | ``` 69 | yarn dev:client 70 | ``` 71 | 72 | The app is now live on http://localhost:3000 ✨ 73 | 74 | ## Available Scripts 75 | 76 | In the project directory, you can run: 77 | 78 | #### `yarn db:start`, `yarn db:stop` 79 | 80 | Start and stop the Postgres container. 81 | 82 | #### `yarn prisma:studio` 83 | 84 | Launch [Prisma studio](https://www.prisma.io/studio) GUI to explore and manipulate the data in the database. 85 | 86 | #### `yarn codegen:prisma` 87 | 88 | Reads Prisma schema and generates the Prisma Client code in the `node_modules/@prisma/client` folder. It provides a type-safe query builder and Typescript definitions for all database models. 89 | 90 | You'll need to run this command whenever you make changes in the database Schema to update the generated code. 91 | 92 | #### `yarn codegen:graphql` 93 | 94 | This command uses [GraphQL Code Generator](https://www.graphql-code-generator.com/) to generate Typescript definitions for: 95 | 96 | - GraphQL resolvers on the server. 97 | - GraphQL queries made on the client. The [typescript-react-apollo](https://www.graphql-code-generator.com/plugins/typescript-react-apollo) plugin generates reusable React hooks to send GraphQL queries. 98 | - GraphQL queries made during tests. The [gql-tag-operations-preset](https://www.graphql-code-generator.com/plugins/gql-tag-operations-preset) preset is used to generate typings for inline `gql` usage. So need to import types from anywhere, it's just magic 🪄 99 | 100 | You'll need to run this command whenever you make changes to the GraphQL Schema or client-side or test queries to update the generated code. 101 | 102 | Alternatively, you can run the command once in watch mode: 103 | 104 | ```sh 105 | yarn codegen:graphql --watch 106 | ``` 107 | 108 | #### `yarn codegen` 109 | 110 | Run both `codegen:graphql` and `codegen:prisma` 111 | 112 | ### `yarn test` 113 | 114 | Run tests. 115 | 116 | For [integration tests](./server/__tests__/integration.spec.ts) to run correctly, you'll need to start the database container with `yarn db:start` 117 | 118 | #### `yarn build` 119 | 120 | Build the server and client projects for production. 121 | 122 | #### `yarn start` 123 | 124 | Command to run after running `yarn build` to start the app in production mode. 125 | 126 | ## License 127 | 128 | [MIT](LICENSE) license. 129 | -------------------------------------------------------------------------------- /apollo.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | client: { 3 | includes: ['src/**/*.{js,ts,tsx}'], 4 | excludes: ['**/node_modules/**', '**/generated/**'], 5 | service: { 6 | name: 'tinycounter', 7 | url: 'http://localhost:4000/graphql', 8 | }, 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | style: { 3 | postcss: { 4 | plugins: { 5 | 'postcss-import': {}, 6 | tailwindcss: {}, 7 | autoprefixer: {}, 8 | }, 9 | }, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | database: 4 | container_name: tinycounter-db 5 | image: postgres 6 | restart: always 7 | volumes: 8 | - ./data/database:/var/lib/postgresql/data 9 | environment: 10 | POSTGRES_USER: 'postgres' 11 | POSTGRES_PASSWORD: 'postgres' 12 | POSTGRES_DB: 'tinycounter' 13 | ports: 14 | - '5432:5432' 15 | -------------------------------------------------------------------------------- /graphql.config.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: server/graphql/schema.graphql 3 | extensions: 4 | codegen: 5 | hooks: 6 | afterAllFileWrite: 7 | - prettier --write 8 | generates: 9 | ./server/graphql/generated/graphql.ts: 10 | documents: 11 | - 'server/**/*.ts' 12 | - '!**/generated' 13 | plugins: 14 | - typescript 15 | - typescript-resolvers 16 | config: 17 | mapperTypeSuffix: Model 18 | mappers: 19 | Space: '@prisma/client#Space' 20 | Counter: '@prisma/client#Counter' 21 | ./server/__tests__/generated: 22 | documents: 23 | - 'server/__tests__/**/*.spec.ts' 24 | - '!**/generated' 25 | preset: gql-tag-operations-preset 26 | ./src/generated/graphql.ts: 27 | documents: 28 | - 'src/**/*.ts' 29 | - '!**/generated' 30 | plugins: 31 | - typescript 32 | - typescript-operations 33 | - typescript-react-apollo 34 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | roots: ['/src/', '/server/'], 6 | testPathIgnorePatterns: ['/node_modules/', '/generated/', '_utils'], 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tinycounter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": "16.x" 7 | }, 8 | "scripts": { 9 | "db:start": "docker-compose up -d", 10 | "db:stop": "docker-compose down", 11 | "dev:server": "nodemon ./server/server.ts --exec 'ts-node --log-error --project tsconfig.server.json' -w server --ext .ts", 12 | "dev:client": "craco start", 13 | "build:server": "rimraf dist && tsc -p tsconfig.server.json", 14 | "build:client": "craco build", 15 | "build": "run-s build:*", 16 | "start": "NODE_ENV=production node dist/server.js", 17 | "codegen:graphql": "graphql-codegen -c graphql.config.yml", 18 | "codegen:prisma": "npx prisma generate", 19 | "codegen": "run-s codegen:*", 20 | "prisma:studio": "npx prisma studio", 21 | "prepare": "husky install" 22 | }, 23 | "dependencies": { 24 | "@apollo/client": "^3.5.10", 25 | "@craco/craco": "^7.0.0-alpha.3", 26 | "@graphql-codegen/gql-tag-operations-preset": "^1.3.0", 27 | "@graphql-tools/schema": "^8.3.6", 28 | "@prisma/client": "^3.11.1", 29 | "apollo-server-core": "^3.6.7", 30 | "apollo-server-express": "^3.6.7", 31 | "classnames": "^2.3.1", 32 | "express": "^4.17.1", 33 | "graphql": "^15.0.0", 34 | "graphql-config": "^4.2.0", 35 | "graphql-subscriptions": "^2.0.0", 36 | "graphql-tag": "2.12.4", 37 | "graphql-ws": "^5.6.4", 38 | "qrcode.react": "^3.0.1", 39 | "react": "^17.0.2", 40 | "react-dom": "^17.0.2", 41 | "react-icons": "^4.3.1", 42 | "react-router-dom": "^5.2.0", 43 | "react-scripts": "^5.0.0", 44 | "web-vitals": "^1.0.1", 45 | "ws": "^8.5.0" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.0.0-0", 49 | "@babel/plugin-syntax-flow": "^7.14.5", 50 | "@babel/plugin-transform-react-jsx": "^7.14.9", 51 | "@graphql-codegen/cli": "2.6.2", 52 | "@graphql-codegen/typescript": "2.4.8", 53 | "@graphql-codegen/typescript-operations": "2.3.5", 54 | "@graphql-codegen/typescript-react-apollo": "3.2.11", 55 | "@graphql-codegen/typescript-resolvers": "2.6.1", 56 | "@testing-library/dom": "^8.12.0", 57 | "@testing-library/jest-dom": "^5.11.4", 58 | "@testing-library/react": "^11.1.0", 59 | "@testing-library/user-event": "^12.1.10", 60 | "@types/express": "^4.17.1", 61 | "@types/jest": "^26.0.23", 62 | "@types/node": "^15.6.1", 63 | "@types/react": "^17.0.7", 64 | "@types/react-dom": "^17.0.5", 65 | "@types/react-router-dom": "^5.1.7", 66 | "@types/ws": "^8.5.3", 67 | "autoprefixer": "^10.4.4", 68 | "http-proxy-middleware": "^2.0.4", 69 | "husky": "^7.0.4", 70 | "jest": "^27.5.1", 71 | "lint-staged": "^12.3.7", 72 | "nodemon": "^2.0.15", 73 | "npm-run-all": "^4.1.5", 74 | "postcss": "^8.4.12", 75 | "postcss-import": "^14.1.0", 76 | "prettier": "^2.6.1", 77 | "prettier-plugin-prisma": "^3.11.0", 78 | "prisma": "^3.11.1", 79 | "rimraf": "^3.0.2", 80 | "tailwindcss": "^3.0.23", 81 | "ts-jest": "^27.1.4", 82 | "ts-node": "^10.7.0", 83 | "typescript": "^4.6.3" 84 | }, 85 | "prisma": { 86 | "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" 87 | }, 88 | "eslintConfig": { 89 | "extends": [ 90 | "react-app", 91 | "react-app/jest" 92 | ] 93 | }, 94 | "husky": { 95 | "hooks": { 96 | "pre-commit": "lint-staged" 97 | } 98 | }, 99 | "lint-staged": { 100 | "**/*.{js,jsx,ts,tsx,json,css,md}": [ 101 | "prettier --write" 102 | ] 103 | }, 104 | "browserslist": { 105 | "production": [ 106 | ">0.2%", 107 | "not dead", 108 | "not op_mini all" 109 | ], 110 | "development": [ 111 | "last 1 chrome version", 112 | "last 1 firefox version", 113 | "last 1 safari version" 114 | ] 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /prisma/migrations/20210601111442_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Space" ( 3 | "id" TEXT NOT NULL, 4 | "title" VARCHAR(255), 5 | 6 | PRIMARY KEY ("id") 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "Counter" ( 11 | "id" TEXT NOT NULL, 12 | "title" VARCHAR(255), 13 | "value" INTEGER NOT NULL DEFAULT 0, 14 | "spaceId" TEXT, 15 | 16 | PRIMARY KEY ("id") 17 | ); 18 | 19 | -- AddForeignKey 20 | ALTER TABLE "Counter" ADD FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE SET NULL ON UPDATE CASCADE; 21 | -------------------------------------------------------------------------------- /prisma/migrations/20220412203040_cascade_delete_counters/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Counter" DROP CONSTRAINT "Counter_spaceId_fkey"; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "Counter" ADD CONSTRAINT "Counter_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE CASCADE ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model Space { 11 | id String @id @default(cuid()) 12 | title String? @db.VarChar(255) 13 | counters Counter[] 14 | } 15 | 16 | model Counter { 17 | id String @id @default(cuid()) 18 | title String? @db.VarChar(255) 19 | value Int @default(0) 20 | spaceId String? 21 | space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade) 22 | } 23 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | const prisma = new PrismaClient() 4 | 5 | async function seed() { 6 | await prisma.space.deleteMany() 7 | const space = await prisma.space.create({ 8 | data: { 9 | title: 'Test space', 10 | counters: { 11 | create: [{ title: 'Beers' }, { title: 'People' }], 12 | }, 13 | }, 14 | include: { counters: true }, 15 | }) 16 | console.log('created space', space) 17 | } 18 | 19 | seed() 20 | .catch((e) => { 21 | console.error(e) 22 | process.exit(1) 23 | }) 24 | .finally(async () => { 25 | await prisma.$disconnect() 26 | }) 27 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulnta/tinycounter/02220bc61b2bce898b1813ed149b59a4004ca7a6/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulnta/tinycounter/02220bc61b2bce898b1813ed149b59a4004ca7a6/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulnta/tinycounter/02220bc61b2bce898b1813ed149b59a4004ca7a6/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulnta/tinycounter/02220bc61b2bce898b1813ed149b59a4004ca7a6/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | tinycounter 22 | 23 | 24 | 25 |
26 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /public/logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulnta/tinycounter/02220bc61b2bce898b1813ed149b59a4004ca7a6/public/logo-192.png -------------------------------------------------------------------------------- /public/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulnta/tinycounter/02220bc61b2bce898b1813ed149b59a4004ca7a6/public/logo-512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "tinycounter", 3 | "name": "Simple and collaborative counter app to keep track of multiple values", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo-192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo-512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 16 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulnta/tinycounter/02220bc61b2bce898b1813ed149b59a4004ca7a6/screenshot.png -------------------------------------------------------------------------------- /server/__tests__/__utils.ts: -------------------------------------------------------------------------------- 1 | import { ResultOf, VariablesOf } from '@graphql-typed-document-node/core' 2 | import { GraphQLRequest, GraphQLResponse, VariableValues } from 'apollo-server-core' 3 | import { ApolloServer } from 'apollo-server-express' 4 | import { DocumentNode } from 'graphql' 5 | import { schema } from '../graphql' 6 | 7 | type Operation = Omit & { 8 | query: T 9 | variables?: VariablesOf 10 | } 11 | 12 | type OperationResponse = Omit & { 13 | data?: ResultOf 14 | } 15 | 16 | export function constructTestServer() { 17 | const server = new ApolloServer({ schema }) 18 | async function executeOperation(operation: Operation) { 19 | return server.executeOperation({ 20 | ...operation, 21 | variables: operation.variables as VariableValues, 22 | }) as OperationResponse 23 | } 24 | return { server, executeOperation } 25 | } 26 | 27 | export function assertDefined(value: T | null | undefined): asserts value is T { 28 | expect(value).toBeDefined() 29 | } 30 | 31 | export function assertUndefined(value: T | null | undefined): asserts value is null | undefined { 32 | expect(value).toBeUndefined() 33 | } 34 | -------------------------------------------------------------------------------- /server/__tests__/generated/gql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import * as graphql from './graphql' 3 | import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' 4 | 5 | const documents = { 6 | '\n mutation CreateSpace($data: SpaceInput!) {\n createSpace(data: $data) {\n id\n title\n counters {\n id\n title\n value\n }\n }\n }\n': 7 | graphql.CreateSpaceDocument, 8 | '\n mutation UpdateSpace($id: String!, $data: SpaceInput!) {\n updateSpace(id: $id, data: $data) {\n id\n title\n }\n }\n': 9 | graphql.UpdateSpaceDocument, 10 | '\n mutation DeleteSpace($id: String!) {\n deleteSpace(id: $id) {\n id\n }\n }\n': 11 | graphql.DeleteSpaceDocument, 12 | '\n query GetSpace($id: String!) {\n space(id: $id) {\n id\n title\n counters {\n id\n title\n value\n }\n }\n }\n': 13 | graphql.GetSpaceDocument, 14 | '\n mutation CreateCounter($spaceId: String!, $data: CounterInput!) {\n createCounter(spaceId: $spaceId, data: $data) {\n id\n title\n value\n }\n }\n': 15 | graphql.CreateCounterDocument, 16 | '\n mutation UpdateCounter($id: String!, $data: CounterInput!) {\n updateCounter(id: $id, data: $data) {\n id\n title\n value\n }\n }\n': 17 | graphql.UpdateCounterDocument, 18 | '\n mutation IncrementeCounter($id: String!, $step: Int!) {\n incrementCounter(id: $id, step: $step) {\n id\n title\n value\n }\n }\n': 19 | graphql.IncrementeCounterDocument, 20 | '\n mutation DeleteCounter($id: String!) {\n deleteCounter(id: $id) {\n id\n }\n }\n': 21 | graphql.DeleteCounterDocument, 22 | } 23 | 24 | export function gql( 25 | source: '\n mutation CreateSpace($data: SpaceInput!) {\n createSpace(data: $data) {\n id\n title\n counters {\n id\n title\n value\n }\n }\n }\n', 26 | ): typeof documents['\n mutation CreateSpace($data: SpaceInput!) {\n createSpace(data: $data) {\n id\n title\n counters {\n id\n title\n value\n }\n }\n }\n'] 27 | export function gql( 28 | source: '\n mutation UpdateSpace($id: String!, $data: SpaceInput!) {\n updateSpace(id: $id, data: $data) {\n id\n title\n }\n }\n', 29 | ): typeof documents['\n mutation UpdateSpace($id: String!, $data: SpaceInput!) {\n updateSpace(id: $id, data: $data) {\n id\n title\n }\n }\n'] 30 | export function gql( 31 | source: '\n mutation DeleteSpace($id: String!) {\n deleteSpace(id: $id) {\n id\n }\n }\n', 32 | ): typeof documents['\n mutation DeleteSpace($id: String!) {\n deleteSpace(id: $id) {\n id\n }\n }\n'] 33 | export function gql( 34 | source: '\n query GetSpace($id: String!) {\n space(id: $id) {\n id\n title\n counters {\n id\n title\n value\n }\n }\n }\n', 35 | ): typeof documents['\n query GetSpace($id: String!) {\n space(id: $id) {\n id\n title\n counters {\n id\n title\n value\n }\n }\n }\n'] 36 | export function gql( 37 | source: '\n mutation CreateCounter($spaceId: String!, $data: CounterInput!) {\n createCounter(spaceId: $spaceId, data: $data) {\n id\n title\n value\n }\n }\n', 38 | ): typeof documents['\n mutation CreateCounter($spaceId: String!, $data: CounterInput!) {\n createCounter(spaceId: $spaceId, data: $data) {\n id\n title\n value\n }\n }\n'] 39 | export function gql( 40 | source: '\n mutation UpdateCounter($id: String!, $data: CounterInput!) {\n updateCounter(id: $id, data: $data) {\n id\n title\n value\n }\n }\n', 41 | ): typeof documents['\n mutation UpdateCounter($id: String!, $data: CounterInput!) {\n updateCounter(id: $id, data: $data) {\n id\n title\n value\n }\n }\n'] 42 | export function gql( 43 | source: '\n mutation IncrementeCounter($id: String!, $step: Int!) {\n incrementCounter(id: $id, step: $step) {\n id\n title\n value\n }\n }\n', 44 | ): typeof documents['\n mutation IncrementeCounter($id: String!, $step: Int!) {\n incrementCounter(id: $id, step: $step) {\n id\n title\n value\n }\n }\n'] 45 | export function gql( 46 | source: '\n mutation DeleteCounter($id: String!) {\n deleteCounter(id: $id) {\n id\n }\n }\n', 47 | ): typeof documents['\n mutation DeleteCounter($id: String!) {\n deleteCounter(id: $id) {\n id\n }\n }\n'] 48 | 49 | export function gql(source: string): unknown 50 | export function gql(source: string) { 51 | return (documents as any)[source] ?? {} 52 | } 53 | 54 | export type DocumentType> = 55 | TDocumentNode extends DocumentNode ? TType : never 56 | -------------------------------------------------------------------------------- /server/__tests__/generated/graphql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' 3 | export type Maybe = T | null 4 | export type InputMaybe = Maybe 5 | export type Exact = { [K in keyof T]: T[K] } 6 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe } 7 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe } 8 | /** All built-in and custom scalars, mapped to their actual values */ 9 | export type Scalars = { 10 | ID: string 11 | String: string 12 | Boolean: boolean 13 | Int: number 14 | Float: number 15 | } 16 | 17 | export type Counter = { 18 | __typename?: 'Counter' 19 | id: Scalars['String'] 20 | title?: Maybe 21 | value: Scalars['Int'] 22 | } 23 | 24 | export type CounterInput = { 25 | title?: InputMaybe 26 | value?: InputMaybe 27 | } 28 | 29 | export type Mutation = { 30 | __typename?: 'Mutation' 31 | createCounter: Counter 32 | createSpace: Space 33 | deleteCounter: Counter 34 | deleteSpace: Space 35 | incrementCounter: Counter 36 | updateCounter: Counter 37 | updateSpace: Space 38 | } 39 | 40 | export type MutationCreateCounterArgs = { 41 | data: CounterInput 42 | spaceId: Scalars['String'] 43 | } 44 | 45 | export type MutationCreateSpaceArgs = { 46 | data: SpaceInput 47 | } 48 | 49 | export type MutationDeleteCounterArgs = { 50 | id: Scalars['String'] 51 | } 52 | 53 | export type MutationDeleteSpaceArgs = { 54 | id: Scalars['String'] 55 | } 56 | 57 | export type MutationIncrementCounterArgs = { 58 | id: Scalars['String'] 59 | step: Scalars['Int'] 60 | } 61 | 62 | export type MutationUpdateCounterArgs = { 63 | data: CounterInput 64 | id: Scalars['String'] 65 | } 66 | 67 | export type MutationUpdateSpaceArgs = { 68 | data: SpaceInput 69 | id: Scalars['String'] 70 | } 71 | 72 | export type Query = { 73 | __typename?: 'Query' 74 | space?: Maybe 75 | } 76 | 77 | export type QuerySpaceArgs = { 78 | id: Scalars['String'] 79 | } 80 | 81 | export type Space = { 82 | __typename?: 'Space' 83 | counters: Array 84 | id: Scalars['String'] 85 | title?: Maybe 86 | } 87 | 88 | export type SpaceInput = { 89 | title?: InputMaybe 90 | } 91 | 92 | export type Subscription = { 93 | __typename?: 'Subscription' 94 | spaceUpdated?: Maybe 95 | } 96 | 97 | export type SubscriptionSpaceUpdatedArgs = { 98 | id: Scalars['String'] 99 | } 100 | 101 | export type CreateSpaceMutationVariables = Exact<{ 102 | data: SpaceInput 103 | }> 104 | 105 | export type CreateSpaceMutation = { 106 | __typename?: 'Mutation' 107 | createSpace: { 108 | __typename?: 'Space' 109 | id: string 110 | title?: string | null 111 | counters: Array<{ __typename?: 'Counter'; id: string; title?: string | null; value: number }> 112 | } 113 | } 114 | 115 | export type UpdateSpaceMutationVariables = Exact<{ 116 | id: Scalars['String'] 117 | data: SpaceInput 118 | }> 119 | 120 | export type UpdateSpaceMutation = { 121 | __typename?: 'Mutation' 122 | updateSpace: { __typename?: 'Space'; id: string; title?: string | null } 123 | } 124 | 125 | export type DeleteSpaceMutationVariables = Exact<{ 126 | id: Scalars['String'] 127 | }> 128 | 129 | export type DeleteSpaceMutation = { 130 | __typename?: 'Mutation' 131 | deleteSpace: { __typename?: 'Space'; id: string } 132 | } 133 | 134 | export type GetSpaceQueryVariables = Exact<{ 135 | id: Scalars['String'] 136 | }> 137 | 138 | export type GetSpaceQuery = { 139 | __typename?: 'Query' 140 | space?: { 141 | __typename?: 'Space' 142 | id: string 143 | title?: string | null 144 | counters: Array<{ __typename?: 'Counter'; id: string; title?: string | null; value: number }> 145 | } | null 146 | } 147 | 148 | export type CreateCounterMutationVariables = Exact<{ 149 | spaceId: Scalars['String'] 150 | data: CounterInput 151 | }> 152 | 153 | export type CreateCounterMutation = { 154 | __typename?: 'Mutation' 155 | createCounter: { __typename?: 'Counter'; id: string; title?: string | null; value: number } 156 | } 157 | 158 | export type UpdateCounterMutationVariables = Exact<{ 159 | id: Scalars['String'] 160 | data: CounterInput 161 | }> 162 | 163 | export type UpdateCounterMutation = { 164 | __typename?: 'Mutation' 165 | updateCounter: { __typename?: 'Counter'; id: string; title?: string | null; value: number } 166 | } 167 | 168 | export type IncrementeCounterMutationVariables = Exact<{ 169 | id: Scalars['String'] 170 | step: Scalars['Int'] 171 | }> 172 | 173 | export type IncrementeCounterMutation = { 174 | __typename?: 'Mutation' 175 | incrementCounter: { __typename?: 'Counter'; id: string; title?: string | null; value: number } 176 | } 177 | 178 | export type DeleteCounterMutationVariables = Exact<{ 179 | id: Scalars['String'] 180 | }> 181 | 182 | export type DeleteCounterMutation = { 183 | __typename?: 'Mutation' 184 | deleteCounter: { __typename?: 'Counter'; id: string } 185 | } 186 | 187 | export const CreateSpaceDocument = { 188 | kind: 'Document', 189 | definitions: [ 190 | { 191 | kind: 'OperationDefinition', 192 | operation: 'mutation', 193 | name: { kind: 'Name', value: 'CreateSpace' }, 194 | variableDefinitions: [ 195 | { 196 | kind: 'VariableDefinition', 197 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'data' } }, 198 | type: { 199 | kind: 'NonNullType', 200 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'SpaceInput' } }, 201 | }, 202 | }, 203 | ], 204 | selectionSet: { 205 | kind: 'SelectionSet', 206 | selections: [ 207 | { 208 | kind: 'Field', 209 | name: { kind: 'Name', value: 'createSpace' }, 210 | arguments: [ 211 | { 212 | kind: 'Argument', 213 | name: { kind: 'Name', value: 'data' }, 214 | value: { kind: 'Variable', name: { kind: 'Name', value: 'data' } }, 215 | }, 216 | ], 217 | selectionSet: { 218 | kind: 'SelectionSet', 219 | selections: [ 220 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 221 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 222 | { 223 | kind: 'Field', 224 | name: { kind: 'Name', value: 'counters' }, 225 | selectionSet: { 226 | kind: 'SelectionSet', 227 | selections: [ 228 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 229 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 230 | { kind: 'Field', name: { kind: 'Name', value: 'value' } }, 231 | ], 232 | }, 233 | }, 234 | ], 235 | }, 236 | }, 237 | ], 238 | }, 239 | }, 240 | ], 241 | } as unknown as DocumentNode 242 | export const UpdateSpaceDocument = { 243 | kind: 'Document', 244 | definitions: [ 245 | { 246 | kind: 'OperationDefinition', 247 | operation: 'mutation', 248 | name: { kind: 'Name', value: 'UpdateSpace' }, 249 | variableDefinitions: [ 250 | { 251 | kind: 'VariableDefinition', 252 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 253 | type: { 254 | kind: 'NonNullType', 255 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, 256 | }, 257 | }, 258 | { 259 | kind: 'VariableDefinition', 260 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'data' } }, 261 | type: { 262 | kind: 'NonNullType', 263 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'SpaceInput' } }, 264 | }, 265 | }, 266 | ], 267 | selectionSet: { 268 | kind: 'SelectionSet', 269 | selections: [ 270 | { 271 | kind: 'Field', 272 | name: { kind: 'Name', value: 'updateSpace' }, 273 | arguments: [ 274 | { 275 | kind: 'Argument', 276 | name: { kind: 'Name', value: 'id' }, 277 | value: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 278 | }, 279 | { 280 | kind: 'Argument', 281 | name: { kind: 'Name', value: 'data' }, 282 | value: { kind: 'Variable', name: { kind: 'Name', value: 'data' } }, 283 | }, 284 | ], 285 | selectionSet: { 286 | kind: 'SelectionSet', 287 | selections: [ 288 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 289 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 290 | ], 291 | }, 292 | }, 293 | ], 294 | }, 295 | }, 296 | ], 297 | } as unknown as DocumentNode 298 | export const DeleteSpaceDocument = { 299 | kind: 'Document', 300 | definitions: [ 301 | { 302 | kind: 'OperationDefinition', 303 | operation: 'mutation', 304 | name: { kind: 'Name', value: 'DeleteSpace' }, 305 | variableDefinitions: [ 306 | { 307 | kind: 'VariableDefinition', 308 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 309 | type: { 310 | kind: 'NonNullType', 311 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, 312 | }, 313 | }, 314 | ], 315 | selectionSet: { 316 | kind: 'SelectionSet', 317 | selections: [ 318 | { 319 | kind: 'Field', 320 | name: { kind: 'Name', value: 'deleteSpace' }, 321 | arguments: [ 322 | { 323 | kind: 'Argument', 324 | name: { kind: 'Name', value: 'id' }, 325 | value: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 326 | }, 327 | ], 328 | selectionSet: { 329 | kind: 'SelectionSet', 330 | selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }], 331 | }, 332 | }, 333 | ], 334 | }, 335 | }, 336 | ], 337 | } as unknown as DocumentNode 338 | export const GetSpaceDocument = { 339 | kind: 'Document', 340 | definitions: [ 341 | { 342 | kind: 'OperationDefinition', 343 | operation: 'query', 344 | name: { kind: 'Name', value: 'GetSpace' }, 345 | variableDefinitions: [ 346 | { 347 | kind: 'VariableDefinition', 348 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 349 | type: { 350 | kind: 'NonNullType', 351 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, 352 | }, 353 | }, 354 | ], 355 | selectionSet: { 356 | kind: 'SelectionSet', 357 | selections: [ 358 | { 359 | kind: 'Field', 360 | name: { kind: 'Name', value: 'space' }, 361 | arguments: [ 362 | { 363 | kind: 'Argument', 364 | name: { kind: 'Name', value: 'id' }, 365 | value: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 366 | }, 367 | ], 368 | selectionSet: { 369 | kind: 'SelectionSet', 370 | selections: [ 371 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 372 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 373 | { 374 | kind: 'Field', 375 | name: { kind: 'Name', value: 'counters' }, 376 | selectionSet: { 377 | kind: 'SelectionSet', 378 | selections: [ 379 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 380 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 381 | { kind: 'Field', name: { kind: 'Name', value: 'value' } }, 382 | ], 383 | }, 384 | }, 385 | ], 386 | }, 387 | }, 388 | ], 389 | }, 390 | }, 391 | ], 392 | } as unknown as DocumentNode 393 | export const CreateCounterDocument = { 394 | kind: 'Document', 395 | definitions: [ 396 | { 397 | kind: 'OperationDefinition', 398 | operation: 'mutation', 399 | name: { kind: 'Name', value: 'CreateCounter' }, 400 | variableDefinitions: [ 401 | { 402 | kind: 'VariableDefinition', 403 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'spaceId' } }, 404 | type: { 405 | kind: 'NonNullType', 406 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, 407 | }, 408 | }, 409 | { 410 | kind: 'VariableDefinition', 411 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'data' } }, 412 | type: { 413 | kind: 'NonNullType', 414 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'CounterInput' } }, 415 | }, 416 | }, 417 | ], 418 | selectionSet: { 419 | kind: 'SelectionSet', 420 | selections: [ 421 | { 422 | kind: 'Field', 423 | name: { kind: 'Name', value: 'createCounter' }, 424 | arguments: [ 425 | { 426 | kind: 'Argument', 427 | name: { kind: 'Name', value: 'spaceId' }, 428 | value: { kind: 'Variable', name: { kind: 'Name', value: 'spaceId' } }, 429 | }, 430 | { 431 | kind: 'Argument', 432 | name: { kind: 'Name', value: 'data' }, 433 | value: { kind: 'Variable', name: { kind: 'Name', value: 'data' } }, 434 | }, 435 | ], 436 | selectionSet: { 437 | kind: 'SelectionSet', 438 | selections: [ 439 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 440 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 441 | { kind: 'Field', name: { kind: 'Name', value: 'value' } }, 442 | ], 443 | }, 444 | }, 445 | ], 446 | }, 447 | }, 448 | ], 449 | } as unknown as DocumentNode 450 | export const UpdateCounterDocument = { 451 | kind: 'Document', 452 | definitions: [ 453 | { 454 | kind: 'OperationDefinition', 455 | operation: 'mutation', 456 | name: { kind: 'Name', value: 'UpdateCounter' }, 457 | variableDefinitions: [ 458 | { 459 | kind: 'VariableDefinition', 460 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 461 | type: { 462 | kind: 'NonNullType', 463 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, 464 | }, 465 | }, 466 | { 467 | kind: 'VariableDefinition', 468 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'data' } }, 469 | type: { 470 | kind: 'NonNullType', 471 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'CounterInput' } }, 472 | }, 473 | }, 474 | ], 475 | selectionSet: { 476 | kind: 'SelectionSet', 477 | selections: [ 478 | { 479 | kind: 'Field', 480 | name: { kind: 'Name', value: 'updateCounter' }, 481 | arguments: [ 482 | { 483 | kind: 'Argument', 484 | name: { kind: 'Name', value: 'id' }, 485 | value: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 486 | }, 487 | { 488 | kind: 'Argument', 489 | name: { kind: 'Name', value: 'data' }, 490 | value: { kind: 'Variable', name: { kind: 'Name', value: 'data' } }, 491 | }, 492 | ], 493 | selectionSet: { 494 | kind: 'SelectionSet', 495 | selections: [ 496 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 497 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 498 | { kind: 'Field', name: { kind: 'Name', value: 'value' } }, 499 | ], 500 | }, 501 | }, 502 | ], 503 | }, 504 | }, 505 | ], 506 | } as unknown as DocumentNode 507 | export const IncrementeCounterDocument = { 508 | kind: 'Document', 509 | definitions: [ 510 | { 511 | kind: 'OperationDefinition', 512 | operation: 'mutation', 513 | name: { kind: 'Name', value: 'IncrementeCounter' }, 514 | variableDefinitions: [ 515 | { 516 | kind: 'VariableDefinition', 517 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 518 | type: { 519 | kind: 'NonNullType', 520 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, 521 | }, 522 | }, 523 | { 524 | kind: 'VariableDefinition', 525 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'step' } }, 526 | type: { 527 | kind: 'NonNullType', 528 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, 529 | }, 530 | }, 531 | ], 532 | selectionSet: { 533 | kind: 'SelectionSet', 534 | selections: [ 535 | { 536 | kind: 'Field', 537 | name: { kind: 'Name', value: 'incrementCounter' }, 538 | arguments: [ 539 | { 540 | kind: 'Argument', 541 | name: { kind: 'Name', value: 'id' }, 542 | value: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 543 | }, 544 | { 545 | kind: 'Argument', 546 | name: { kind: 'Name', value: 'step' }, 547 | value: { kind: 'Variable', name: { kind: 'Name', value: 'step' } }, 548 | }, 549 | ], 550 | selectionSet: { 551 | kind: 'SelectionSet', 552 | selections: [ 553 | { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 554 | { kind: 'Field', name: { kind: 'Name', value: 'title' } }, 555 | { kind: 'Field', name: { kind: 'Name', value: 'value' } }, 556 | ], 557 | }, 558 | }, 559 | ], 560 | }, 561 | }, 562 | ], 563 | } as unknown as DocumentNode 564 | export const DeleteCounterDocument = { 565 | kind: 'Document', 566 | definitions: [ 567 | { 568 | kind: 'OperationDefinition', 569 | operation: 'mutation', 570 | name: { kind: 'Name', value: 'DeleteCounter' }, 571 | variableDefinitions: [ 572 | { 573 | kind: 'VariableDefinition', 574 | variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 575 | type: { 576 | kind: 'NonNullType', 577 | type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, 578 | }, 579 | }, 580 | ], 581 | selectionSet: { 582 | kind: 'SelectionSet', 583 | selections: [ 584 | { 585 | kind: 'Field', 586 | name: { kind: 'Name', value: 'deleteCounter' }, 587 | arguments: [ 588 | { 589 | kind: 'Argument', 590 | name: { kind: 'Name', value: 'id' }, 591 | value: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, 592 | }, 593 | ], 594 | selectionSet: { 595 | kind: 'SelectionSet', 596 | selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }], 597 | }, 598 | }, 599 | ], 600 | }, 601 | }, 602 | ], 603 | } as unknown as DocumentNode 604 | -------------------------------------------------------------------------------- /server/__tests__/generated/index.ts: -------------------------------------------------------------------------------- 1 | export * from './gql' 2 | -------------------------------------------------------------------------------- /server/__tests__/integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { gql } from './generated' 2 | import prisma from '../prisma' 3 | import { assertDefined, assertUndefined, constructTestServer } from './__utils' 4 | 5 | const CREATE_SPACE = gql(/* GraphQL */ ` 6 | mutation CreateSpace($data: SpaceInput!) { 7 | createSpace(data: $data) { 8 | id 9 | title 10 | counters { 11 | id 12 | title 13 | value 14 | } 15 | } 16 | } 17 | `) 18 | 19 | const UPDATE_SPACE = gql(/* GraphQL */ ` 20 | mutation UpdateSpace($id: String!, $data: SpaceInput!) { 21 | updateSpace(id: $id, data: $data) { 22 | id 23 | title 24 | } 25 | } 26 | `) 27 | 28 | const DELETE_SPACE = gql(/* GraphQL */ ` 29 | mutation DeleteSpace($id: String!) { 30 | deleteSpace(id: $id) { 31 | id 32 | } 33 | } 34 | `) 35 | 36 | const QUERY_SPACE = gql(/* GraphQL */ ` 37 | query GetSpace($id: String!) { 38 | space(id: $id) { 39 | id 40 | title 41 | counters { 42 | id 43 | title 44 | value 45 | } 46 | } 47 | } 48 | `) 49 | 50 | const CREATE_COUNTER = gql(/* GraphQL */ ` 51 | mutation CreateCounter($spaceId: String!, $data: CounterInput!) { 52 | createCounter(spaceId: $spaceId, data: $data) { 53 | id 54 | title 55 | value 56 | } 57 | } 58 | `) 59 | 60 | const UPDATE_COUNTER = gql(/* GraphQL */ ` 61 | mutation UpdateCounter($id: String!, $data: CounterInput!) { 62 | updateCounter(id: $id, data: $data) { 63 | id 64 | title 65 | value 66 | } 67 | } 68 | `) 69 | 70 | const INCREMENT_COUNTER = gql(/* GraphQL */ ` 71 | mutation IncrementeCounter($id: String!, $step: Int!) { 72 | incrementCounter(id: $id, step: $step) { 73 | id 74 | title 75 | value 76 | } 77 | } 78 | `) 79 | 80 | const DELETE_COUNTER = gql(/* GraphQL */ ` 81 | mutation DeleteCounter($id: String!) { 82 | deleteCounter(id: $id) { 83 | id 84 | } 85 | } 86 | `) 87 | 88 | async function createSpace(title: string) { 89 | const { executeOperation } = constructTestServer() 90 | const { data, errors } = await executeOperation({ 91 | query: CREATE_SPACE, 92 | variables: { 93 | data: { title }, 94 | }, 95 | }) 96 | assertUndefined(errors) 97 | assertDefined(data?.createSpace.id) 98 | return data.createSpace 99 | } 100 | 101 | async function getSpace(id: string) { 102 | const { executeOperation } = constructTestServer() 103 | const { data, errors } = await executeOperation({ 104 | query: QUERY_SPACE, 105 | variables: { id: id }, 106 | }) 107 | assertUndefined(errors) 108 | assertDefined(data?.space) 109 | return data.space 110 | } 111 | 112 | afterAll(async () => { 113 | await prisma.space.deleteMany() 114 | }) 115 | 116 | describe('Space', () => { 117 | it('creates a space', async () => { 118 | const { executeOperation } = constructTestServer() 119 | const newSpace = await createSpace('Test space') 120 | 121 | const { data, errors } = await executeOperation({ 122 | query: QUERY_SPACE, 123 | variables: { id: newSpace.id }, 124 | }) 125 | 126 | assertUndefined(errors) 127 | assertDefined(data?.space) 128 | 129 | expect(data.space).toBeDefined() 130 | expect(data.space.title).toBe('Test space') 131 | }) 132 | 133 | it('initializes new spaces with one counter', async () => { 134 | const space = await createSpace('Test space') 135 | 136 | expect(space.counters).toHaveLength(1) 137 | expect(space.counters[0].title).toBe('Counter') 138 | expect(space.counters[0].value).toBe(0) 139 | }) 140 | 141 | it('updates a space', async () => { 142 | const { executeOperation } = constructTestServer() 143 | const space = await createSpace('Test space') 144 | 145 | const { data, errors } = await executeOperation({ 146 | query: UPDATE_SPACE, 147 | variables: { 148 | id: space.id, 149 | data: { title: 'New title' }, 150 | }, 151 | }) 152 | 153 | assertUndefined(errors) 154 | assertDefined(data?.updateSpace) 155 | expect(data.updateSpace.title).toBe('New title') 156 | }) 157 | 158 | it('deletes a space and related counters', async () => { 159 | const { executeOperation } = constructTestServer() 160 | const space = await createSpace('Test space') 161 | const defaultCounter = space.counters[0] 162 | 163 | const { errors, data } = await executeOperation({ 164 | query: DELETE_SPACE, 165 | variables: { id: space.id }, 166 | }) 167 | 168 | assertUndefined(errors) 169 | assertDefined(data) 170 | expect(data.deleteSpace.id).toBe(space.id) 171 | 172 | const counter = await prisma.counter.findUnique({ where: { id: defaultCounter.id } }) 173 | console.log(counter) 174 | expect(counter).toBeNull() 175 | }) 176 | }) 177 | 178 | describe('Counter', () => { 179 | it('creates a counter', async () => { 180 | const { executeOperation } = constructTestServer() 181 | const space = await createSpace('Test space') 182 | 183 | const { errors, data } = await executeOperation({ 184 | query: CREATE_COUNTER, 185 | variables: { spaceId: space.id, data: { title: 'Coffees', value: 4 } }, 186 | }) 187 | assertUndefined(errors) 188 | assertDefined(data) 189 | expect(data.createCounter.title).toBe('Coffees') 190 | expect(data.createCounter.value).toBe(4) 191 | 192 | const updatedSpace = await getSpace(space.id) 193 | const counter = updatedSpace.counters.find((c) => c.id === data.createCounter.id) 194 | assertDefined(counter) 195 | expect(counter.title).toBe('Coffees') 196 | expect(counter.value).toBe(4) 197 | }) 198 | 199 | it('updates a counter', async () => { 200 | const { executeOperation } = constructTestServer() 201 | const space = await createSpace('Test space') 202 | const defaultCounter = space.counters[0] 203 | assertDefined(defaultCounter) 204 | 205 | const { errors, data } = await executeOperation({ 206 | query: UPDATE_COUNTER, 207 | variables: { 208 | id: defaultCounter.id, 209 | data: { title: 'New title', value: 7 }, 210 | }, 211 | }) 212 | 213 | assertUndefined(errors) 214 | assertDefined(data) 215 | expect(data.updateCounter.title).toBe('New title') 216 | expect(data.updateCounter.value).toBe(7) 217 | }) 218 | 219 | it('increments a counter', async () => { 220 | const { executeOperation } = constructTestServer() 221 | const space = await createSpace('Test space') 222 | const defaultCounter = space.counters[0] 223 | assertDefined(defaultCounter) 224 | 225 | const incrementCounter = async (step: number) => { 226 | const { errors, data } = await executeOperation({ 227 | query: INCREMENT_COUNTER, 228 | variables: { id: defaultCounter.id, step }, 229 | }) 230 | assertUndefined(errors) 231 | assertDefined(data) 232 | return data.incrementCounter 233 | } 234 | 235 | let counter = await incrementCounter(2) 236 | expect(counter.value).toBe(2) 237 | 238 | counter = await incrementCounter(2) 239 | expect(counter.value).toBe(4) 240 | 241 | counter = await incrementCounter(-1) 242 | expect(counter.value).toBe(3) 243 | }) 244 | 245 | it('deletes a counter', async () => { 246 | const { executeOperation } = constructTestServer() 247 | const space = await createSpace('Test space') 248 | const defaultCounter = space.counters[0] 249 | assertDefined(defaultCounter) 250 | 251 | const { errors, data } = await executeOperation({ 252 | query: DELETE_COUNTER, 253 | variables: { id: defaultCounter.id }, 254 | }) 255 | assertUndefined(errors) 256 | assertDefined(data) 257 | 258 | expect(data.deleteCounter.id).toBe(defaultCounter.id) 259 | 260 | const updatedSpace = await getSpace(space.id) 261 | expect(updatedSpace.counters).toHaveLength(0) 262 | }) 263 | }) 264 | -------------------------------------------------------------------------------- /server/graphql/generated/graphql.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLResolveInfo } from 'graphql' 2 | import { Space as SpaceModel, Counter as CounterModel } from '@prisma/client' 3 | export type Maybe = T | null 4 | export type InputMaybe = Maybe 5 | export type Exact = { [K in keyof T]: T[K] } 6 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe } 7 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe } 8 | export type RequireFields = Omit & { [P in K]-?: NonNullable } 9 | /** All built-in and custom scalars, mapped to their actual values */ 10 | export type Scalars = { 11 | ID: string 12 | String: string 13 | Boolean: boolean 14 | Int: number 15 | Float: number 16 | } 17 | 18 | export type Counter = { 19 | __typename?: 'Counter' 20 | id: Scalars['String'] 21 | title?: Maybe 22 | value: Scalars['Int'] 23 | } 24 | 25 | export type CounterInput = { 26 | title?: InputMaybe 27 | value?: InputMaybe 28 | } 29 | 30 | export type Mutation = { 31 | __typename?: 'Mutation' 32 | createCounter: Counter 33 | createSpace: Space 34 | deleteCounter: Counter 35 | deleteSpace: Space 36 | incrementCounter: Counter 37 | updateCounter: Counter 38 | updateSpace: Space 39 | } 40 | 41 | export type MutationCreateCounterArgs = { 42 | data: CounterInput 43 | spaceId: Scalars['String'] 44 | } 45 | 46 | export type MutationCreateSpaceArgs = { 47 | data: SpaceInput 48 | } 49 | 50 | export type MutationDeleteCounterArgs = { 51 | id: Scalars['String'] 52 | } 53 | 54 | export type MutationDeleteSpaceArgs = { 55 | id: Scalars['String'] 56 | } 57 | 58 | export type MutationIncrementCounterArgs = { 59 | id: Scalars['String'] 60 | step: Scalars['Int'] 61 | } 62 | 63 | export type MutationUpdateCounterArgs = { 64 | data: CounterInput 65 | id: Scalars['String'] 66 | } 67 | 68 | export type MutationUpdateSpaceArgs = { 69 | data: SpaceInput 70 | id: Scalars['String'] 71 | } 72 | 73 | export type Query = { 74 | __typename?: 'Query' 75 | space?: Maybe 76 | } 77 | 78 | export type QuerySpaceArgs = { 79 | id: Scalars['String'] 80 | } 81 | 82 | export type Space = { 83 | __typename?: 'Space' 84 | counters: Array 85 | id: Scalars['String'] 86 | title?: Maybe 87 | } 88 | 89 | export type SpaceInput = { 90 | title?: InputMaybe 91 | } 92 | 93 | export type Subscription = { 94 | __typename?: 'Subscription' 95 | spaceUpdated?: Maybe 96 | } 97 | 98 | export type SubscriptionSpaceUpdatedArgs = { 99 | id: Scalars['String'] 100 | } 101 | 102 | export type ResolverTypeWrapper = Promise | T 103 | 104 | export type ResolverWithResolve = { 105 | resolve: ResolverFn 106 | } 107 | export type Resolver = 108 | | ResolverFn 109 | | ResolverWithResolve 110 | 111 | export type ResolverFn = ( 112 | parent: TParent, 113 | args: TArgs, 114 | context: TContext, 115 | info: GraphQLResolveInfo, 116 | ) => Promise | TResult 117 | 118 | export type SubscriptionSubscribeFn = ( 119 | parent: TParent, 120 | args: TArgs, 121 | context: TContext, 122 | info: GraphQLResolveInfo, 123 | ) => AsyncIterable | Promise> 124 | 125 | export type SubscriptionResolveFn = ( 126 | parent: TParent, 127 | args: TArgs, 128 | context: TContext, 129 | info: GraphQLResolveInfo, 130 | ) => TResult | Promise 131 | 132 | export interface SubscriptionSubscriberObject< 133 | TResult, 134 | TKey extends string, 135 | TParent, 136 | TContext, 137 | TArgs, 138 | > { 139 | subscribe: SubscriptionSubscribeFn<{ [key in TKey]: TResult }, TParent, TContext, TArgs> 140 | resolve?: SubscriptionResolveFn 141 | } 142 | 143 | export interface SubscriptionResolverObject { 144 | subscribe: SubscriptionSubscribeFn 145 | resolve: SubscriptionResolveFn 146 | } 147 | 148 | export type SubscriptionObject = 149 | | SubscriptionSubscriberObject 150 | | SubscriptionResolverObject 151 | 152 | export type SubscriptionResolver< 153 | TResult, 154 | TKey extends string, 155 | TParent = {}, 156 | TContext = {}, 157 | TArgs = {}, 158 | > = 159 | | ((...args: any[]) => SubscriptionObject) 160 | | SubscriptionObject 161 | 162 | export type TypeResolveFn = ( 163 | parent: TParent, 164 | context: TContext, 165 | info: GraphQLResolveInfo, 166 | ) => Maybe | Promise> 167 | 168 | export type IsTypeOfResolverFn = ( 169 | obj: T, 170 | context: TContext, 171 | info: GraphQLResolveInfo, 172 | ) => boolean | Promise 173 | 174 | export type NextResolverFn = () => Promise 175 | 176 | export type DirectiveResolverFn = ( 177 | next: NextResolverFn, 178 | parent: TParent, 179 | args: TArgs, 180 | context: TContext, 181 | info: GraphQLResolveInfo, 182 | ) => TResult | Promise 183 | 184 | /** Mapping between all available schema types and the resolvers types */ 185 | export type ResolversTypes = { 186 | Boolean: ResolverTypeWrapper 187 | Counter: ResolverTypeWrapper 188 | CounterInput: CounterInput 189 | Int: ResolverTypeWrapper 190 | Mutation: ResolverTypeWrapper<{}> 191 | Query: ResolverTypeWrapper<{}> 192 | Space: ResolverTypeWrapper 193 | SpaceInput: SpaceInput 194 | String: ResolverTypeWrapper 195 | Subscription: ResolverTypeWrapper<{}> 196 | } 197 | 198 | /** Mapping between all available schema types and the resolvers parents */ 199 | export type ResolversParentTypes = { 200 | Boolean: Scalars['Boolean'] 201 | Counter: CounterModel 202 | CounterInput: CounterInput 203 | Int: Scalars['Int'] 204 | Mutation: {} 205 | Query: {} 206 | Space: SpaceModel 207 | SpaceInput: SpaceInput 208 | String: Scalars['String'] 209 | Subscription: {} 210 | } 211 | 212 | export type CounterResolvers< 213 | ContextType = any, 214 | ParentType extends ResolversParentTypes['Counter'] = ResolversParentTypes['Counter'], 215 | > = { 216 | id?: Resolver 217 | title?: Resolver, ParentType, ContextType> 218 | value?: Resolver 219 | __isTypeOf?: IsTypeOfResolverFn 220 | } 221 | 222 | export type MutationResolvers< 223 | ContextType = any, 224 | ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation'], 225 | > = { 226 | createCounter?: Resolver< 227 | ResolversTypes['Counter'], 228 | ParentType, 229 | ContextType, 230 | RequireFields 231 | > 232 | createSpace?: Resolver< 233 | ResolversTypes['Space'], 234 | ParentType, 235 | ContextType, 236 | RequireFields 237 | > 238 | deleteCounter?: Resolver< 239 | ResolversTypes['Counter'], 240 | ParentType, 241 | ContextType, 242 | RequireFields 243 | > 244 | deleteSpace?: Resolver< 245 | ResolversTypes['Space'], 246 | ParentType, 247 | ContextType, 248 | RequireFields 249 | > 250 | incrementCounter?: Resolver< 251 | ResolversTypes['Counter'], 252 | ParentType, 253 | ContextType, 254 | RequireFields 255 | > 256 | updateCounter?: Resolver< 257 | ResolversTypes['Counter'], 258 | ParentType, 259 | ContextType, 260 | RequireFields 261 | > 262 | updateSpace?: Resolver< 263 | ResolversTypes['Space'], 264 | ParentType, 265 | ContextType, 266 | RequireFields 267 | > 268 | } 269 | 270 | export type QueryResolvers< 271 | ContextType = any, 272 | ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query'], 273 | > = { 274 | space?: Resolver< 275 | Maybe, 276 | ParentType, 277 | ContextType, 278 | RequireFields 279 | > 280 | } 281 | 282 | export type SpaceResolvers< 283 | ContextType = any, 284 | ParentType extends ResolversParentTypes['Space'] = ResolversParentTypes['Space'], 285 | > = { 286 | counters?: Resolver, ParentType, ContextType> 287 | id?: Resolver 288 | title?: Resolver, ParentType, ContextType> 289 | __isTypeOf?: IsTypeOfResolverFn 290 | } 291 | 292 | export type SubscriptionResolvers< 293 | ContextType = any, 294 | ParentType extends ResolversParentTypes['Subscription'] = ResolversParentTypes['Subscription'], 295 | > = { 296 | spaceUpdated?: SubscriptionResolver< 297 | Maybe, 298 | 'spaceUpdated', 299 | ParentType, 300 | ContextType, 301 | RequireFields 302 | > 303 | } 304 | 305 | export type Resolvers = { 306 | Counter?: CounterResolvers 307 | Mutation?: MutationResolvers 308 | Query?: QueryResolvers 309 | Space?: SpaceResolvers 310 | Subscription?: SubscriptionResolvers 311 | } 312 | -------------------------------------------------------------------------------- /server/graphql/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { makeExecutableSchema } from '@graphql-tools/schema' 3 | import { loadSchemaSync } from '@graphql-tools/load' 4 | import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader' 5 | import { resolvers } from './resolvers' 6 | 7 | const typeDefs = loadSchemaSync(join(__dirname, './schema.graphql'), { 8 | loaders: [new GraphQLFileLoader()], 9 | }) 10 | 11 | export const schema = makeExecutableSchema({ typeDefs, resolvers }) 12 | -------------------------------------------------------------------------------- /server/graphql/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Counter, Space } from '.prisma/client' 2 | import { PubSub, withFilter } from 'graphql-subscriptions' 3 | import { Resolvers, SubscriptionSpaceUpdatedArgs } from './generated/graphql' 4 | import prisma from '../prisma' 5 | 6 | const pubSub = new PubSub() 7 | 8 | async function onCounterChanged(counter: Counter) { 9 | if (counter.spaceId) { 10 | const space = await prisma.space.findUnique({ where: { id: counter.spaceId } }) 11 | return pubSub.publish('SPACE_UPDATED', { 12 | spaceUpdated: space, 13 | }) 14 | } 15 | } 16 | 17 | function onSpaceChanged(space: Space) { 18 | pubSub.publish('SPACE_UPDATED', { spaceUpdated: space }) 19 | } 20 | 21 | export const resolvers: Resolvers = { 22 | Subscription: { 23 | spaceUpdated: { 24 | subscribe: withFilter( 25 | () => pubSub.asyncIterator(['SPACE_UPDATED']), 26 | (payload: { spaceUpdated: Space }, variables: SubscriptionSpaceUpdatedArgs) => { 27 | return payload.spaceUpdated.id === variables.id 28 | }, 29 | ) as any, 30 | }, 31 | }, 32 | Query: { 33 | space: (_, { id }) => { 34 | return prisma.space.findUnique({ where: { id } }) 35 | }, 36 | }, 37 | Mutation: { 38 | async createSpace(_, { data }) { 39 | return prisma.space.create({ 40 | data: { 41 | ...data, 42 | counters: { 43 | create: [{ title: 'Counter' }], 44 | }, 45 | }, 46 | }) 47 | }, 48 | async updateSpace(_, { id, data }) { 49 | const space = await prisma.space.update({ where: { id }, data }) 50 | onSpaceChanged(space) 51 | return space 52 | }, 53 | deleteSpace(_, { id }) { 54 | return prisma.space.delete({ 55 | where: { id }, 56 | include: { counters: true }, 57 | }) 58 | }, 59 | async createCounter(_, { spaceId, data }) { 60 | const counter = await prisma.counter.create({ 61 | data: { 62 | title: data.title ?? undefined, 63 | value: data.value ?? undefined, 64 | space: { 65 | connect: { id: spaceId }, 66 | }, 67 | }, 68 | }) 69 | onCounterChanged(counter) 70 | return counter 71 | }, 72 | async updateCounter(_, { id, data }) { 73 | const counter = await prisma.counter.update({ 74 | where: { id }, 75 | data: { 76 | title: data.title ?? undefined, 77 | value: data.value ?? undefined, 78 | }, 79 | }) 80 | onCounterChanged(counter) 81 | return counter 82 | }, 83 | async incrementCounter(_, { id, step }) { 84 | const counter = await prisma.counter.update({ 85 | where: { id }, 86 | data: { 87 | value: { increment: step }, 88 | }, 89 | }) 90 | onCounterChanged(counter) 91 | return counter 92 | }, 93 | async deleteCounter(_, { id }) { 94 | const counter = await prisma.counter.delete({ where: { id } }) 95 | onCounterChanged(counter) 96 | return counter 97 | }, 98 | }, 99 | Space: { 100 | counters: (space) => { 101 | return prisma.counter.findMany({ 102 | where: { spaceId: space.id }, 103 | orderBy: { id: 'asc' }, 104 | }) 105 | }, 106 | }, 107 | } 108 | -------------------------------------------------------------------------------- /server/graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | type Counter { 2 | id: String! 3 | title: String 4 | value: Int! 5 | } 6 | 7 | type Space { 8 | id: String! 9 | title: String 10 | counters: [Counter!]! 11 | } 12 | 13 | input SpaceInput { 14 | title: String 15 | } 16 | 17 | input CounterInput { 18 | title: String 19 | value: Int 20 | } 21 | 22 | type Query { 23 | space(id: String!): Space 24 | } 25 | 26 | type Mutation { 27 | createSpace(data: SpaceInput!): Space! 28 | updateSpace(id: String!, data: SpaceInput!): Space! 29 | deleteSpace(id: String!): Space! 30 | 31 | createCounter(spaceId: String!, data: CounterInput!): Counter! 32 | updateCounter(id: String!, data: CounterInput!): Counter! 33 | incrementCounter(id: String!, step: Int!): Counter! 34 | deleteCounter(id: String!): Counter! 35 | } 36 | 37 | type Subscription { 38 | spaceUpdated(id: String!): Space 39 | } 40 | -------------------------------------------------------------------------------- /server/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | const prisma = new PrismaClient() 4 | 5 | export default prisma 6 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { ApolloServer } from 'apollo-server-express' 3 | import { useServer } from 'graphql-ws/lib/use/ws' 4 | import { createServer } from 'http' 5 | import { WebSocketServer } from 'ws' 6 | import { join } from 'path' 7 | import { schema } from './graphql' 8 | import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core' 9 | 10 | const PORT = process.env.PORT || 4000 11 | 12 | const GRAPHQL_PATH = '/graphql' 13 | const SUBSCRIPTION_PATH = '/graphql' 14 | 15 | async function startServer() { 16 | const app = express() 17 | 18 | if (process.env.NODE_ENV === 'production') { 19 | app.use(express.static(join(__dirname, '../build'))) 20 | app.get('*', (_, res) => { 21 | res.sendFile(join(__dirname, '../build/index.html')) 22 | }) 23 | } 24 | 25 | const httpServer = createServer(app) 26 | const wsServer = new WebSocketServer({ 27 | server: httpServer, 28 | path: SUBSCRIPTION_PATH, 29 | }) 30 | 31 | // eslint-disable-next-line react-hooks/rules-of-hooks 32 | const serverCleanup = useServer({ schema }, wsServer) 33 | const server = new ApolloServer({ 34 | schema, 35 | plugins: [ 36 | ApolloServerPluginDrainHttpServer({ httpServer }), 37 | { 38 | async serverWillStart() { 39 | return { 40 | async drainServer() { 41 | await serverCleanup.dispose() 42 | }, 43 | } 44 | }, 45 | }, 46 | ], 47 | }) 48 | await server.start() 49 | server.applyMiddleware({ app, path: GRAPHQL_PATH }) 50 | 51 | await new Promise((resolve) => httpServer.listen(PORT, resolve)) 52 | return { app, httpServer, server } 53 | } 54 | 55 | startServer().then(() => { 56 | console.log(`🚀 Server ready at http://localhost:${PORT}${GRAPHQL_PATH}`) 57 | console.log(`🚀 Subscriptions ready at ws://localhost:${PORT}${SUBSCRIPTION_PATH}`) 58 | }) 59 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Switch } from 'react-router-dom' 2 | 3 | import Home from './views/home' 4 | import Space from './views/space' 5 | 6 | function App() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export default App 20 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | @layer components { 11 | .icon-button { 12 | @apply p-2 active:bg-slate-200 hover:bg-slate-100 rounded-full; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Counter.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from 'react' 2 | import { FaPlus, FaMinus } from 'react-icons/fa' 3 | 4 | type CounterProps = { 5 | title?: string | null 6 | value: number 7 | onClick: MouseEventHandler 8 | onIncrement: MouseEventHandler 9 | onDecrement: MouseEventHandler 10 | } 11 | 12 | export function Counter({ title, value, onClick, onIncrement, onDecrement }: CounterProps) { 13 | return ( 14 |
  • 18 |
    19 |
    {title}
    20 |
    {value}
    21 |
    22 |
    23 | 29 | 35 |
    36 |
  • 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/components/CounterModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from './Modal' 2 | import { MdAdd, MdRemove, MdClose, MdDelete } from 'react-icons/md' 3 | import { TextField } from './TextField' 4 | import { useIncrementCounter } from '../hooks/counter' 5 | import { useDeleteCounterMutation, useUpdateCounterMutation } from '../generated/graphql' 6 | 7 | type CounterModalProps = { 8 | counter: any 9 | onClose: () => void 10 | } 11 | 12 | export function CounterModal({ counter, onClose }: CounterModalProps) { 13 | const [incrementCounter] = useIncrementCounter() 14 | const [updateCounter] = useUpdateCounterMutation() 15 | const [deleteCounter] = useDeleteCounterMutation() 16 | 17 | const onDeleteCounter = async () => { 18 | await deleteCounter({ variables: { id: counter.id } }) 19 | onClose() 20 | } 21 | 22 | return ( 23 | 24 | <> 25 |
    26 | 29 |
    30 | 33 |
    34 |
    35 |
    36 |
    37 | 42 | updateCounter({ 43 | variables: { 44 | id: counter.id, 45 | data: { title }, 46 | }, 47 | }) 48 | } 49 | /> 50 | { 56 | updateCounter({ 57 | variables: { 58 | id: counter.id, 59 | data: { value: parseInt(value) }, 60 | }, 61 | }) 62 | }} 63 | /> 64 |
    65 |
    66 | 72 | 78 |
    79 | 80 | 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react' 2 | import classnames from 'classnames' 3 | 4 | type ModalProps = { 5 | children?: React.ReactNode 6 | maxWidth?: 'sm' | 'md' | 'lg' 7 | onClickOutside?: () => void 8 | } 9 | 10 | export function Modal(props: ModalProps) { 11 | const { children, onClickOutside, maxWidth = 'lg' } = props 12 | const contentEl = useRef(null) 13 | useEffect(() => { 14 | const listener = (e: MouseEvent) => { 15 | if (!contentEl.current?.contains(e.target as Node)) { 16 | onClickOutside?.() 17 | } 18 | } 19 | document.addEventListener('click', listener) 20 | return () => document.removeEventListener('click', listener) 21 | }, [contentEl, onClickOutside]) 22 | 23 | return ( 24 |
    25 |
    26 |
    27 |
    35 | {children} 36 |
    37 |
    38 |
    39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/ShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from './Modal' 2 | import classNames from 'classnames' 3 | import { QRCodeSVG } from 'qrcode.react' 4 | import { MdClose } from 'react-icons/md' 5 | import { useState } from 'react' 6 | 7 | type ShareModalProps = { 8 | onClose: () => void 9 | } 10 | 11 | export function ShareModal({ onClose }: ShareModalProps) { 12 | const url = window.location.href 13 | const [copied, setCopied] = useState(false) 14 | 15 | const onCopy = async () => { 16 | if (!navigator.clipboard || !navigator.clipboard.writeText) { 17 | throw Error('Clipboard API not available') 18 | } 19 | await navigator.clipboard.writeText(url) 20 | setCopied(true) 21 | } 22 | 23 | return ( 24 | 25 |
    26 |
    27 | 30 |
    31 | 32 |
    33 |

    34 | Share this space and count on multiple devices 35 |

    36 |
    37 | 38 |
    39 | 40 |

    or copy link

    41 |
    42 | 47 | 55 |
    56 |
    57 |
    58 |
    59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/TextField.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import cx from 'classnames' 3 | 4 | type TextFieldProps = React.InputHTMLAttributes & { 5 | value: string | number | null | undefined 6 | className?: string 7 | onChange: (value: string) => void 8 | } 9 | 10 | export function TextField({ value, className, onChange, type, ...inputProps }: TextFieldProps) { 11 | const [inputValue, setValue] = useState(value) 12 | useEffect(() => { 13 | setValue(value) 14 | }, [value]) 15 | 16 | return ( 17 | setValue(e.target.value)} 25 | onBlur={(e) => onChange(e.target.value)} 26 | /> 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/generated/graphql.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client' 2 | import * as Apollo from '@apollo/client' 3 | export type Maybe = T | null 4 | export type InputMaybe = Maybe 5 | export type Exact = { [K in keyof T]: T[K] } 6 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe } 7 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe } 8 | const defaultOptions = {} as const 9 | /** All built-in and custom scalars, mapped to their actual values */ 10 | export type Scalars = { 11 | ID: string 12 | String: string 13 | Boolean: boolean 14 | Int: number 15 | Float: number 16 | } 17 | 18 | export type Counter = { 19 | __typename?: 'Counter' 20 | id: Scalars['String'] 21 | title?: Maybe 22 | value: Scalars['Int'] 23 | } 24 | 25 | export type CounterInput = { 26 | title?: InputMaybe 27 | value?: InputMaybe 28 | } 29 | 30 | export type Mutation = { 31 | __typename?: 'Mutation' 32 | createCounter: Counter 33 | createSpace: Space 34 | deleteCounter: Counter 35 | deleteSpace: Space 36 | incrementCounter: Counter 37 | updateCounter: Counter 38 | updateSpace: Space 39 | } 40 | 41 | export type MutationCreateCounterArgs = { 42 | data: CounterInput 43 | spaceId: Scalars['String'] 44 | } 45 | 46 | export type MutationCreateSpaceArgs = { 47 | data: SpaceInput 48 | } 49 | 50 | export type MutationDeleteCounterArgs = { 51 | id: Scalars['String'] 52 | } 53 | 54 | export type MutationDeleteSpaceArgs = { 55 | id: Scalars['String'] 56 | } 57 | 58 | export type MutationIncrementCounterArgs = { 59 | id: Scalars['String'] 60 | step: Scalars['Int'] 61 | } 62 | 63 | export type MutationUpdateCounterArgs = { 64 | data: CounterInput 65 | id: Scalars['String'] 66 | } 67 | 68 | export type MutationUpdateSpaceArgs = { 69 | data: SpaceInput 70 | id: Scalars['String'] 71 | } 72 | 73 | export type Query = { 74 | __typename?: 'Query' 75 | space?: Maybe 76 | } 77 | 78 | export type QuerySpaceArgs = { 79 | id: Scalars['String'] 80 | } 81 | 82 | export type Space = { 83 | __typename?: 'Space' 84 | counters: Array 85 | id: Scalars['String'] 86 | title?: Maybe 87 | } 88 | 89 | export type SpaceInput = { 90 | title?: InputMaybe 91 | } 92 | 93 | export type Subscription = { 94 | __typename?: 'Subscription' 95 | spaceUpdated?: Maybe 96 | } 97 | 98 | export type SubscriptionSpaceUpdatedArgs = { 99 | id: Scalars['String'] 100 | } 101 | 102 | export type CreateCounterMutationVariables = Exact<{ 103 | spaceId: Scalars['String'] 104 | data: CounterInput 105 | }> 106 | 107 | export type CreateCounterMutation = { 108 | __typename?: 'Mutation' 109 | createCounter: { __typename?: 'Counter'; id: string; title?: string | null; value: number } 110 | } 111 | 112 | export type UpdateCounterMutationVariables = Exact<{ 113 | id: Scalars['String'] 114 | data: CounterInput 115 | }> 116 | 117 | export type UpdateCounterMutation = { 118 | __typename?: 'Mutation' 119 | updateCounter: { __typename?: 'Counter'; id: string; title?: string | null; value: number } 120 | } 121 | 122 | export type IncrementCounterMutationVariables = Exact<{ 123 | id: Scalars['String'] 124 | step: Scalars['Int'] 125 | }> 126 | 127 | export type IncrementCounterMutation = { 128 | __typename?: 'Mutation' 129 | incrementCounter: { __typename?: 'Counter'; id: string; title?: string | null; value: number } 130 | } 131 | 132 | export type DeleteCounterMutationVariables = Exact<{ 133 | id: Scalars['String'] 134 | }> 135 | 136 | export type DeleteCounterMutation = { 137 | __typename?: 'Mutation' 138 | deleteCounter: { __typename?: 'Counter'; id: string } 139 | } 140 | 141 | export type CreateSpaceMutationVariables = Exact<{ 142 | data: SpaceInput 143 | }> 144 | 145 | export type CreateSpaceMutation = { 146 | __typename?: 'Mutation' 147 | createSpace: { __typename?: 'Space'; id: string } 148 | } 149 | 150 | export type SpaceQueryVariables = Exact<{ 151 | id: Scalars['String'] 152 | }> 153 | 154 | export type SpaceQuery = { 155 | __typename?: 'Query' 156 | space?: { 157 | __typename?: 'Space' 158 | id: string 159 | title?: string | null 160 | counters: Array<{ __typename?: 'Counter'; id: string; title?: string | null; value: number }> 161 | } | null 162 | } 163 | 164 | export type SpaceUpdatedSubscriptionVariables = Exact<{ 165 | id: Scalars['String'] 166 | }> 167 | 168 | export type SpaceUpdatedSubscription = { 169 | __typename?: 'Subscription' 170 | spaceUpdated?: { 171 | __typename?: 'Space' 172 | id: string 173 | title?: string | null 174 | counters: Array<{ __typename?: 'Counter'; id: string; title?: string | null; value: number }> 175 | } | null 176 | } 177 | 178 | export type UpdateSpaceMutationVariables = Exact<{ 179 | id: Scalars['String'] 180 | data: SpaceInput 181 | }> 182 | 183 | export type UpdateSpaceMutation = { 184 | __typename?: 'Mutation' 185 | updateSpace: { __typename?: 'Space'; id: string; title?: string | null } 186 | } 187 | 188 | export type DeleteSpaceMutationVariables = Exact<{ 189 | id: Scalars['String'] 190 | }> 191 | 192 | export type DeleteSpaceMutation = { 193 | __typename?: 'Mutation' 194 | deleteSpace: { __typename?: 'Space'; id: string } 195 | } 196 | 197 | export const CreateCounterDocument = gql` 198 | mutation CreateCounter($spaceId: String!, $data: CounterInput!) { 199 | createCounter(spaceId: $spaceId, data: $data) { 200 | id 201 | title 202 | value 203 | } 204 | } 205 | ` 206 | export type CreateCounterMutationFn = Apollo.MutationFunction< 207 | CreateCounterMutation, 208 | CreateCounterMutationVariables 209 | > 210 | 211 | /** 212 | * __useCreateCounterMutation__ 213 | * 214 | * To run a mutation, you first call `useCreateCounterMutation` within a React component and pass it any options that fit your needs. 215 | * When your component renders, `useCreateCounterMutation` returns a tuple that includes: 216 | * - A mutate function that you can call at any time to execute the mutation 217 | * - An object with fields that represent the current status of the mutation's execution 218 | * 219 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 220 | * 221 | * @example 222 | * const [createCounterMutation, { data, loading, error }] = useCreateCounterMutation({ 223 | * variables: { 224 | * spaceId: // value for 'spaceId' 225 | * data: // value for 'data' 226 | * }, 227 | * }); 228 | */ 229 | export function useCreateCounterMutation( 230 | baseOptions?: Apollo.MutationHookOptions, 231 | ) { 232 | const options = { ...defaultOptions, ...baseOptions } 233 | return Apollo.useMutation( 234 | CreateCounterDocument, 235 | options, 236 | ) 237 | } 238 | export type CreateCounterMutationHookResult = ReturnType 239 | export type CreateCounterMutationResult = Apollo.MutationResult 240 | export type CreateCounterMutationOptions = Apollo.BaseMutationOptions< 241 | CreateCounterMutation, 242 | CreateCounterMutationVariables 243 | > 244 | export const UpdateCounterDocument = gql` 245 | mutation UpdateCounter($id: String!, $data: CounterInput!) { 246 | updateCounter(id: $id, data: $data) { 247 | id 248 | title 249 | value 250 | } 251 | } 252 | ` 253 | export type UpdateCounterMutationFn = Apollo.MutationFunction< 254 | UpdateCounterMutation, 255 | UpdateCounterMutationVariables 256 | > 257 | 258 | /** 259 | * __useUpdateCounterMutation__ 260 | * 261 | * To run a mutation, you first call `useUpdateCounterMutation` within a React component and pass it any options that fit your needs. 262 | * When your component renders, `useUpdateCounterMutation` returns a tuple that includes: 263 | * - A mutate function that you can call at any time to execute the mutation 264 | * - An object with fields that represent the current status of the mutation's execution 265 | * 266 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 267 | * 268 | * @example 269 | * const [updateCounterMutation, { data, loading, error }] = useUpdateCounterMutation({ 270 | * variables: { 271 | * id: // value for 'id' 272 | * data: // value for 'data' 273 | * }, 274 | * }); 275 | */ 276 | export function useUpdateCounterMutation( 277 | baseOptions?: Apollo.MutationHookOptions, 278 | ) { 279 | const options = { ...defaultOptions, ...baseOptions } 280 | return Apollo.useMutation( 281 | UpdateCounterDocument, 282 | options, 283 | ) 284 | } 285 | export type UpdateCounterMutationHookResult = ReturnType 286 | export type UpdateCounterMutationResult = Apollo.MutationResult 287 | export type UpdateCounterMutationOptions = Apollo.BaseMutationOptions< 288 | UpdateCounterMutation, 289 | UpdateCounterMutationVariables 290 | > 291 | export const IncrementCounterDocument = gql` 292 | mutation IncrementCounter($id: String!, $step: Int!) { 293 | incrementCounter(id: $id, step: $step) { 294 | id 295 | title 296 | value 297 | } 298 | } 299 | ` 300 | export type IncrementCounterMutationFn = Apollo.MutationFunction< 301 | IncrementCounterMutation, 302 | IncrementCounterMutationVariables 303 | > 304 | 305 | /** 306 | * __useIncrementCounterMutation__ 307 | * 308 | * To run a mutation, you first call `useIncrementCounterMutation` within a React component and pass it any options that fit your needs. 309 | * When your component renders, `useIncrementCounterMutation` returns a tuple that includes: 310 | * - A mutate function that you can call at any time to execute the mutation 311 | * - An object with fields that represent the current status of the mutation's execution 312 | * 313 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 314 | * 315 | * @example 316 | * const [incrementCounterMutation, { data, loading, error }] = useIncrementCounterMutation({ 317 | * variables: { 318 | * id: // value for 'id' 319 | * step: // value for 'step' 320 | * }, 321 | * }); 322 | */ 323 | export function useIncrementCounterMutation( 324 | baseOptions?: Apollo.MutationHookOptions< 325 | IncrementCounterMutation, 326 | IncrementCounterMutationVariables 327 | >, 328 | ) { 329 | const options = { ...defaultOptions, ...baseOptions } 330 | return Apollo.useMutation( 331 | IncrementCounterDocument, 332 | options, 333 | ) 334 | } 335 | export type IncrementCounterMutationHookResult = ReturnType 336 | export type IncrementCounterMutationResult = Apollo.MutationResult 337 | export type IncrementCounterMutationOptions = Apollo.BaseMutationOptions< 338 | IncrementCounterMutation, 339 | IncrementCounterMutationVariables 340 | > 341 | export const DeleteCounterDocument = gql` 342 | mutation DeleteCounter($id: String!) { 343 | deleteCounter(id: $id) { 344 | id 345 | } 346 | } 347 | ` 348 | export type DeleteCounterMutationFn = Apollo.MutationFunction< 349 | DeleteCounterMutation, 350 | DeleteCounterMutationVariables 351 | > 352 | 353 | /** 354 | * __useDeleteCounterMutation__ 355 | * 356 | * To run a mutation, you first call `useDeleteCounterMutation` within a React component and pass it any options that fit your needs. 357 | * When your component renders, `useDeleteCounterMutation` returns a tuple that includes: 358 | * - A mutate function that you can call at any time to execute the mutation 359 | * - An object with fields that represent the current status of the mutation's execution 360 | * 361 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 362 | * 363 | * @example 364 | * const [deleteCounterMutation, { data, loading, error }] = useDeleteCounterMutation({ 365 | * variables: { 366 | * id: // value for 'id' 367 | * }, 368 | * }); 369 | */ 370 | export function useDeleteCounterMutation( 371 | baseOptions?: Apollo.MutationHookOptions, 372 | ) { 373 | const options = { ...defaultOptions, ...baseOptions } 374 | return Apollo.useMutation( 375 | DeleteCounterDocument, 376 | options, 377 | ) 378 | } 379 | export type DeleteCounterMutationHookResult = ReturnType 380 | export type DeleteCounterMutationResult = Apollo.MutationResult 381 | export type DeleteCounterMutationOptions = Apollo.BaseMutationOptions< 382 | DeleteCounterMutation, 383 | DeleteCounterMutationVariables 384 | > 385 | export const CreateSpaceDocument = gql` 386 | mutation CreateSpace($data: SpaceInput!) { 387 | createSpace(data: $data) { 388 | id 389 | } 390 | } 391 | ` 392 | export type CreateSpaceMutationFn = Apollo.MutationFunction< 393 | CreateSpaceMutation, 394 | CreateSpaceMutationVariables 395 | > 396 | 397 | /** 398 | * __useCreateSpaceMutation__ 399 | * 400 | * To run a mutation, you first call `useCreateSpaceMutation` within a React component and pass it any options that fit your needs. 401 | * When your component renders, `useCreateSpaceMutation` returns a tuple that includes: 402 | * - A mutate function that you can call at any time to execute the mutation 403 | * - An object with fields that represent the current status of the mutation's execution 404 | * 405 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 406 | * 407 | * @example 408 | * const [createSpaceMutation, { data, loading, error }] = useCreateSpaceMutation({ 409 | * variables: { 410 | * data: // value for 'data' 411 | * }, 412 | * }); 413 | */ 414 | export function useCreateSpaceMutation( 415 | baseOptions?: Apollo.MutationHookOptions, 416 | ) { 417 | const options = { ...defaultOptions, ...baseOptions } 418 | return Apollo.useMutation( 419 | CreateSpaceDocument, 420 | options, 421 | ) 422 | } 423 | export type CreateSpaceMutationHookResult = ReturnType 424 | export type CreateSpaceMutationResult = Apollo.MutationResult 425 | export type CreateSpaceMutationOptions = Apollo.BaseMutationOptions< 426 | CreateSpaceMutation, 427 | CreateSpaceMutationVariables 428 | > 429 | export const SpaceDocument = gql` 430 | query Space($id: String!) { 431 | space(id: $id) { 432 | id 433 | title 434 | counters { 435 | id 436 | title 437 | value 438 | } 439 | } 440 | } 441 | ` 442 | 443 | /** 444 | * __useSpaceQuery__ 445 | * 446 | * To run a query within a React component, call `useSpaceQuery` and pass it any options that fit your needs. 447 | * When your component renders, `useSpaceQuery` returns an object from Apollo Client that contains loading, error, and data properties 448 | * you can use to render your UI. 449 | * 450 | * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; 451 | * 452 | * @example 453 | * const { data, loading, error } = useSpaceQuery({ 454 | * variables: { 455 | * id: // value for 'id' 456 | * }, 457 | * }); 458 | */ 459 | export function useSpaceQuery( 460 | baseOptions: Apollo.QueryHookOptions, 461 | ) { 462 | const options = { ...defaultOptions, ...baseOptions } 463 | return Apollo.useQuery(SpaceDocument, options) 464 | } 465 | export function useSpaceLazyQuery( 466 | baseOptions?: Apollo.LazyQueryHookOptions, 467 | ) { 468 | const options = { ...defaultOptions, ...baseOptions } 469 | return Apollo.useLazyQuery(SpaceDocument, options) 470 | } 471 | export type SpaceQueryHookResult = ReturnType 472 | export type SpaceLazyQueryHookResult = ReturnType 473 | export type SpaceQueryResult = Apollo.QueryResult 474 | export const SpaceUpdatedDocument = gql` 475 | subscription SpaceUpdated($id: String!) { 476 | spaceUpdated(id: $id) { 477 | id 478 | title 479 | counters { 480 | id 481 | title 482 | value 483 | } 484 | } 485 | } 486 | ` 487 | 488 | /** 489 | * __useSpaceUpdatedSubscription__ 490 | * 491 | * To run a query within a React component, call `useSpaceUpdatedSubscription` and pass it any options that fit your needs. 492 | * When your component renders, `useSpaceUpdatedSubscription` returns an object from Apollo Client that contains loading, error, and data properties 493 | * you can use to render your UI. 494 | * 495 | * @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; 496 | * 497 | * @example 498 | * const { data, loading, error } = useSpaceUpdatedSubscription({ 499 | * variables: { 500 | * id: // value for 'id' 501 | * }, 502 | * }); 503 | */ 504 | export function useSpaceUpdatedSubscription( 505 | baseOptions: Apollo.SubscriptionHookOptions< 506 | SpaceUpdatedSubscription, 507 | SpaceUpdatedSubscriptionVariables 508 | >, 509 | ) { 510 | const options = { ...defaultOptions, ...baseOptions } 511 | return Apollo.useSubscription( 512 | SpaceUpdatedDocument, 513 | options, 514 | ) 515 | } 516 | export type SpaceUpdatedSubscriptionHookResult = ReturnType 517 | export type SpaceUpdatedSubscriptionResult = Apollo.SubscriptionResult 518 | export const UpdateSpaceDocument = gql` 519 | mutation UpdateSpace($id: String!, $data: SpaceInput!) { 520 | updateSpace(id: $id, data: $data) { 521 | id 522 | title 523 | } 524 | } 525 | ` 526 | export type UpdateSpaceMutationFn = Apollo.MutationFunction< 527 | UpdateSpaceMutation, 528 | UpdateSpaceMutationVariables 529 | > 530 | 531 | /** 532 | * __useUpdateSpaceMutation__ 533 | * 534 | * To run a mutation, you first call `useUpdateSpaceMutation` within a React component and pass it any options that fit your needs. 535 | * When your component renders, `useUpdateSpaceMutation` returns a tuple that includes: 536 | * - A mutate function that you can call at any time to execute the mutation 537 | * - An object with fields that represent the current status of the mutation's execution 538 | * 539 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 540 | * 541 | * @example 542 | * const [updateSpaceMutation, { data, loading, error }] = useUpdateSpaceMutation({ 543 | * variables: { 544 | * id: // value for 'id' 545 | * data: // value for 'data' 546 | * }, 547 | * }); 548 | */ 549 | export function useUpdateSpaceMutation( 550 | baseOptions?: Apollo.MutationHookOptions, 551 | ) { 552 | const options = { ...defaultOptions, ...baseOptions } 553 | return Apollo.useMutation( 554 | UpdateSpaceDocument, 555 | options, 556 | ) 557 | } 558 | export type UpdateSpaceMutationHookResult = ReturnType 559 | export type UpdateSpaceMutationResult = Apollo.MutationResult 560 | export type UpdateSpaceMutationOptions = Apollo.BaseMutationOptions< 561 | UpdateSpaceMutation, 562 | UpdateSpaceMutationVariables 563 | > 564 | export const DeleteSpaceDocument = gql` 565 | mutation DeleteSpace($id: String!) { 566 | deleteSpace(id: $id) { 567 | id 568 | } 569 | } 570 | ` 571 | export type DeleteSpaceMutationFn = Apollo.MutationFunction< 572 | DeleteSpaceMutation, 573 | DeleteSpaceMutationVariables 574 | > 575 | 576 | /** 577 | * __useDeleteSpaceMutation__ 578 | * 579 | * To run a mutation, you first call `useDeleteSpaceMutation` within a React component and pass it any options that fit your needs. 580 | * When your component renders, `useDeleteSpaceMutation` returns a tuple that includes: 581 | * - A mutate function that you can call at any time to execute the mutation 582 | * - An object with fields that represent the current status of the mutation's execution 583 | * 584 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; 585 | * 586 | * @example 587 | * const [deleteSpaceMutation, { data, loading, error }] = useDeleteSpaceMutation({ 588 | * variables: { 589 | * id: // value for 'id' 590 | * }, 591 | * }); 592 | */ 593 | export function useDeleteSpaceMutation( 594 | baseOptions?: Apollo.MutationHookOptions, 595 | ) { 596 | const options = { ...defaultOptions, ...baseOptions } 597 | return Apollo.useMutation( 598 | DeleteSpaceDocument, 599 | options, 600 | ) 601 | } 602 | export type DeleteSpaceMutationHookResult = ReturnType 603 | export type DeleteSpaceMutationResult = Apollo.MutationResult 604 | export type DeleteSpaceMutationOptions = Apollo.BaseMutationOptions< 605 | DeleteSpaceMutation, 606 | DeleteSpaceMutationVariables 607 | > 608 | -------------------------------------------------------------------------------- /src/hooks/counter.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client' 2 | import { Counter, useIncrementCounterMutation } from '../generated/graphql' 3 | 4 | export const CREATE_COUNTER = gql` 5 | mutation CreateCounter($spaceId: String!, $data: CounterInput!) { 6 | createCounter(spaceId: $spaceId, data: $data) { 7 | id 8 | title 9 | value 10 | } 11 | } 12 | ` 13 | 14 | export const UPDATE_COUNTER = gql` 15 | mutation UpdateCounter($id: String!, $data: CounterInput!) { 16 | updateCounter(id: $id, data: $data) { 17 | id 18 | title 19 | value 20 | } 21 | } 22 | ` 23 | 24 | export const INCREMENT_COUNTER = gql` 25 | mutation IncrementCounter($id: String!, $step: Int!) { 26 | incrementCounter(id: $id, step: $step) { 27 | id 28 | title 29 | value 30 | } 31 | } 32 | ` 33 | 34 | export const DELETE_COUNTER = gql` 35 | mutation DeleteCounter($id: String!) { 36 | deleteCounter(id: $id) { 37 | id 38 | } 39 | } 40 | ` 41 | 42 | export function useIncrementCounter() { 43 | const [incrementCounter] = useIncrementCounterMutation() 44 | return [ 45 | (counter: Counter, step: number) => { 46 | const value = counter.value + step 47 | incrementCounter({ 48 | variables: { id: counter.id, step }, 49 | optimisticResponse: { 50 | incrementCounter: { 51 | ...counter, 52 | value, 53 | }, 54 | }, 55 | }) 56 | }, 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /src/hooks/space.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client' 2 | import { useEffect } from 'react' 3 | import { useSpaceQuery } from '../generated/graphql' 4 | 5 | export const CREATE_SPACE = gql` 6 | mutation CreateSpace($data: SpaceInput!) { 7 | createSpace(data: $data) { 8 | id 9 | } 10 | } 11 | ` 12 | 13 | export const GET_SPACE = gql` 14 | query Space($id: String!) { 15 | space(id: $id) { 16 | id 17 | title 18 | counters { 19 | id 20 | title 21 | value 22 | } 23 | } 24 | } 25 | ` 26 | 27 | export const SPACE_SUBSCRIPTION = gql` 28 | subscription SpaceUpdated($id: String!) { 29 | spaceUpdated(id: $id) { 30 | id 31 | title 32 | counters { 33 | id 34 | title 35 | value 36 | } 37 | } 38 | } 39 | ` 40 | 41 | export const UPDATE_SPACE = gql` 42 | mutation UpdateSpace($id: String!, $data: SpaceInput!) { 43 | updateSpace(id: $id, data: $data) { 44 | id 45 | title 46 | } 47 | } 48 | ` 49 | 50 | export const DELETE_SPACE = gql` 51 | mutation DeleteSpace($id: String!) { 52 | deleteSpace(id: $id) { 53 | id 54 | } 55 | } 56 | ` 57 | 58 | export function useSpace(id: string) { 59 | const { data, loading, error, subscribeToMore } = useSpaceQuery({ 60 | fetchPolicy: 'cache-and-network', 61 | nextFetchPolicy: 'cache-first', 62 | variables: { id }, 63 | }) 64 | 65 | useEffect(() => { 66 | return subscribeToMore({ 67 | document: SPACE_SUBSCRIPTION, 68 | variables: { id }, 69 | }) 70 | }, [id, subscribeToMore]) 71 | 72 | return { space: data?.space, loading, error } 73 | } 74 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { BrowserRouter as Router } from 'react-router-dom' 4 | import { GraphQLWsLink } from '@apollo/client/link/subscriptions' 5 | import { createClient } from 'graphql-ws' 6 | import { ApolloClient, HttpLink, InMemoryCache, ApolloProvider, split } from '@apollo/client' 7 | import { getMainDefinition } from '@apollo/client/utilities' 8 | 9 | import './assets/main.css' 10 | import App from './App' 11 | 12 | const protocol = document.location.protocol.replace('http', 'ws') 13 | const host = document.location.host 14 | const wsLink = new GraphQLWsLink( 15 | createClient({ 16 | url: `${protocol}//${host}/graphql`, 17 | }), 18 | ) 19 | 20 | const httpLink = new HttpLink({ uri: '/graphql' }) 21 | 22 | const link = split( 23 | ({ query }) => { 24 | const definition = getMainDefinition(query) 25 | return definition.kind === 'OperationDefinition' && definition.operation === 'subscription' 26 | }, 27 | wsLink, 28 | httpLink, 29 | ) 30 | 31 | const client = new ApolloClient({ 32 | link, 33 | cache: new InMemoryCache(), 34 | }) 35 | 36 | ReactDOM.render( 37 | 38 | 39 | 40 | 41 | 42 | 43 | , 44 | document.getElementById('root'), 45 | ) 46 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require('http-proxy-middleware') 2 | 3 | module.exports = function (app) { 4 | app.use(createProxyMiddleware('http://localhost:4000/graphql', { ws: true })) 5 | } 6 | -------------------------------------------------------------------------------- /src/views/home.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useState } from 'react' 2 | import { useHistory } from 'react-router-dom' 3 | import logo from '../assets/logo.svg' 4 | import { useCreateSpaceMutation } from '../generated/graphql' 5 | 6 | const DEFAULT_SPACE_TITLE = 'My space' 7 | 8 | export default function Home() { 9 | const [title, setTitle] = useState('') 10 | const [loading, setLoading] = useState(false) 11 | 12 | const history = useHistory() 13 | const [createSpace] = useCreateSpaceMutation() 14 | 15 | const onSubmit = async (e: FormEvent) => { 16 | e.preventDefault() 17 | setLoading(true) 18 | const { data, errors } = await createSpace({ 19 | variables: { data: { title: title || DEFAULT_SPACE_TITLE } }, 20 | }) 21 | setLoading(false) 22 | if (errors || !data) { 23 | console.error(errors, 'Failed to create space') 24 | return 25 | } 26 | history.push(`/s/${data.createSpace.id}`) 27 | } 28 | 29 | return ( 30 |
    31 |
    32 | tinycounter logo 33 |

    tinycounter

    34 |
    35 | 36 |

    37 | Simple and collaborative counter app to keep track of multiple values 38 |

    39 | 40 |
    41 |

    Start by creating a space

    42 | 43 | setTitle(e.target.value)} 49 | /> 50 | 51 | 58 |
    59 |
    60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/views/space.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useParams, Link } from 'react-router-dom' 3 | import { 4 | MdAddCircle as AddIcon, 5 | MdArrowBack as BackIcon, 6 | MdIosShare as ShareIcon, 7 | } from 'react-icons/md' 8 | 9 | import { Counter } from '../components/Counter' 10 | import { CounterModal } from '../components/CounterModal' 11 | import { TextField } from '../components/TextField' 12 | import { useSpace } from '../hooks/space' 13 | import { useIncrementCounter } from '../hooks/counter' 14 | import { useCreateCounterMutation, useUpdateSpaceMutation } from '../generated/graphql' 15 | import { ShareModal } from '../components/ShareModal' 16 | 17 | const DEFAULT_COUNTER_TITLE = 'New counter' 18 | 19 | export default function Space() { 20 | const { spaceId } = useParams<{ spaceId: string }>() 21 | const { space, loading, error } = useSpace(spaceId) 22 | const [selected, setSelected] = useState(null) 23 | const [shareModalOpened, setShareModalOpened] = useState(false) 24 | 25 | const [createCounter] = useCreateCounterMutation() 26 | const [updateSpace] = useUpdateSpaceMutation() 27 | const [incrementCounter] = useIncrementCounter() 28 | 29 | const toggleShareModal = () => { 30 | setShareModalOpened(!shareModalOpened) 31 | } 32 | 33 | const onUpdateSpaceTitle = (title: string) => { 34 | updateSpace({ 35 | variables: { 36 | id: spaceId, 37 | data: { title }, 38 | }, 39 | }) 40 | } 41 | 42 | const onAddCounter = () => { 43 | createCounter({ 44 | variables: { 45 | spaceId, 46 | data: { 47 | title: DEFAULT_COUNTER_TITLE, 48 | value: 0, 49 | }, 50 | }, 51 | }) 52 | } 53 | 54 | if (loading) { 55 | return
    Loading...
    56 | } 57 | if (error || !space) { 58 | return
    Something went wrong
    59 | } 60 | 61 | const selectedCounter = selected && space.counters.find((counter) => counter.id === selected) 62 | 63 | return ( 64 |
    65 |
    66 | 67 | 68 | 69 |
    70 | 73 | 76 |
    77 | 78 | onUpdateSpaceTitle(title)} 83 | /> 84 |
      85 | {space.counters.map((counter) => ( 86 | setSelected(counter.id)} 91 | onDecrement={(e) => { 92 | e.stopPropagation() 93 | incrementCounter(counter, -1) 94 | }} 95 | onIncrement={(e) => { 96 | e.stopPropagation() 97 | incrementCounter(counter, 1) 98 | }} 99 | /> 100 | ))} 101 |
    102 | 103 | {selectedCounter && ( 104 | setSelected(null)} 108 | /> 109 | )} 110 | 111 | {shareModalOpened && } 112 |
    113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{ts,tsx}', './public/index.html'], 3 | theme: { 4 | fontFamily: { 5 | sans: [ 6 | '-apple-system', 7 | 'BlinkMacSystemFont', 8 | 'avenir next', 9 | 'avenir', 10 | 'segoe ui', 11 | 'helvetica neue', 12 | 'helvetica', 13 | 'Ubuntu', 14 | 'roboto', 15 | 'noto', 16 | 'arial', 17 | 'sans-serif', 18 | ], 19 | }, 20 | extend: {}, 21 | }, 22 | plugins: [], 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "noImplicitAny": false, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src", "server"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "lib": ["ES2020"], 6 | "module": "CommonJS", 7 | "rootDir": "server", 8 | "outDir": "dist", 9 | "noEmit": false, 10 | "sourceMap": true 11 | }, 12 | "include": ["server"], 13 | "exclude": ["node_modules"] 14 | } 15 | --------------------------------------------------------------------------------