diff --git a/packages/hoppscotch-app/components/teams/index.vue b/packages/hoppscotch-app/components/teams/index.vue index 64c9e501d..82636de4f 100644 --- a/packages/hoppscotch-app/components/teams/index.vue +++ b/packages/hoppscotch-app/components/teams/index.vue @@ -18,25 +18,29 @@ @click.native="displayModalAdd(true)" />
{{ $t("state.loading") }}
help_outline {{ $t("empty.teams") }}
import { gql } from "@apollo/client/core" -import { ref } from "@nuxtjs/composition-api" -import { useGQLQuery, isApolloError } from "~/helpers/apollo" +import { ref, watchEffect } from "@nuxtjs/composition-api" +import * as E from "fp-ts/Either" +import { useGQLQuery } from "~/helpers/backend/GQLClient" +import { MyTeamsQueryError } from "~/helpers/backend/QueryErrors" +import { TeamMemberRole } from "~/helpers/backend/types/TeamMemberRole" const showModalAdd = ref(false) const showModalEdit = ref(false) const editingTeam = ref({}) // TODO: Check this out const editingTeamID = ref("") -const { loading: myTeamsLoading, data: myTeams } = useGQLQuery({ - query: gql` +const myTeams = useGQLQuery< + { + myTeams: Array<{ + id: string + name: string + myRole: TeamMemberRole + ownersCount: number + members: Array<{ + user: { + photoURL: string | null + displayName: string + email: string + uid: string + } + role: TeamMemberRole + }> + }> + }, + MyTeamsQueryError +>( + gql` query GetMyTeams { myTeams { id @@ -86,8 +116,11 @@ const { loading: myTeamsLoading, data: myTeams } = useGQLQuery({ } } } - `, - pollInterval: 10000, + ` +) + +watchEffect(() => { + console.log(myTeams) }) const displayModalAdd = (shouldDisplay: boolean) => { diff --git a/packages/hoppscotch-app/helpers/backend/GQLClient.ts b/packages/hoppscotch-app/helpers/backend/GQLClient.ts new file mode 100644 index 000000000..006465542 --- /dev/null +++ b/packages/hoppscotch-app/helpers/backend/GQLClient.ts @@ -0,0 +1,165 @@ +import { + computed, + ref, + onMounted, + onBeforeUnmount, + reactive, + Ref, +} from "@nuxtjs/composition-api" +import { DocumentNode } from "graphql/language" +import { + createClient, + TypedDocumentNode, + OperationResult, + defaultExchanges, +} from "@urql/core" +import { devtoolsExchange } from "@urql/devtools" +import * as E from "fp-ts/Either" +import { pipe } from "fp-ts/function" +import { subscribe } from "wonka" +import clone from "lodash/clone" +import { getAuthIDToken } from "~/helpers/fb/auth" + +const BACKEND_GQL_URL = + process.env.CONTEXT === "production" + ? "https://api.hoppscotch.io/graphql" + : "https://api.hoppscotch.io/graphql" + +const client = createClient({ + url: BACKEND_GQL_URL, + fetchOptions: () => { + const token = getAuthIDToken() + + return { + headers: { + authorization: token ? `Bearer ${token}` : "", + }, + } + }, + exchanges: [devtoolsExchange, ...defaultExchanges], +}) + +/** + * A wrapper type for defining errors possible in a GQL operation + */ +export type GQLError = + | { + type: "network_error" + error: Error + } + | { + type: "gql_error" + err: T + } + +const DEFAULT_QUERY_OPTIONS = { + noPolling: false, + pause: undefined as Ref | undefined, +} + +type GQL_QUERY_OPTIONS = typeof DEFAULT_QUERY_OPTIONS + +type UseQueryLoading = { + loading: true +} + +type UseQueryLoaded< + QueryFailType extends string = "", + QueryReturnType = any +> = { + loading: false + data: E.Either, QueryReturnType> +} + +type UseQueryReturn = + | UseQueryLoading + | UseQueryLoaded + +export function isLoadedGQLQuery( + x: UseQueryReturn +): x is { + loading: false + data: E.Either, QueryReturnType> +} { + return !x.loading +} + +export function useGQLQuery< + QueryReturnType = any, + QueryFailType extends string = "", + QueryVariables extends object = {} +>( + query: string | DocumentNode | TypedDocumentNode, + variables?: QueryVariables, + options: Partial = DEFAULT_QUERY_OPTIONS +): + | { loading: false; data: E.Either, QueryReturnType> } + | { loading: true } { + type DataType = E.Either, QueryReturnType> + + const finalOptions = Object.assign(clone(DEFAULT_QUERY_OPTIONS), options) + + const data = ref() + + let subscription: { unsubscribe(): void } | null = null + + onMounted(() => { + const gqlQuery = client.query(query, variables) + + const processResult = (result: OperationResult) => + pipe( + // The target + result.data as QueryReturnType | undefined, + // Define what happens if data does not exist (it is an error) + E.fromNullable( + pipe( + // Take the network error value + result.error?.networkError, + // If it null, set the left to the generic error name + E.fromNullable(result.error?.name), + E.match( + // The left case (network error was null) + (gqlErr) => + >{ + type: "gql_error", + err: gqlErr as QueryFailType, + }, + // The right case (it was a GraphQL Error) + (networkErr) => + >{ + type: "network_error", + error: networkErr, + } + ) + ) + ) + ) + + if (finalOptions.noPolling) { + gqlQuery.toPromise().then((result) => { + data.value = processResult(result) + }) + } else { + subscription = pipe( + gqlQuery, + subscribe((result) => { + data.value = processResult(result) + }) + ) + } + }) + + onBeforeUnmount(() => { + subscription?.unsubscribe() + }) + + return reactive({ + loading: computed(() => !data.value), + data: data!, + }) as + | { + loading: false + data: DataType + } + | { loading: true } +} diff --git a/packages/hoppscotch-app/helpers/backend/QueryErrors.ts b/packages/hoppscotch-app/helpers/backend/QueryErrors.ts new file mode 100644 index 000000000..37d1963f8 --- /dev/null +++ b/packages/hoppscotch-app/helpers/backend/QueryErrors.ts @@ -0,0 +1,3 @@ +export type UserQueryError = "user/not_found" + +export type MyTeamsQueryError = "ea/not_invite_or_admin" diff --git a/packages/hoppscotch-app/helpers/backend/types/TeamMemberRole.ts b/packages/hoppscotch-app/helpers/backend/types/TeamMemberRole.ts new file mode 100644 index 000000000..a98db8c4c --- /dev/null +++ b/packages/hoppscotch-app/helpers/backend/types/TeamMemberRole.ts @@ -0,0 +1 @@ +export type TeamMemberRole = "OWNER" | "EDITOR" | "VIEWER" \ No newline at end of file diff --git a/packages/hoppscotch-app/helpers/backend/types/TeamName.ts b/packages/hoppscotch-app/helpers/backend/types/TeamName.ts new file mode 100644 index 000000000..ce8253a04 --- /dev/null +++ b/packages/hoppscotch-app/helpers/backend/types/TeamName.ts @@ -0,0 +1,13 @@ +import * as t from "io-ts" + +interface TeamNameBrand { + readonly TeamName: unique symbol +} + +export const TeamNameCodec = t.brand( + t.string, + (x): x is t.Branded => x.length > 6, + "TeamName" +) + +export type TeamName = t.TypeOf diff --git a/packages/hoppscotch-app/helpers/fb/auth.ts b/packages/hoppscotch-app/helpers/fb/auth.ts index eafaf2766..88767e4c2 100644 --- a/packages/hoppscotch-app/helpers/fb/auth.ts +++ b/packages/hoppscotch-app/helpers/fb/auth.ts @@ -141,6 +141,10 @@ export function initAuth() { }) } +export function getAuthIDToken(): string | null { + return authIdToken$.getValue() +} + /** * Sign user in with a popup using Google */ diff --git a/packages/hoppscotch-app/package.json b/packages/hoppscotch-app/package.json index 24d27dbe1..55dd024e5 100644 --- a/packages/hoppscotch-app/package.json +++ b/packages/hoppscotch-app/package.json @@ -35,6 +35,7 @@ "@nuxtjs/robots": "^2.5.0", "@nuxtjs/sitemap": "^2.4.0", "@nuxtjs/toast": "^3.3.1", + "@urql/core": "^2.3.3", "acorn": "^8.5.0", "acorn-walk": "^8.2.0", "axios": "^0.21.4", @@ -48,6 +49,8 @@ "graphql": "^15.6.0", "graphql-language-service-interface": "^2.8.4", "graphql-language-service-parser": "^1.9.2", + "graphql-tag": "^2.12.5", + "io-ts": "^2.2.16", "json-loader": "^0.5.7", "lodash": "^4.17.21", "mustache": "^4.2.0", @@ -66,6 +69,7 @@ "vue-textarea-autosize": "^1.1.1", "vue-tippy": "^4.11.0", "vuejs-auto-complete": "^0.9.0", + "wonka": "^4.0.15", "yargs-parser": "^20.2.9" }, "devDependencies": { @@ -90,6 +94,7 @@ "@types/esprima": "^4.0.3", "@types/lodash": "^4.14.174", "@types/splitpanes": "^2.2.1", + "@urql/devtools": "^2.0.3", "@vue/runtime-dom": "^3.2.19", "@vue/test-utils": "^1.2.2", "babel-core": "^7.0.0-bridge.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d3297026..9df89872c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,8 @@ importers: '@types/esprima': ^4.0.3 '@types/lodash': ^4.14.174 '@types/splitpanes': ^2.2.1 + '@urql/core': ^2.3.3 + '@urql/devtools': ^2.0.3 '@vue/runtime-dom': ^3.2.19 '@vue/test-utils': ^1.2.2 acorn: ^8.5.0 @@ -69,6 +71,8 @@ importers: graphql: ^15.6.0 graphql-language-service-interface: ^2.8.4 graphql-language-service-parser: ^1.9.2 + graphql-tag: ^2.12.5 + io-ts: ^2.2.16 jest: ^27.2.2 jest-serializer-vue: ^2.0.2 json-loader: ^0.5.7 @@ -102,6 +106,7 @@ importers: vue-textarea-autosize: ^1.1.1 vue-tippy: ^4.11.0 vuejs-auto-complete: ^0.9.0 + wonka: ^4.0.15 worker-loader: ^3.0.8 yargs-parser: ^20.2.9 dependencies: @@ -114,6 +119,7 @@ importers: '@nuxtjs/robots': 2.5.0 '@nuxtjs/sitemap': 2.4.0 '@nuxtjs/toast': 3.3.1 + '@urql/core': 2.3.3_graphql@15.6.0 acorn: 8.5.0 acorn-walk: 8.2.0 axios: 0.21.4 @@ -127,6 +133,8 @@ importers: graphql: 15.6.0 graphql-language-service-interface: 2.8.4_graphql@15.6.0 graphql-language-service-parser: 1.9.2_graphql@15.6.0 + graphql-tag: 2.12.5_graphql@15.6.0 + io-ts: 2.2.16_fp-ts@2.11.3 json-loader: 0.5.7 lodash: 4.17.21 mustache: 4.2.0 @@ -138,13 +146,14 @@ importers: socketio-wildcard: 2.0.0 splitpanes: 2.3.8 tern: 0.24.3 - vue-apollo: 3.0.8 + vue-apollo: 3.0.8_graphql-tag@2.12.5 vue-cli-plugin-apollo: 0.22.2_typescript@4.4.3 vue-functional-data-merge: 3.1.0 vue-github-button: 1.3.0 vue-textarea-autosize: 1.1.1 vue-tippy: 4.11.0 vuejs-auto-complete: 0.9.0 + wonka: 4.0.15 yargs-parser: 20.2.9 devDependencies: '@babel/core': 7.15.5 @@ -168,6 +177,7 @@ importers: '@types/esprima': 4.0.3 '@types/lodash': 4.14.174 '@types/splitpanes': 2.2.1 + '@urql/devtools': 2.0.3_@urql+core@2.3.3+graphql@15.6.0 '@vue/runtime-dom': 3.2.19 '@vue/test-utils': 1.2.2 babel-core: 7.0.0-bridge.0_@babel+core@7.15.5 @@ -4592,6 +4602,27 @@ packages: eslint-visitor-keys: 2.1.0 dev: true + /@urql/core/2.3.3_graphql@15.6.0: + resolution: {integrity: sha512-Bi9mafTFu0O1XZmI7/HrEk12LHZW+Fs/V1FqSJoUDgYIhARIJW6cCh3Havy1dJJ0FETxYmmQQXPf6kst+IP2qQ==} + peerDependencies: + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 + dependencies: + '@graphql-typed-document-node/core': 3.1.0_graphql@15.6.0 + graphql: 15.6.0 + wonka: 4.0.15 + dev: false + + /@urql/devtools/2.0.3_@urql+core@2.3.3+graphql@15.6.0: + resolution: {integrity: sha512-TktPLiBS9LcBPHD6qcnb8wqOVcg3Bx0iCtvQ80uPpfofwwBGJmqnQTjUdEFU6kwaLOFZULQ9+Uo4831G823mQw==} + peerDependencies: + '@urql/core': '>= 1.14.0' + graphql: '>= 0.11.0' + dependencies: + '@urql/core': 2.3.3_graphql@15.6.0 + graphql: 15.6.0 + wonka: 4.0.15 + dev: true + /@vue/babel-helper-vue-jsx-merge-props/1.2.1: resolution: {integrity: sha512-QOi5OW45e2R20VygMSNhyQHvpdUwQZqGPc748JLGCYEy+yp8fNFNdbNIGAgZmi9e+2JHPd6i6idRuqivyicIkA==} dev: false @@ -10432,7 +10463,6 @@ packages: fp-ts: ^2.5.0 dependencies: fp-ts: 2.11.3 - dev: true /ip/1.1.5: resolution: {integrity: sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=} @@ -17362,12 +17392,13 @@ packages: deprecated: Sorry but vue-analytics is no longer maintained. I would suggest you switch to vue-gtag, with love, the guy who made the package. dev: true - /vue-apollo/3.0.8: + /vue-apollo/3.0.8_graphql-tag@2.12.5: resolution: {integrity: sha512-RnkC75PMoGwl1sdZdVO3R9P51wqmgOVi4QmljkBaTzlVThVlqfkJhrBcPiw2K9EohvSagvZclNqXktyOCcXbBA==} peerDependencies: graphql-tag: ^2 dependencies: chalk: 2.4.2 + graphql-tag: 2.12.5_graphql@15.6.0 serialize-javascript: 4.0.0 throttle-debounce: 2.3.0 dev: false @@ -17865,6 +17896,9 @@ packages: babel-walk: 3.0.0-canary-5 optional: true + /wonka/4.0.15: + resolution: {integrity: sha512-U0IUQHKXXn6PFo9nqsHphVCE5m3IntqZNB9Jjn7EB1lrR7YTDY3YWgFvEvwniTzXSvOH/XMzAZaIfJF/LvHYXg==} + /word-wrap/1.2.3: resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} engines: {node: '>=0.10.0'}