From 26f5f8676e8c5c4ee5f079c1a6528fb0aaafe3cf Mon Sep 17 00:00:00 2001 From: claygorman Date: Sat, 30 Dec 2023 20:06:55 -0800 Subject: [PATCH 1/2] Implement issue creation subscription In this update, a subscription for issue creation has been implemented, enabling real-time updates when an issue is created. This includes changes in the resolvers on the backend and updating some related GraphQL queries on the frontend. The dependencies were also updated to support this feature, with packages including "@graphql-tools/schema", "graphql-postgres-subscriptions", and "graphql-ws" added to backend and the "graphql-ws" version updated on the frontend. --- backend/package.json | 4 + backend/pnpm-lock.yaml | 107 +++++++++++++++++++++- backend/src/index.js | 17 +++- backend/src/resolvers/Issue/index.js | 15 ++- backend/src/services/apollo-pg-pubsub.js | 14 +++ backend/src/type-defs.js | 5 + docker-compose.yml | 17 +++- frontend/components/KanbanBoard/index.tsx | 46 ++++++---- frontend/gql/__generated__/gql.ts | 5 + frontend/gql/__generated__/graphql.ts | 16 +++- frontend/gql/gql-queries-mutations.ts | 8 ++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 23 +++-- frontend/services/apollo-client.ts | 45 +++++++-- 14 files changed, 279 insertions(+), 44 deletions(-) create mode 100644 backend/src/services/apollo-pg-pubsub.js diff --git a/backend/package.json b/backend/package.json index 209742a..58d1edf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "@fastify/cors": "^8.4.1", "@fastify/websocket": "^8.3.0", "@graphql-tools/merge": "^9.0.1", + "@graphql-tools/schema": "^10.0.2", "@hocuspocus/extension-database": "^2.8.1", "@hocuspocus/server": "^2.8.1", "axios": "^1.6.0", @@ -28,8 +29,10 @@ "fastify-graceful-shutdown": "^3.5.1", "fastify-socket.io": "^5.0.0", "graphql": "^16.8.1", + "graphql-postgres-subscriptions": "^1.0.5", "graphql-tag": "^2.12.6", "graphql-upload": "^16.0.2", + "graphql-ws": "^5.14.3", "lodash-es": "^4.17.21", "minio": "^7.1.3", "pg": "^8.11.3", @@ -46,6 +49,7 @@ }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/ws": "^8.5.10", "chai": "^4.3.10", "mocha": "^10.2.0", "prettier": "^3.0.3", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index e32d58d..b7a6b83 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: '@graphql-tools/merge': specifier: ^9.0.1 version: 9.0.1(graphql@16.8.1) + '@graphql-tools/schema': + specifier: ^10.0.2 + version: 10.0.2(graphql@16.8.1) '@hocuspocus/extension-database': specifier: ^2.8.1 version: 2.8.1(bufferutil@4.0.8)(utf-8-validate@6.0.3)(y-protocols@1.0.6)(yjs@13.6.10) @@ -41,12 +44,18 @@ dependencies: graphql: specifier: ^16.8.1 version: 16.8.1 + graphql-postgres-subscriptions: + specifier: ^1.0.5 + version: 1.0.5(graphql@16.8.1) graphql-tag: specifier: ^2.12.6 version: 2.12.6(graphql@16.8.1) graphql-upload: specifier: ^16.0.2 version: 16.0.2(graphql@16.8.1) + graphql-ws: + specifier: ^5.14.3 + version: 5.14.3(graphql@16.8.1) lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -99,6 +108,9 @@ devDependencies: '@trivago/prettier-plugin-sort-imports': specifier: ^4.3.0 version: 4.3.0(prettier@3.0.3) + '@types/ws': + specifier: ^8.5.10 + version: 8.5.10 chai: specifier: ^4.3.10 version: 4.3.10 @@ -500,6 +512,19 @@ packages: tslib: 2.6.2 dev: false + /@graphql-tools/schema@10.0.2(graphql@16.8.1): + resolution: {integrity: sha512-TbPsIZnWyDCLhgPGnDjt4hosiNU2mF/rNtSk5BVaXWnZqvKJ6gzJV4fcHcvhRIwtscDMW2/YTnK6dLVnk8pc4w==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/merge': 9.0.1(graphql@16.8.1) + '@graphql-tools/utils': 10.0.12(graphql@16.8.1) + graphql: 16.8.1 + tslib: 2.6.2 + value-or-promise: 1.0.12 + dev: false + /@graphql-tools/schema@9.0.19(graphql@16.8.1): resolution: {integrity: sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==} peerDependencies: @@ -813,7 +838,6 @@ packages: resolution: {integrity: sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==} dependencies: undici-types: 5.26.5 - dev: false /@types/object-path@0.11.4: resolution: {integrity: sha512-4tgJ1Z3elF/tOMpA8JLVuR9spt9Ynsf7+JjqsQ2IqtiPJtcLoHoXcT6qU4E10cPFqyXX5HDm9QwIzZhBSkLxsw==} @@ -846,6 +870,12 @@ packages: resolution: {integrity: sha512-xW4qsT4UIYILu+7ZrBnfQdBYniZrMLYYK3wN9M/NdeIHgBN5pZI2/8Q7UfdWIcr5RLJv/OGENsx91JIpUUoC7Q==} dev: false + /@types/ws@8.5.10: + resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + dependencies: + '@types/node': 20.8.10 + dev: true + /@zxing/text-encoding@0.9.0: resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} requiresBuild: true @@ -1930,6 +1960,28 @@ packages: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: true + /graphql-postgres-subscriptions@1.0.5(graphql@16.8.1): + resolution: {integrity: sha512-k6aoe/0lBU8JEM4BowAX5ojQ8ovphNyMYmSWJo4bFU6IzzfM0UmdvSnVXBlwxABrJUX3IQ+tXdnQsOmQKuhTAw==} + engines: {node: '>=6'} + peerDependencies: + graphql: ^0.10.5 || ^0.11.3 || ^0.12.0 || ^0.13.0 || ^14.0.0 + dependencies: + graphql: 16.8.1 + graphql-subscriptions: 0.5.8(graphql@16.8.1) + iterall: 1.3.0 + pg: 7.18.2 + pg-ipc: 1.0.5 + dev: false + + /graphql-subscriptions@0.5.8(graphql@16.8.1): + resolution: {integrity: sha512-0CaZnXKBw2pwnIbvmVckby5Ge5e2ecmjofhYCdyeACbCly2j3WXDP/pl+s+Dqd2GQFC7y99NB+53jrt55CKxYQ==} + peerDependencies: + graphql: ^0.10.5 || ^0.11.3 || ^0.12.0 || ^0.13.0 + dependencies: + graphql: 16.8.1 + iterall: 1.3.0 + dev: false + /graphql-tag@2.12.6(graphql@16.8.1): resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} engines: {node: '>=10'} @@ -1963,6 +2015,15 @@ packages: object-path: 0.11.8 dev: false + /graphql-ws@5.14.3(graphql@16.8.1): + resolution: {integrity: sha512-F/i2xNIVbaEF2xWggID0X/UZQa2V8kqKDPO8hwmu53bVOcTL7uNkxnexeEgSCVxYBQUTUNEI8+e4LO1FOhKPKQ==} + engines: {node: '>=10'} + peerDependencies: + graphql: '>=0.11 <=16' + dependencies: + graphql: 16.8.1 + dev: false + /graphql@16.8.1: resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -2152,6 +2213,10 @@ packages: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} dev: false + /iterall@1.3.0: + resolution: {integrity: sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==} + dev: false + /javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} dev: true @@ -2617,6 +2682,10 @@ packages: dev: false optional: true + /pg-connection-string@0.1.3: + resolution: {integrity: sha512-i0NV/CrSkFTaiOQs9AGy3tq0dkSjtTd4d7DfsjeDVZAA4aIHInwfFEmriNYGGJUfZ5x6IAC/QddoUpUJjQAi0w==} + dev: false + /pg-connection-string@2.6.2: resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==} dev: false @@ -2633,6 +2702,22 @@ packages: engines: {node: '>=4.0.0'} dev: false + /pg-ipc@1.0.5: + resolution: {integrity: sha512-nF4uB5vuqvPCeyWGLRVCOLxGaXq8tVMVZ+rCkX0XGbErlUUCwpb6+uME6s6vkB2T3n8lhZwojn5frFl67I5Ugg==} + dev: false + + /pg-packet-stream@1.1.0: + resolution: {integrity: sha512-kRBH0tDIW/8lfnnOyTwKD23ygJ/kexQVXZs7gEyBljw4FYqimZFxnMMx50ndZ8In77QgfGuItS5LLclC2TtjYg==} + dev: false + + /pg-pool@2.0.10(pg@7.18.2): + resolution: {integrity: sha512-qdwzY92bHf3nwzIUcj+zJ0Qo5lpG/YxchahxIN8+ZVmXqkahKXsnl2aiJPHLYN9o5mB/leG+Xh6XKxtP7e0sjg==} + peerDependencies: + pg: '>5.0' + dependencies: + pg: 7.18.2 + dev: false + /pg-pool@3.6.1(pg@8.11.3): resolution: {integrity: sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==} peerDependencies: @@ -2656,6 +2741,20 @@ packages: postgres-interval: 1.2.0 dev: false + /pg@7.18.2: + resolution: {integrity: sha512-Mvt0dGYMwvEADNKy5PMQGlzPudKcKKzJds/VbOeZJpb6f/pI3mmoXX0JksPgI3l3JPP/2Apq7F36O63J7mgveA==} + engines: {node: '>= 4.5.0'} + dependencies: + buffer-writer: 2.0.0 + packet-reader: 1.0.0 + pg-connection-string: 0.1.3 + pg-packet-stream: 1.1.0 + pg-pool: 2.0.10(pg@7.18.2) + pg-types: 2.2.0 + pgpass: 1.0.5 + semver: 4.3.2 + dev: false + /pg@8.11.3: resolution: {integrity: sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==} engines: {node: '>= 8.0.0'} @@ -2978,6 +3077,11 @@ packages: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} dev: false + /semver@4.3.2: + resolution: {integrity: sha512-VyFUffiBx8hABJ9HYSTXLRwyZtdDHMzMtFmID1aiNAD2BZppBmJm0Hqw3p2jkgxP9BNt1pQ9RnC49P0EcXf6cA==} + hasBin: true + dev: false + /semver@7.5.4: resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} engines: {node: '>=10'} @@ -3488,7 +3592,6 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - dev: false /universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} diff --git a/backend/src/index.js b/backend/src/index.js index a983110..30690c4 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -2,10 +2,12 @@ import { ApolloServer } from '@apollo/server'; import { fastifyApolloDrainPlugin, fastifyApolloHandler } from '@as-integrations/fastify'; import * as cors from '@fastify/cors'; import { fastifyWebsocket } from '@fastify/websocket'; +import { makeExecutableSchema } from '@graphql-tools/schema'; import axios from 'axios'; import Fastify from 'fastify'; import fastifyIO from 'fastify-socket.io'; import processRequest from 'graphql-upload/processRequest.mjs'; +import { makeHandler } from 'graphql-ws/lib/use/@fastify/websocket'; import { GraphQLError } from 'graphql/error/index.js'; import { db } from './db/index.js'; @@ -25,6 +27,8 @@ import { minioClient } from './services/minio-client.js'; import { socketInit } from './socket/index.js'; import typeDefs from './type-defs.js'; +const schema = makeExecutableSchema({ typeDefs, resolvers }); + const fastify = Fastify({ logger: ENABLE_FASTIFY_LOGGING, keepAliveTimeout: 61 * 1000, @@ -114,8 +118,13 @@ const apollo = new ApolloServer({ await apollo.start(); const myContextFunction = async (request) => { + let token = request?.headers?.authorization; + + if (request?.connectionParams?.headers?.Authorization) { + token = request?.connectionParams?.headers?.Authorization; + } + // get the user token from the headers - const token = request.headers.authorization; let user = null; // Allow if introspection query only @@ -131,7 +140,7 @@ const myContextFunction = async (request) => { try { // TODO: maybe we just call the url of the caller origin const { data } = await axios.get(`${FRONTEND_HOSTNAME}/api/verify-jwt`, { - headers: { Authorization: request.headers.authorization }, + headers: { Authorization: token }, }); // TODO: We can inject from DB here the whitelist domains and emails in addition to ENV vars @@ -208,6 +217,10 @@ const myContextFunction = async (request) => { }; }; +fastify.register(async (fastify) => { + fastify.get('/graphql', { websocket: true }, makeHandler({ schema, context: myContextFunction })); +}); + fastify.post( '/graphql', fastifyApolloHandler(apollo, { diff --git a/backend/src/resolvers/Issue/index.js b/backend/src/resolvers/Issue/index.js index 7de78d8..d225039 100644 --- a/backend/src/resolvers/Issue/index.js +++ b/backend/src/resolvers/Issue/index.js @@ -1,6 +1,13 @@ import { Op } from 'sequelize'; +import pubsub from '../../services/apollo-pg-pubsub.js'; + const resolvers = { + Subscription: { + issueCreated: { + subscribe: () => pubsub.asyncIterator(['ISSUE_CREATED']), + }, + }, Query: { issues: (parent, { input: { projectId, id, search, searchOperator } }, { db }) => { let whereOr = []; @@ -175,7 +182,13 @@ const resolvers = { const issueStatus = await db.sequelize.models.IssueStatuses.findByPk(issueStatusId); - return { ...issue.toJSON(), status: issueStatus.toJSON() }; + const returnData = { ...issue.toJSON(), status: issueStatus.toJSON() }; + + pubsub.publish('ISSUE_CREATED', { + issueCreated: returnData, + }); + + return returnData; }, deleteIssue: async (parent, { input }, { db }) => { const { id } = input; diff --git a/backend/src/services/apollo-pg-pubsub.js b/backend/src/services/apollo-pg-pubsub.js new file mode 100644 index 0000000..a440983 --- /dev/null +++ b/backend/src/services/apollo-pg-pubsub.js @@ -0,0 +1,14 @@ +import { PostgresPubSub } from 'graphql-postgres-subscriptions'; +import pkg from 'pg'; + +import { SQL_URI } from './config.js'; + +const { Client } = pkg; + +const client = new Client({ + connectionString: SQL_URI, +}); +await client.connect(); +const pubsub = new PostgresPubSub({ client }); + +export default pubsub; diff --git a/backend/src/type-defs.js b/backend/src/type-defs.js index 42f03b1..37cda77 100644 --- a/backend/src/type-defs.js +++ b/backend/src/type-defs.js @@ -377,6 +377,11 @@ const typeDefs = gql` issues(input: QueryIssueInput): [Issue] issue(input: QueryIssueInput): Issue } + + # SUBSCRIPTIONS + type Subscription { + issueCreated: Issue + } `; export default typeDefs; diff --git a/docker-compose.yml b/docker-compose.yml index 662a685..3f409f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,13 +79,17 @@ services: frontend: container_name: frontend - image: ghcr.io/openpro-io/openpro-frontend:latest + build: + context: ./frontend + dockerfile: ./Dockerfile + args: + DOCKER_BUILDKIT: 1 environment: NEXT_PUBLIC_API_URL: http://backend:8080 + NEXT_PUBLIC_DEFAULT_LOGIN_PROVIDER: keycloak NEXTAUTH_URL: http://localhost:3000 - NEXTAUTH_SECRET: # openssl rand -base64 32 + NEXTAUTH_SECRET: 68a8450a7e7945f6a0c83cef2d46eae177564ac7c6c3872f55bdb04f36f568be NEXT_PUBLIC_NEXTAUTH_URL: http://localhost:3000 - NEXT_PUBLIC_DEFAULT_LOGIN_PROVIDER: # github|keycloak # Uncomment the following lines to enable keycloak OAuth #AUTH_KEYCLOAK_ID: @@ -95,6 +99,13 @@ services: # Uncomment the following lines to enable GitHub OAuth #GITHUB_CLIENT_ID: #GITHUB_CLIENT_SECRET: + AUTH_KEYCLOAK_ID: scrumboard + AUTH_KEYCLOAK_SECRET: H4yFPBaVBgsYmcwsghEVuEgOXcEkX0YU + AUTH_KEYCLOAK_ISSUER: https://auth.oauthd.me/realms/claysjira + KEYCLOAK_BASE_URL: https://auth.oauthd.me + NFTY_WS_HOST: localhost:8093 + NFTY_WS_SSL: false + PUBLIC_NFTY_HTTP_SSL: false networks: - internal ports: diff --git a/frontend/components/KanbanBoard/index.tsx b/frontend/components/KanbanBoard/index.tsx index 3f9fb4a..f6bc2fa 100644 --- a/frontend/components/KanbanBoard/index.tsx +++ b/frontend/components/KanbanBoard/index.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useSubscription } from '@apollo/client'; +import { omitDeep } from '@apollo/client/utilities'; // https://github.com/chetanverma16/dndkit-guide/tree/main/components // This guy did a pretty good job!!! - import { DndContext, DragEndEvent, @@ -11,44 +11,45 @@ import { DragOverlay, DragStartEvent, KeyboardSensor, + MouseSensor, + TouchSensor, UniqueIdentifier, closestCorners, useSensor, useSensors, - TouchSensor, - MouseSensor, } from '@dnd-kit/core'; import { SortableContext, arrayMove, sortableKeyboardCoordinates, } from '@dnd-kit/sortable'; +import { cloneDeep, isEmpty, isEqual, pick } from 'lodash'; +import { getSession } from 'next-auth/react'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; -// Components -import Container from './Container'; -import Items from './Item'; -import Modal from './Modal'; -import Input from './Input'; import { Button } from '@/components/Button'; -import { useMutation, useQuery } from '@apollo/client'; +import IssueModal from '@/components/IssueModal'; +import IssueModalContents from '@/components/IssueModal/IssueModalContents'; +import Toolbar from '@/components/KanbanBoard/Toolbar'; import { CREATE_ISSUE_MUTATION, CREATE_ISSUE_STATUS_MUTATION, GET_PROJECT_INFO, + ISSUE_CREATED_SUBSCRIPTION, ISSUE_FIELDS, UPDATE_BOARD_MUTATION, UPDATE_ISSUE_MUTATION, } from '@/gql/gql-queries-mutations'; -import { cloneDeep, isEmpty, isEqual, pick } from 'lodash'; -import { getSession } from 'next-auth/react'; -import { getDomainName } from '@/services/utils'; -import { useSearchParams } from 'next/navigation'; -import IssueModal from '@/components/IssueModal'; -import IssueModalContents from '@/components/IssueModal/IssueModalContents'; -import Toolbar from '@/components/KanbanBoard/Toolbar'; import useAuthenticatedSocket from '@/hooks/useAuthenticatedSocket'; -import { omitDeep } from '@apollo/client/utilities'; import { apolloClient } from '@/services/apollo-client'; +import { getDomainName } from '@/services/utils'; + +// Components +import Container from './Container'; +import Input from './Input'; +import Items from './Item'; +import Modal from './Modal'; type DNDType = { id: UniqueIdentifier; @@ -112,6 +113,7 @@ export default function KanbanBoard({ const [updateIssue] = useMutation(UPDATE_ISSUE_MUTATION); const [updateBoard] = useMutation(UPDATE_BOARD_MUTATION); const [addIssueStatus] = useMutation(CREATE_ISSUE_STATUS_MUTATION); + const issueCreatedSubscription = useSubscription(ISSUE_CREATED_SUBSCRIPTION); const getProjectInfo = useQuery(GET_PROJECT_INFO, { skip: !projectId, @@ -128,6 +130,14 @@ export default function KanbanBoard({ // }, // }); + useEffect(() => { + if (!issueCreatedSubscription.loading && issueCreatedSubscription.data) { + // TODO: Update the board state locally and server side + + console.log({ issueCreatedSubscription }); + } + }, [issueCreatedSubscription]); + // On page load we open the modal if there is a query param for the issue useEffect(() => { if (selectedIssueId && !isIssueModalOpen) { diff --git a/frontend/gql/__generated__/gql.ts b/frontend/gql/__generated__/gql.ts index 2f97b3a..8709d7e 100644 --- a/frontend/gql/__generated__/gql.ts +++ b/frontend/gql/__generated__/gql.ts @@ -51,6 +51,7 @@ const documents = { "\n mutation DeleteIssueLink($input: DeleteIssueLinkInput!) {\n deleteIssueLink(input: $input) {\n message\n status\n }\n }\n": types.DeleteIssueLinkDocument, "\n mutation AddUserToProject($input: AddUserToProjectInput!) {\n addUserToProject(input: $input) {\n message\n status\n }\n }\n": types.AddUserToProjectDocument, "\n mutation RemoveUserFromProject($input: RemoveUserFromProjectInput!) {\n removeUserFromProject(input: $input) {\n message\n status\n }\n }\n": types.RemoveUserFromProjectDocument, + "\n subscription IssueCreated {\n issueCreated {\n ...IssueFields\n }\n }\n": types.IssueCreatedDocument, }; /** @@ -219,6 +220,10 @@ export function gql(source: "\n mutation AddUserToProject($input: AddUserToProj * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n mutation RemoveUserFromProject($input: RemoveUserFromProjectInput!) {\n removeUserFromProject(input: $input) {\n message\n status\n }\n }\n"): (typeof documents)["\n mutation RemoveUserFromProject($input: RemoveUserFromProjectInput!) {\n removeUserFromProject(input: $input) {\n message\n status\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n subscription IssueCreated {\n issueCreated {\n ...IssueFields\n }\n }\n"): (typeof documents)["\n subscription IssueCreated {\n issueCreated {\n ...IssueFields\n }\n }\n"]; export function gql(source: string) { return (documents as any)[source] ?? {}; diff --git a/frontend/gql/__generated__/graphql.ts b/frontend/gql/__generated__/graphql.ts index e9b8992..987eaf7 100644 --- a/frontend/gql/__generated__/graphql.ts +++ b/frontend/gql/__generated__/graphql.ts @@ -445,6 +445,11 @@ export type SortBy = { order: Order; }; +export type Subscription = { + __typename?: 'Subscription'; + issueCreated?: Maybe; +}; + export type UpdateBoardInput = { backlogEnabled?: InputMaybe; containerOrder?: InputMaybe; @@ -848,6 +853,14 @@ export type RemoveUserFromProjectMutationVariables = Exact<{ export type RemoveUserFromProjectMutation = { __typename?: 'Mutation', removeUserFromProject?: { __typename?: 'MessageAndStatus', message?: string | null, status?: string | null } | null }; +export type IssueCreatedSubscriptionVariables = Exact<{ [key: string]: never; }>; + + +export type IssueCreatedSubscription = { __typename?: 'Subscription', issueCreated?: ( + { __typename?: 'Issue' } + & { ' $fragmentRefs'?: { 'IssueFieldsFragment': IssueFieldsFragment } } + ) | null }; + export const UpdateIssueStatusFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UpdateIssueStatus"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Issue"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const ViewStateIssueStatusFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewStateIssueStatusFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewStateIssueStatus"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}}]} as unknown as DocumentNode; export const ViewStateItemFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewStateItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewStateItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"status"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewStateIssueStatusFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewStateIssueStatusFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewStateIssueStatus"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}}]} as unknown as DocumentNode; @@ -885,4 +898,5 @@ export const UpdateMeDocument = {"kind":"Document","definitions":[{"kind":"Opera export const CreateIssueLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateIssueLink"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateIssueLinkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createIssueLink"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; export const DeleteIssueLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteIssueLink"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteIssueLinkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteIssueLink"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; export const AddUserToProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddUserToProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddUserToProjectInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addUserToProject"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; -export const RemoveUserFromProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveUserFromProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveUserFromProjectInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeUserFromProject"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const RemoveUserFromProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveUserFromProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveUserFromProjectInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeUserFromProject"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; +export const IssueCreatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"IssueCreated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"issueCreated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"IssueFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"IssueCommentFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"IssueComment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"reporter"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"issueId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"IssueFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Issue"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"archived"}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"status"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}},{"kind":"Field","name":{"kind":"Name","value":"reporter"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"IssueCommentFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/gql/gql-queries-mutations.ts b/frontend/gql/gql-queries-mutations.ts index e83e07c..eaf4535 100644 --- a/frontend/gql/gql-queries-mutations.ts +++ b/frontend/gql/gql-queries-mutations.ts @@ -467,3 +467,11 @@ export const REMOVE_USER_FROM_PROJECT_MUTATION = gql(/* GraphQL */ ` } } `); + +export const ISSUE_CREATED_SUBSCRIPTION = gql(/* GraphQL */ ` + subscription IssueCreated { + issueCreated { + ...IssueFields + } + } +`); diff --git a/frontend/package.json b/frontend/package.json index 1190294..0722409 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -105,6 +105,7 @@ "eslint": "^8.56.0", "eslint-config-next": "14.0.4", "eslint-config-prettier": "^9.1.0", + "graphql-ws": "^5.14.3", "postcss": "^8.4.32", "prettier": "^3.1.1", "prettier-plugin-tailwindcss": "^0.5.9", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 47994f9..7997bba 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: dependencies: '@apollo/client': specifier: ^3.8.8 - version: 3.8.8(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) + version: 3.8.8(graphql-ws@5.14.3)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) '@dnd-kit/core': specifier: ^6.1.0 version: 6.1.0(react-dom@18.2.0)(react@18.2.0) @@ -231,7 +231,7 @@ devDependencies: version: 4.3.0(prettier@3.1.1) '@types/apollo-upload-client': specifier: ^17.0.5 - version: 17.0.5(react-dom@18.2.0)(react@18.2.0) + version: 17.0.5(graphql-ws@5.14.3)(react-dom@18.2.0)(react@18.2.0) '@types/lodash': specifier: ^4.14.202 version: 4.14.202 @@ -265,6 +265,9 @@ devDependencies: eslint-config-prettier: specifier: ^9.1.0 version: 9.1.0(eslint@8.56.0) + graphql-ws: + specifier: ^5.14.3 + version: 5.14.3(graphql@16.8.1) postcss: specifier: ^8.4.32 version: 8.4.32 @@ -299,7 +302,7 @@ packages: '@jridgewell/gen-mapping': 0.3.3 '@jridgewell/trace-mapping': 0.3.20 - /@apollo/client@3.8.8(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0): + /@apollo/client@3.8.8(graphql-ws@5.14.3)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-omjd9ryGDkadZrKW6l5ktUAdS4SNaFOccYQ4ZST0HLW83y8kQaSZOCTNlpkoBUK8cv6qP8+AxOKwLm2ho8qQ+Q==} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -322,6 +325,7 @@ packages: '@wry/trie': 0.5.0 graphql: 16.8.1 graphql-tag: 2.12.6(graphql@16.8.1) + graphql-ws: 5.14.3(graphql@16.8.1) hoist-non-react-statics: 3.3.2 optimism: 0.18.0 prop-types: 15.8.1 @@ -1460,7 +1464,7 @@ packages: '@graphql-tools/utils': 10.0.11(graphql@16.8.1) '@types/ws': 8.5.10 graphql: 16.8.1 - graphql-ws: 5.14.2(graphql@16.8.1) + graphql-ws: 5.14.3(graphql@16.8.1) isomorphic-ws: 5.0.0(ws@8.15.1) tslib: 2.6.2 ws: 8.15.1 @@ -2549,10 +2553,10 @@ packages: - supports-color dev: true - /@types/apollo-upload-client@17.0.5(react-dom@18.2.0)(react@18.2.0): + /@types/apollo-upload-client@17.0.5(graphql-ws@5.14.3)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-rPKHaE4QNd06LNtBgz6hfntVO+pOQMS2yTcynrzBPg9+a/nbtJ2gus5KgzRp2rqfzmnKEc/sRGjLen/9Ot0Z2A==} dependencies: - '@apollo/client': 3.8.8(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) + '@apollo/client': 3.8.8(graphql-ws@5.14.3)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) '@types/extract-files': 13.0.0 graphql: 16.8.1 transitivePeerDependencies: @@ -3053,7 +3057,7 @@ packages: '@apollo/client': ^3.8.0 graphql: 14 - 16 dependencies: - '@apollo/client': 3.8.8(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) + '@apollo/client': 3.8.8(graphql-ws@5.14.3)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) extract-files: 13.0.0 graphql: 16.8.1 dev: false @@ -4820,14 +4824,13 @@ packages: graphql: 16.8.1 tslib: 2.6.2 - /graphql-ws@5.14.2(graphql@16.8.1): - resolution: {integrity: sha512-LycmCwhZ+Op2GlHz4BZDsUYHKRiiUz+3r9wbhBATMETNlORQJAaFlAgTFoeRh6xQoQegwYwIylVD1Qns9/DA3w==} + /graphql-ws@5.14.3(graphql@16.8.1): + resolution: {integrity: sha512-F/i2xNIVbaEF2xWggID0X/UZQa2V8kqKDPO8hwmu53bVOcTL7uNkxnexeEgSCVxYBQUTUNEI8+e4LO1FOhKPKQ==} engines: {node: '>=10'} peerDependencies: graphql: '>=0.11 <=16' dependencies: graphql: 16.8.1 - dev: true /graphql@16.8.1: resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} diff --git a/frontend/services/apollo-client.ts b/frontend/services/apollo-client.ts index 3e8867e..f511a0b 100644 --- a/frontend/services/apollo-client.ts +++ b/frontend/services/apollo-client.ts @@ -1,8 +1,15 @@ -import { from, ApolloClient, InMemoryCache, gql, split } from '@apollo/client'; +import { ApolloClient, InMemoryCache, from, gql, split } from '@apollo/client'; +import { createFragmentRegistry } from '@apollo/client/cache'; +import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { setContext } from '@apollo/client/link/context'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { getMainDefinition } from '@apollo/client/utilities'; // TODO: Why isn't typescript picking up the ./types definition for this... import createUploadLink from 'apollo-upload-client/createUploadLink.mjs'; +import axios from 'axios'; +import { createClient } from 'graphql-ws'; import { DateTime } from 'luxon'; + import { ISSUE_COMMENT_FIELDS, ISSUE_FIELDS, @@ -12,15 +19,11 @@ import { VIEW_STATE_ISSUE_STATUS_FIELDS, VIEW_STATE_ITEM_FIELDS, } from '@/gql/gql-queries-mutations'; -import { createFragmentRegistry } from '@apollo/client/cache'; import { + API_URL, NEXT_PUBLIC_API_URL, PUBLIC_NEXTAUTH_URL, - API_URL, } from '@/services/config'; -import { BatchHttpLink } from '@apollo/client/link/batch-http'; -import { getMainDefinition } from '@apollo/client/utilities'; -import axios from 'axios'; const authLink = setContext(async (_, { headers }) => { const { data } = await axios.get(`${PUBLIC_NEXTAUTH_URL}/api/get-jwt`); @@ -48,6 +51,34 @@ const uploadLink = createUploadLink({ }, }); +const wsClientLink = new GraphQLWsLink( + createClient({ + url: uri.replace('https', 'wss').replace('http', 'ws'), + connectionParams: async () => { + const { data } = await axios.get(`${PUBLIC_NEXTAUTH_URL}/api/get-jwt`); + + return { + headers: { + Authorization: data.token ? `Bearer ${data.token}` : '', + }, + }; + }, + }) +); + +const wsSplitLink = split( + ({ query }) => { + const definition = getMainDefinition(query); + + return ( + definition.kind === 'OperationDefinition' && + definition.operation === 'subscription' + ); + }, + wsClientLink, + batchLink +); + export const splitLink = split( ({ query }) => { const definition = getMainDefinition(query); @@ -59,7 +90,7 @@ export const splitLink = split( ); }, uploadLink, - batchLink + wsSplitLink ); export const apolloClient = new ApolloClient({ From fc55ed0d8447557810024119093854eecfb179fb Mon Sep 17 00:00:00 2001 From: claygorman Date: Sun, 31 Dec 2023 11:40:30 -0800 Subject: [PATCH 2/2] Add subscription for board updates Introduce a subscription for 'BoardUpdated' in the backend. This changes the front-end to subscribe to these updates, improving the synchronization of board statuses between clients. The changes include functionality for merging incoming data into the existing state and adjust the cache handling when retrieving issue data. --- backend/src/resolvers/Board/index.js | 11 ++ backend/src/type-defs.js | 1 + frontend/components/KanbanBoard/index.tsx | 121 +++++++++++++--------- frontend/gql/gql-queries-mutations.ts | 32 ++++-- frontend/services/apollo-client.ts | 12 +++ 5 files changed, 117 insertions(+), 60 deletions(-) diff --git a/backend/src/resolvers/Board/index.js b/backend/src/resolvers/Board/index.js index 76fc722..37ea990 100644 --- a/backend/src/resolvers/Board/index.js +++ b/backend/src/resolvers/Board/index.js @@ -1,4 +1,11 @@ +import pubsub from '../../services/apollo-pg-pubsub.js'; + const resolvers = { + Subscription: { + boardUpdated: { + subscribe: () => pubsub.asyncIterator(['BOARD_UPDATED']), + }, + }, Query: { boards: (parent, args, { db }) => { // TODO: should we require a project id to show boards? @@ -27,6 +34,10 @@ const resolvers = { await board.save(); + pubsub.publish('BOARD_UPDATED', { + boardUpdated: board, + }); + // TODO: fix // emitBoardUpdatedEvent(io, board.toJSON()); diff --git a/backend/src/type-defs.js b/backend/src/type-defs.js index 37cda77..845ada6 100644 --- a/backend/src/type-defs.js +++ b/backend/src/type-defs.js @@ -381,6 +381,7 @@ const typeDefs = gql` # SUBSCRIPTIONS type Subscription { issueCreated: Issue + boardUpdated: Board } `; diff --git a/frontend/components/KanbanBoard/index.tsx b/frontend/components/KanbanBoard/index.tsx index f6bc2fa..4899e91 100644 --- a/frontend/components/KanbanBoard/index.tsx +++ b/frontend/components/KanbanBoard/index.tsx @@ -33,8 +33,10 @@ import IssueModal from '@/components/IssueModal'; import IssueModalContents from '@/components/IssueModal/IssueModalContents'; import Toolbar from '@/components/KanbanBoard/Toolbar'; import { + BOARD_UPDATED_SUBSCRIPTION, CREATE_ISSUE_MUTATION, CREATE_ISSUE_STATUS_MUTATION, + GET_ISSUE_QUERY, GET_PROJECT_INFO, ISSUE_CREATED_SUBSCRIPTION, ISSUE_FIELDS, @@ -71,10 +73,21 @@ interface PageState { } const getIssueFragment = (issueId: string) => { - return apolloClient.readFragment({ - id: `Issue:${issueId}`, - fragment: ISSUE_FIELDS, - }); + // const fragment = apolloClient.readFragment({ + // id: `Issue:${issueId}`, + // fragment: ISSUE_FIELDS, + // }); + return apolloClient + .query({ + query: GET_ISSUE_QUERY, + fetchPolicy: 'cache-first', + variables: { + input: { id: issueId }, + }, + }) + .then(({ data }) => { + return data.issue; + }); }; export default function KanbanBoard({ @@ -113,7 +126,7 @@ export default function KanbanBoard({ const [updateIssue] = useMutation(UPDATE_ISSUE_MUTATION); const [updateBoard] = useMutation(UPDATE_BOARD_MUTATION); const [addIssueStatus] = useMutation(CREATE_ISSUE_STATUS_MUTATION); - const issueCreatedSubscription = useSubscription(ISSUE_CREATED_SUBSCRIPTION); + const boardUpdatedSubscription = useSubscription(BOARD_UPDATED_SUBSCRIPTION); const getProjectInfo = useQuery(GET_PROJECT_INFO, { skip: !projectId, @@ -131,12 +144,12 @@ export default function KanbanBoard({ // }); useEffect(() => { - if (!issueCreatedSubscription.loading && issueCreatedSubscription.data) { + if (!boardUpdatedSubscription.loading && boardUpdatedSubscription.data) { // TODO: Update the board state locally and server side - console.log({ issueCreatedSubscription }); + console.log({ boardUpdatedSubscription }); } - }, [issueCreatedSubscription]); + }, [boardUpdatedSubscription]); // On page load we open the modal if there is a query param for the issue useEffect(() => { @@ -300,6 +313,7 @@ export default function KanbanBoard({ } }, [saveToBackend, containers]); + // TODO: !! This is a hack to get the board to update when we add a new issue... we need to figure out a better way to do this // This is called once we fetch data from server useEffect(() => { if (getProjectInfo.loading || !getProjectInfo?.data) return; @@ -321,49 +335,54 @@ export default function KanbanBoard({ // this can happen when using the modal issue status dropdown to change the issue status versus dragging the issue to a new container incomingData.forEach((container: any) => { container.items.forEach((item: any) => { - const issueData = getIssueFragment(`${item.id}`.replace('item-', '')); - if (issueData.status.id !== container.id.replace('container-', '')) { - hasMismatchedIssueStatuses = true; - - // move to correct container - const destinationContainer = findContainerById( - `container-${issueData.status.id}` - ); - - const previousContainer = findContainerByItemId( - `item-${issueData.id}` - ); - - if (!destinationContainer || !previousContainer) return; - - const destinationContainerIndex = containers.findIndex( - (container) => container.id === destinationContainer.id - ); - const previousContainerIndex = containers.findIndex( - (container) => container.id === previousContainer.id - ); - const issueStatusId = `${issueData.status.id}`; - const issueId = `${issueData.id}`; - const previousItemIndex = previousContainer.items.findIndex( - (item) => item.id === `item-${issueData.id}` - ); - const destinationItemIndex = - destinationContainer.items.length > 0 - ? destinationContainer.items.length + 1 - : destinationContainer.items.length; - - // remove item from old container - const [removedItem] = correctedBoardState[ - previousContainerIndex - ].items.splice(previousItemIndex, 1); - - // push removed item to new container - correctedBoardState[destinationContainerIndex].items.splice( - destinationItemIndex, - 0, - removedItem - ); - } + getIssueFragment(`${item.id}`.replace('item-', '')).then( + (issueData) => { + if ( + issueData.status.id !== container.id.replace('container-', '') + ) { + hasMismatchedIssueStatuses = true; + + // move to correct container + const destinationContainer = findContainerById( + `container-${issueData.status.id}` + ); + + const previousContainer = findContainerByItemId( + `item-${issueData.id}` + ); + + if (!destinationContainer || !previousContainer) return; + + const destinationContainerIndex = containers.findIndex( + (container) => container.id === destinationContainer.id + ); + const previousContainerIndex = containers.findIndex( + (container) => container.id === previousContainer.id + ); + const issueStatusId = `${issueData.status.id}`; + const issueId = `${issueData.id}`; + const previousItemIndex = previousContainer.items.findIndex( + (item) => item.id === `item-${issueData.id}` + ); + const destinationItemIndex = + destinationContainer.items.length > 0 + ? destinationContainer.items.length + 1 + : destinationContainer.items.length; + + // remove item from old container + const [removedItem] = correctedBoardState[ + previousContainerIndex + ].items.splice(previousItemIndex, 1); + + // push removed item to new container + correctedBoardState[destinationContainerIndex].items.splice( + destinationItemIndex, + 0, + removedItem + ); + } + } + ); }); }); diff --git a/frontend/gql/gql-queries-mutations.ts b/frontend/gql/gql-queries-mutations.ts index eaf4535..787f8c5 100644 --- a/frontend/gql/gql-queries-mutations.ts +++ b/frontend/gql/gql-queries-mutations.ts @@ -1,5 +1,19 @@ import { gql } from '@apollo/client'; +export const BOARD_FIELDS = gql(/* GraphQL */ ` + fragment BoardFields on Board { + id + name + viewState { + ...ViewStateFields + } + backlogEnabled + settings + createdAt + updatedAt + } +`); + export const PROJECT_FIELDS = gql(/* GraphQL */ ` fragment ProjectFields on Project { id @@ -10,15 +24,7 @@ export const PROJECT_FIELDS = gql(/* GraphQL */ ` createdAt updatedAt boards { - id - name - viewState { - ...ViewStateFields - } - backlogEnabled - settings - createdAt - updatedAt + ...BoardFields } issueStatuses { id @@ -475,3 +481,11 @@ export const ISSUE_CREATED_SUBSCRIPTION = gql(/* GraphQL */ ` } } `); + +export const BOARD_UPDATED_SUBSCRIPTION = gql(/* GraphQL */ ` + subscription BoardUpdated { + boardUpdated { + ...BoardFields + } + } +`); diff --git a/frontend/services/apollo-client.ts b/frontend/services/apollo-client.ts index f511a0b..8e7663b 100644 --- a/frontend/services/apollo-client.ts +++ b/frontend/services/apollo-client.ts @@ -8,9 +8,11 @@ import { getMainDefinition } from '@apollo/client/utilities'; import createUploadLink from 'apollo-upload-client/createUploadLink.mjs'; import axios from 'axios'; import { createClient } from 'graphql-ws'; +import { unionBy } from 'lodash'; import { DateTime } from 'luxon'; import { + BOARD_FIELDS, ISSUE_COMMENT_FIELDS, ISSUE_FIELDS, PROJECT_ONLY_FIELDS, @@ -105,8 +107,18 @@ export const apolloClient = new ApolloClient({ ${VIEW_STATE_ISSUE_STATUS_FIELDS} ${VIEW_STATE_FIELDS} ${PROJECT_ONLY_FIELDS} + ${BOARD_FIELDS} `), typePolicies: { + ViewState: { + fields: { + items: { + merge(existing = [], incoming) { + return unionBy(existing, incoming, '__ref'); + }, + }, + }, + }, Board: { fields: { settings: {