├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
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 | 
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 |
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 |
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 |

33 |
tinycounter
34 |
35 |
36 |
37 | Simple and collaborative counter app to keep track of multiple values
38 |
39 |
40 |
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 |
--------------------------------------------------------------------------------