diff --git a/components/collections/Add.vue b/components/collections/Add.vue index 545d103ee..f66b3b59d 100644 --- a/components/collections/Add.vue +++ b/components/collections/Add.vue @@ -37,9 +37,6 @@ diff --git a/components/collections/Edit.vue b/components/collections/Edit.vue index b5139d18a..417c7f776 100644 --- a/components/collections/Edit.vue +++ b/components/collections/Edit.vue @@ -16,7 +16,7 @@ type="text" id="selectLabel" v-model="name" - :placeholder="editingCollection.name" + :placeholder="placeholderCollName" @keyup.enter="saveCollection" /> @@ -37,53 +37,23 @@ diff --git a/components/collections/Folder.vue b/components/collections/my/Folder.vue similarity index 76% rename from components/collections/Folder.vue rename to components/collections/my/Folder.vue index b0650a206..4c1c0e1be 100644 --- a/components/collections/Folder.vue +++ b/components/collections/my/Folder.vue @@ -13,11 +13,12 @@ - + @@ -52,21 +53,27 @@
- @@ -121,13 +134,18 @@ export default { collectionIndex: Number, folderPath: String, doc: Boolean, + saveRequest: Boolean, isFiltered: Boolean, + collectionsType: Object, + picked: Object, }, data() { return { showChildren: false, dragging: false, confirmRemove: false, + prevCursor: "", + cursor: "", } }, subscriptions() { @@ -135,6 +153,15 @@ export default { SYNC_COLLECTIONS: getSettingSubject("syncCollections"), } }, + computed: { + isSelected() { + return ( + this.picked && + this.picked.pickedType === "my-folder" && + this.picked.folderPath === this.folderPath + ) + }, + }, methods: { syncCollections() { if (fb.currentUser !== null && this.SYNC_COLLECTIONS) { @@ -145,6 +172,16 @@ export default { } }, toggleShowChildren() { + if (this.$props.saveRequest) + this.$emit("select", { + picked: { + pickedType: "my-folder", + + collectionIndex: this.collectionIndex, + folderName: this.folder.name, + folderPath: this.folderPath, + }, + }) this.showChildren = !this.showChildren }, removeFolder() { @@ -179,6 +216,13 @@ export default { }) this.syncCollections() }, + removeRequest({ collectionIndex, folderName, requestIndex }) { + this.$emit("remove-request", { + collectionIndex, + folderName, + requestIndex, + }) + }, }, } diff --git a/components/collections/Request.vue b/components/collections/my/Request.vue similarity index 73% rename from components/collections/Request.vue rename to components/collections/my/Request.vue index 5d5b96dd2..c29de89d0 100644 --- a/components/collections/Request.vue +++ b/components/collections/my/Request.vue @@ -14,11 +14,13 @@ @click="!doc ? selectRequest() : {}" v-tooltip="!doc ? $t('use_request') : ''" > - {{ request.method }} + check_circle + + {{ request.method }} {{ request.name }}
- + @@ -60,17 +62,28 @@ diff --git a/components/collections/teams/Folder.vue b/components/collections/teams/Folder.vue new file mode 100644 index 000000000..023d5e978 --- /dev/null +++ b/components/collections/teams/Folder.vue @@ -0,0 +1,209 @@ + + + diff --git a/components/collections/teams/Request.vue b/components/collections/teams/Request.vue new file mode 100644 index 000000000..c18a04730 --- /dev/null +++ b/components/collections/teams/Request.vue @@ -0,0 +1,118 @@ + + + diff --git a/components/docs/Collection.vue b/components/docs/Collection.vue index 7cf26374f..8d665b593 100644 --- a/components/docs/Collection.vue +++ b/components/docs/Collection.vue @@ -4,10 +4,14 @@ folder {{ collection.name || $t("none") }} - + -
+
diff --git a/components/smart/Intersection.vue b/components/smart/Intersection.vue new file mode 100644 index 000000000..047015bb7 --- /dev/null +++ b/components/smart/Intersection.vue @@ -0,0 +1,36 @@ + + diff --git a/components/smart/Tabs.vue b/components/smart/Tabs.vue index c7f56b2ea..cdfe71857 100644 --- a/components/smart/Tabs.vue +++ b/components/smart/Tabs.vue @@ -117,6 +117,7 @@ export default { this.tabs.forEach((tab) => { tab.isActive = tab.id == id }) + this.$emit("tab-changed", id) }, }, } diff --git a/components/teams/Add.vue b/components/teams/Add.vue new file mode 100644 index 000000000..404315b1f --- /dev/null +++ b/components/teams/Add.vue @@ -0,0 +1,89 @@ + + + diff --git a/components/teams/Edit.vue b/components/teams/Edit.vue new file mode 100644 index 000000000..618f3cbf2 --- /dev/null +++ b/components/teams/Edit.vue @@ -0,0 +1,336 @@ + + + diff --git a/components/teams/ImportExport.vue b/components/teams/ImportExport.vue new file mode 100644 index 000000000..f532a742b --- /dev/null +++ b/components/teams/ImportExport.vue @@ -0,0 +1,168 @@ + + + diff --git a/components/teams/Team.vue b/components/teams/Team.vue new file mode 100644 index 000000000..5a9f8069f --- /dev/null +++ b/components/teams/Team.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/components/teams/index.vue b/components/teams/index.vue new file mode 100644 index 000000000..87d68550a --- /dev/null +++ b/components/teams/index.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/helpers/apollo.ts b/helpers/apollo.ts new file mode 100644 index 000000000..be574f855 --- /dev/null +++ b/helpers/apollo.ts @@ -0,0 +1,78 @@ +import { ApolloClient, HttpLink, InMemoryCache, split } from "@apollo/client/core" +import { WebSocketLink } from "@apollo/client/link/ws" +import { setContext } from "@apollo/client/link/context" +import { fb } from "./fb" +import { getMainDefinition } from "@apollo/client/utilities" + +let authToken: String | null = null + +export function registerApolloAuthUpdate() { + fb.idToken$.subscribe((token: String | null) => { + authToken = token + }) +} + +/** + * Injects auth token if available + */ +const authLink = setContext((_, { headers }) => { + if (authToken) { + return { + headers: { + ...headers, + authorization: `Bearer ${authToken}`, + }, + } + } else { + return { + headers, + } + } +}) + +const httpLink = new HttpLink({ + uri: + process.env.CONTEXT === "production" + ? "https://api.hoppscotch.io/graphql" + : "https://api.hoppscotch.io/graphql", +}) + +const wsLink = new WebSocketLink({ + uri: + process.env.CONTEXT === "production" + ? "wss://api.hoppscotch.io/graphql" + : "wss://api.hoppscotch.io/graphql", + options: { + reconnect: true, + lazy: true, + connectionParams: () => { + return { + authorization: `Bearer ${authToken}`, + } + }, + }, +}) + +const splitLink = split( + ({ query }) => { + const definition = getMainDefinition(query) + return definition.kind === "OperationDefinition" && definition.operation === "subscription" + }, + wsLink, + httpLink +) + +export const apolloClient = new ApolloClient({ + link: authLink.concat(splitLink), + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: "network-only", + errorPolicy: "ignore", + }, + watchQuery: { + fetchPolicy: "network-only", + errorPolicy: "ignore", + }, + }, +}) diff --git a/helpers/fb.js b/helpers/fb.js index 2d30aa65d..7ee3eacd6 100644 --- a/helpers/fb.js +++ b/helpers/fb.js @@ -32,6 +32,7 @@ export class FirebaseInstance { this.usersCollection = this.app.firestore().collection("users") this.currentUser = null + this.idToken = null this.currentFeeds = [] this.currentSettings = [] this.currentHistory = [] @@ -76,6 +77,8 @@ export class FirebaseInstance { }) this.app.auth().onAuthStateChanged((user) => { + this.currentUser$.next(user) + if (user) { this.currentUser = user diff --git a/helpers/teams/BackendUserInfo.ts b/helpers/teams/BackendUserInfo.ts new file mode 100644 index 000000000..2c42d1398 --- /dev/null +++ b/helpers/teams/BackendUserInfo.ts @@ -0,0 +1,86 @@ +import { fb } from "../fb" +import { BehaviorSubject } from "rxjs" +import { apolloClient } from "../apollo" +import gql from "graphql-tag" + +/* + * This file deals with interfacing data provided by the + * Hoppscotch Backend server + */ + +/** + * Defines the information provided about a user + */ +interface UserInfo { + /** + * UID of the user + */ + uid: string + /** + * Displayable name of the user (or null if none available) + */ + displayName: string | null + /** + * Email of the user (or null if none available) + */ + email: string | null + /** + * URL to the profile photo of the user (or null if none available) + */ + photoURL: string | null + /** + * Whether the user has access to Early Access features + */ + eaInvited: boolean +} + +/** + * An observable subject onto the currently logged in user info (is null if not logged in) + */ +export const currentUserInfo$ = new BehaviorSubject(null) + +/** + * Initializes the currenUserInfo$ view and sets up its update mechanism + */ +export async function initUserInfo() { + await updateUserInfo() + + fb.idToken$.subscribe((token) => { + if (token) { + updateUserInfo() + } else { + currentUserInfo$.next(null) + } + }) +} + +/** + * Runs the actual user info fetching + */ +async function updateUserInfo() { + try { + const { data } = await apolloClient.query({ + query: gql` + query GetUserInfo { + me { + uid + displayName + email + photoURL + eaInvited + } + } + `, + }) + + currentUserInfo$.next({ + uid: data.me.uid, + displayName: data.me.displayName, + email: data.me.email, + photoURL: data.me.photoURL, + eaInvited: data.me.eaInvited, + }) + } catch (e) { + currentUserInfo$.next(null) + } +} diff --git a/helpers/teams/TeamCollection.ts b/helpers/teams/TeamCollection.ts new file mode 100644 index 000000000..7076f0aba --- /dev/null +++ b/helpers/teams/TeamCollection.ts @@ -0,0 +1,11 @@ +import { TeamRequest } from "./TeamRequest" + +/** + * Defines how a Team Collection is represented in the TeamCollectionAdapter + */ +export interface TeamCollection { + id: string + title: string + children: TeamCollection[] | null + requests: TeamRequest[] | null +} diff --git a/helpers/teams/TeamCollectionAdapter.ts b/helpers/teams/TeamCollectionAdapter.ts new file mode 100644 index 000000000..64697e793 --- /dev/null +++ b/helpers/teams/TeamCollectionAdapter.ts @@ -0,0 +1,540 @@ +import { BehaviorSubject } from "rxjs" +import { TeamCollection } from "./TeamCollection" +import { TeamRequest } from "./TeamRequest" +import { apolloClient } from "~/helpers/apollo" +import { rootCollectionsOfTeam, getCollectionChildren, getCollectionRequests } from "./utils" +import { gql } from "graphql-tag" +import pull from "lodash/pull" +import remove from "lodash/remove" + +/* + * NOTE: These functions deal with REFERENCES to objects and mutates them, for a simpler implementation. + * Be careful when you play with these. + * + * I am not a fan of mutating references but this is so much simpler compared to mutating clones + * - Andrew + */ + +/** + * Finds the parent of a collection and returns the REFERENCE (or null) + * + * @param {TeamCollection[]} tree - The tree to look in + * @param {string} collID - ID of the collection to find the parent of + * @param {TeamCollection} currentParent - (used for recursion, do not set) The parent in the current iteration (undefined if root) + * + * @returns REFERENCE to the collecton or null if not found or the collection is in root + */ +function findParentOfColl( + tree: TeamCollection[], + collID: string, + currentParent?: TeamCollection +): TeamCollection | null { + for (const coll of tree) { + // If the root is parent, return null + if (coll.id === collID) return currentParent ? currentParent : null + + // Else run it in children + if (coll.children) { + const result = findParentOfColl(coll.children, collID, coll) + if (result) return result + } + } + + return null +} + +/** + * Finds and returns a REFERENCE collection in the given tree (or null) + * + * @param {TeamCollection[]} tree - The tree to look in + * @param {string} targetID - The ID of the collection to look for + * + * @returns REFERENCE to the collection or null if not found + */ +function findCollInTree(tree: TeamCollection[], targetID: string): TeamCollection | null { + for (const coll of tree) { + // If the direct child matched, then return that + if (coll.id === targetID) return coll + + // Else run it in the children + if (coll.children) { + const result = findCollInTree(coll.children, targetID) + if (result) return result + } + } + + // If nothing matched, return null + return null +} + +/** + * Finds and returns a REFERENCE to the collection containing a given request ID in tree (or null) + * + * @param {TeamCollection[]} tree - The tree to look in + * @param {string} reqID - The ID of the request to look for + * + * @returns REFERENCE to the collection or null if request not found + */ +function findCollWithReqIDInTree(tree: TeamCollection[], reqID: string): TeamCollection | null { + for (const coll of tree) { + // Check in root collections (if expanded) + if (coll.requests) { + if (coll.requests.find((req) => req.id === reqID)) return coll + } + + // Check in children of collections + if (coll.children) { + const result = findCollWithReqIDInTree(coll.children, reqID) + if (result) return result + } + } + + // No matches + return null +} + +/** + * Finds and returns a REFERENCE to the request with the given ID (or null) + * + * @param {TeamCollection[]} tree - The tree to look in + * @param {string} reqID - The ID of the request to look for + * + * @returns REFERENCE to the request or null if request not found + */ +function findReqInTree(tree: TeamCollection[], reqID: string): TeamRequest | null { + for (const coll of tree) { + // Check in root collections (if expanded) + if (coll.requests) { + const match = coll.requests.find((req) => req.id === reqID) + if (match) return match + } + + // Check in children of collections + if (coll.children) { + const match = findReqInTree(coll.children, reqID) + if (match) return match + } + } + + // No matches + return null +} + +/** + * Updates a collection in the tree with the specified data + * + * @param {TeamCollection[]} tree - The tree to update in (THIS WILL BE MUTATED!) + * @param {Partial & Pick} updateColl - An object defining all the fields that should be updated (ID is required to find the target collection) + */ +function updateCollInTree( + tree: TeamCollection[], + updateColl: Partial & Pick +) { + const el = findCollInTree(tree, updateColl.id) + + // If no match, stop the operation + if (!el) return + + // Update all the specified keys + Object.assign(el, updateColl) +} + +/** + * Deletes a collection in the tree + * + * @param {TeamCollection[]} tree - The tree to delete in (THIS WILL BE MUTATED!) + * @param {string} targetID - ID of the collection to delete + */ +function deleteCollInTree(tree: TeamCollection[], targetID: string) { + // Get the parent owning the collection + const parent = findParentOfColl(tree, targetID) + + // If we found a parent, update it + if (parent && parent.children) { + parent.children = parent.children.filter((coll) => coll.id !== targetID) + } + + // If there is no parent, it could mean: + // 1. The collection with that ID does not exist + // 2. The collection is in root (therefore, no parent) + + // Let's look for element, if not exist, then stop + const el = findCollInTree(tree, targetID) + if (!el) return + + // Collection exists, so this should be in root, hence removing element + pull(tree, el) +} + +/** + * TeamCollectionAdapter provides a reactive collections list for a specific team + */ +export default class TeamCollectionAdapter { + /** + * The reactive list of collections + * + * A new value is emitted when there is a change + * (Use views instead) + */ + collections$: BehaviorSubject + + // Fields for subscriptions, used for destroying once not needed + private teamCollectionAdded$: ZenObservable.Subscription | null + private teamCollectionUpdated$: ZenObservable.Subscription | null + private teamCollectionRemoved$: ZenObservable.Subscription | null + private teamRequestAdded$: ZenObservable.Subscription | null + private teamRequestUpdated$: ZenObservable.Subscription | null + private teamRequestDeleted$: ZenObservable.Subscription | null + + /** + * @constructor + * + * @param {string | null} teamID - ID of the team to listen to, or null if none decided and the adapter should stand by + */ + constructor(private teamID: string | null) { + this.collections$ = new BehaviorSubject([]) + this.teamCollectionAdded$ = null + this.teamCollectionUpdated$ = null + this.teamCollectionRemoved$ = null + this.teamRequestAdded$ = null + this.teamRequestDeleted$ = null + this.teamRequestUpdated$ = null + + if (this.teamID) this.initialize() + } + + /** + * Updates the team the adapter is looking at + * + * @param {string | null} newTeamID - ID of the team to listen to, or null if none decided and the adapter should stand by + */ + changeTeamID(newTeamID: string | null) { + this.collections$.next([]) + + this.teamID = newTeamID + + if (this.teamID) this.initialize() + } + + /** + * Unsubscribes from the subscriptions + * NOTE: Once this is called, no new updates to the tree will be detected + */ + unsubscribeSubscriptions() { + this.teamCollectionAdded$?.unsubscribe() + this.teamCollectionUpdated$?.unsubscribe() + this.teamCollectionRemoved$?.unsubscribe() + this.teamRequestAdded$?.unsubscribe() + this.teamRequestDeleted$?.unsubscribe() + this.teamRequestUpdated$?.unsubscribe() + } + + /** + * Initializes the adapter + */ + private async initialize() { + await this.loadRootCollections() + this.registerSubscriptions() + } + + /** + * Loads the root collections + */ + private async loadRootCollections(): Promise { + const colls = await rootCollectionsOfTeam(apolloClient, this.teamID) + this.collections$.next(colls) + } + + /** + * Performs addition of a collection to the tree + * + * @param {TeamCollection} collection - The collection to add to the tree + * @param {string | null} parentCollectionID - The parent of the new collection, pass null if this collection is in root + */ + private addCollection(collection: TeamCollection, parentCollectionID: string | null) { + const tree = this.collections$.value + + if (!parentCollectionID) { + tree.push(collection) + } else { + const parentCollection = findCollInTree(tree, parentCollectionID) + + if (!parentCollection) return + + if (parentCollection.children != null) { + parentCollection.children.push(collection) + } else { + parentCollection.children = [collection] + } + } + + this.collections$.next(tree) + } + + /** + * Updates an existing collection in tree + * + * @param {Partial & Pick} collectionUpdate - Object defining the fields that need to be updated (ID is required to find the target) + */ + private updateCollection(collectionUpdate: Partial & Pick) { + const tree = this.collections$.value + + updateCollInTree(tree, collectionUpdate) + + this.collections$.next(tree) + } + + /** + * Removes a collection from the tree + * + * @param {string} collectionID - ID of the collection to remove + */ + private removeCollection(collectionID: string) { + const tree = this.collections$.value + + deleteCollInTree(tree, collectionID) + + this.collections$.next(tree) + } + + /** + * Adds a request to the tree + * + * @param {TeamRequest} request - The request to add to the tree + */ + private addRequest(request: TeamRequest) { + const tree = this.collections$.value + + // Check if we have the collection (if not, then not loaded?) + const coll = findCollInTree(tree, request.collectionID) + if (!coll) return // Ignore add request + + // Collection is not expanded + if (!coll.requests) return + + // Collection is expanded hence append request + coll.requests.push(request) + + this.collections$.next(tree) + } + + /** + * Removes a request from the tree + * + * @param {string} requestID - ID of the request to remove + */ + private removeRequest(requestID: string) { + const tree = this.collections$.value + + // Find request in tree, don't attempt if no collection or no requests (expansion?) + const coll = findCollWithReqIDInTree(tree, requestID) + if (!coll || !coll.requests) return + + // Remove the collection + remove(coll.requests, (req) => req.id === requestID) + + // Publish new tree + this.collections$.next(tree) + } + + /** + * Updates the request in tree + * + * @param {Partial & Pick} requestUpdate - Object defining all the fields to update in request (ID of the request is required) + */ + private updateRequest(requestUpdate: Partial & Pick) { + const tree = this.collections$.value + + // Find request, if not present, don't update + const req = findReqInTree(tree, requestUpdate.id) + if (!req) return + + Object.assign(req, requestUpdate) + + this.collections$.next(tree) + } + + /** + * Registers the subscriptions to listen to team collection updates + */ + registerSubscriptions() { + this.teamCollectionAdded$ = apolloClient + .subscribe({ + query: gql` + subscription TeamCollectionAdded($teamID: String!) { + teamCollectionAdded(teamID: $teamID) { + id + title + parent { + id + } + } + } + `, + variables: { + teamID: this.teamID, + }, + }) + .subscribe(({ data }) => { + this.addCollection( + { + id: data.teamCollectionAdded.id, + children: null, + requests: null, + title: data.teamCollectionAdded.title, + }, + data.teamCollectionAdded.parent?.id + ) + }) + + this.teamCollectionUpdated$ = apolloClient + .subscribe({ + query: gql` + subscription TeamCollectionUpdated($teamID: String!) { + teamCollectionUpdated(teamID: $teamID) { + id + title + parent { + id + } + } + } + `, + variables: { + teamID: this.teamID, + }, + }) + .subscribe(({ data }) => { + this.updateCollection({ + id: data.teamCollectionUpdated.id, + title: data.teamCollectionUpdated.title, + }) + }) + + this.teamCollectionRemoved$ = apolloClient + .subscribe({ + query: gql` + subscription TeamCollectionRemoved($teamID: String!) { + teamCollectionRemoved(teamID: $teamID) + } + `, + variables: { + teamID: this.teamID, + }, + }) + .subscribe(({ data }) => { + this.removeCollection(data.teamCollectionRemoved) + }) + + this.teamRequestAdded$ = apolloClient + .subscribe({ + query: gql` + subscription TeamRequestAdded($teamID: String!) { + teamRequestAdded(teamID: $teamID) { + id + collectionID + request + title + } + } + `, + variables: { + teamID: this.teamID, + }, + }) + .subscribe(({ data }) => { + this.addRequest({ + id: data.teamRequestAdded.id, + collectionID: data.teamRequestAdded.collectionID, + request: JSON.parse(data.teamRequestAdded.request), + title: data.teamRequestAdded.title, + }) + }) + + this.teamRequestUpdated$ = apolloClient + .subscribe({ + query: gql` + subscription TeamRequestUpdated($teamID: String!) { + teamRequestUpdated(teamID: $teamID) { + id + collectionID + request + title + } + } + `, + variables: { + teamID: this.teamID, + }, + }) + .subscribe(({ data }) => { + this.updateRequest({ + id: data.teamRequestUpdated.id, + collectionID: data.teamRequestUpdated.collectionID, + request: JSON.parse(data.teamRequestUpdated.request), + title: data.teamRequestUpdated.title, + }) + }) + + this.teamRequestDeleted$ = apolloClient + .subscribe({ + query: gql` + subscription TeamRequestDeleted($teamID: String!) { + teamRequestDeleted(teamID: $teamID) + } + `, + variables: { + teamID: this.teamID, + }, + }) + .subscribe(({ data }) => { + this.removeRequest(data.teamRequestDeleted) + }) + } + + /** + * Expands a collection on the tree + * + * When a collection is loaded initially in the adapter, children and requests are not loaded (they will be set to null) + * Upon expansion those two fields will be populated + * + * @param {string} collectionID - The ID of the collection to expand + */ + async expandCollection(collectionID: string): Promise { + // TODO: While expanding one collection, block (or queue) the expansion of the other, to avoid race conditions + const tree = this.collections$.value + + const collection = findCollInTree(tree, collectionID) + + if (!collection) return + + if (collection.children != null) return + + const collections: TeamCollection[] = ( + await getCollectionChildren(apolloClient, collectionID) + ).map((el) => { + return { + id: el.id, + title: el.title, + children: null, + requests: null, + } + }) + + const requests: TeamRequest[] = ( + await getCollectionRequests(apolloClient, collectionID) + ).map((el) => { + return { + id: el.id, + collectionID: collectionID, + title: el.title, + request: JSON.parse(el.request), + } + }) + + collection.children = collections + collection.requests = requests + + this.collections$.next(tree) + } +} diff --git a/helpers/teams/TeamMemberAdapter.ts b/helpers/teams/TeamMemberAdapter.ts new file mode 100644 index 000000000..0eebf4a79 --- /dev/null +++ b/helpers/teams/TeamMemberAdapter.ts @@ -0,0 +1,138 @@ +import { BehaviorSubject } from "rxjs" +import { apolloClient } from "~/helpers/apollo" +import gql from "graphql-tag" +import cloneDeep from "lodash/cloneDeep" + +interface TeamsTeamMember { + user: { + uid: string + email: string + } + role: "OWNER" | "EDITOR" | "VIEWER" +} + +export default class TeamMemberAdapter { + members$: BehaviorSubject + + private teamMemberAdded$: ZenObservable.Subscription | null + private teamMemberRemoved$: ZenObservable.Subscription | null + private teamMemberUpdated$: ZenObservable.Subscription | null + + constructor(private teamID: string | null) { + this.members$ = new BehaviorSubject([]) + + this.teamMemberAdded$ = null + this.teamMemberUpdated$ = null + this.teamMemberRemoved$ = null + + if (this.teamID) this.initialize() + } + + changeTeamID(newTeamID: string | null) { + this.members$.next([]) + + this.teamID = newTeamID + + if (this.teamID) this.initialize() + } + + unsubscribeSubscriptions() { + this.teamMemberAdded$?.unsubscribe() + this.teamMemberRemoved$?.unsubscribe() + this.teamMemberUpdated$?.unsubscribe() + } + + private async initialize() { + await this.loadTeamMembers() + this.registerSubscriptions() + } + + private async loadTeamMembers(): Promise { + const { data } = await apolloClient.query({ + query: gql` + query GetTeamMembers($teamID: String!) { + team(teamID: $teamID) { + members { + user { + uid + email + } + role + } + } + } + `, + variables: { + teamID: this.teamID, + }, + }) + + this.members$.next(data.team.members) + } + + private registerSubscriptions() { + this.teamMemberAdded$ = apolloClient + .subscribe({ + query: gql` + subscription TeamMemberAdded($teamID: String!) { + teamMemberAdded(teamID: $teamID) { + user { + uid + email + } + role + } + } + `, + variables: { + teamID: this.teamID, + }, + }) + .subscribe(({ data }) => { + this.members$.next([...this.members$.value, data.teamMemberAdded]) + }) + + this.teamMemberRemoved$ = apolloClient + .subscribe({ + query: gql` + subscription TeamMemberRemoved($teamID: String!) { + teamMemberRemoved(teamID: $teamID) + } + `, + variables: { + teamID: this.teamID, + }, + }) + .subscribe(({ data }) => { + this.members$.next( + this.members$.value.filter((el) => el.user.uid !== data.teamMemberRemoved) + ) + }) + + this.teamMemberUpdated$ = apolloClient + .subscribe({ + query: gql` + subscription TeamMemberUpdated($teamID: String!) { + teamMemberUpdated(teamID: $teamID) { + user { + uid + email + } + role + } + } + `, + variables: { + teamID: this.teamID, + }, + }) + .subscribe(({ data }) => { + const list = cloneDeep(this.members$.value) + const obj = list.find((el) => el.user.uid === data.teamMemberUpdated.user.uid) + + if (!obj) return + + Object.assign(obj, data.teamMemberUpdated) + }) + } +} diff --git a/helpers/teams/TeamRequest.ts b/helpers/teams/TeamRequest.ts new file mode 100644 index 000000000..fdafb6e09 --- /dev/null +++ b/helpers/teams/TeamRequest.ts @@ -0,0 +1,9 @@ +/** + * Defines how a Teams request is represented in TeamCollectionAdapter + */ +export interface TeamRequest { + id: string; + collectionID: string; + title: string; + request: any; +} diff --git a/helpers/teams/utils.js b/helpers/teams/utils.js new file mode 100644 index 000000000..e284d5261 --- /dev/null +++ b/helpers/teams/utils.js @@ -0,0 +1,547 @@ +import { ApolloClient } from "@apollo/client/core" +import gql from "graphql-tag" +import { BehaviorSubject } from "rxjs" + +/** + * Returns an observable list of team members in the given Team + * + * @param {ApolloClient} apollo - Instance of ApolloClient + * @param {string} teamID - ID of the team to observe + * + * @returns {{user: {uid: string, email: string}, role: 'OWNER' | 'EDITOR' | 'VIEWER'}} + */ +export async function getLiveTeamMembersList(apollo, teamID) { + const subject = new BehaviorSubject([]) + + const { data } = await apollo.query({ + query: gql` + query GetTeamMembers($teamID: String!) { + team(teamID: $teamID) { + members { + user { + uid + email + } + role + } + } + } + `, + variables: { + teamID, + }, + }) + + subject.next(data.team.members) + + const addedSub = apollo + .subscribe({ + query: gql` + subscription TeamMemberAdded($teamID: String!) { + teamMemberAdded(teamID: $teamID) { + user { + uid + email + } + role + } + } + `, + variables: { + teamID, + }, + }) + .subscribe(({ data }) => { + subject.next([...subject.value, data.teamMemberAdded]) + }) + + const updateSub = apollo + .subscribe({ + query: gql` + subscription TeamMemberUpdated($teamID: String!) { + teamMemberUpdated(teamID: $teamID) { + user { + uid + email + } + role + } + } + `, + variables: { + teamID, + }, + }) + .subscribe(({ data }) => { + const val = subject.value.find( + (member) => member.user.uid === data.teamMemberUpdated.user.uid + ) + + if (!val) return + + Object.assign(val, data.teamMemberUpdated) + }) + + const removeSub = apollo + .subscribe({ + query: gql` + subscription TeamMemberRemoved($teamID: String!) { + teamMemberRemoved(teamID: $teamID) + } + `, + variables: { + teamID, + }, + }) + .subscribe(({ data }) => { + subject.next( + subject.value.filter((member) => member.user.uid !== data.teamMemberAdded.user.uid) + ) + }) + + const mainSub = subject.subscribe({ + complete() { + addedSub.unsubscribe() + updateSub.unsubscribe() + removeSub.unsubscribe() + + mainSub.unsubscribe() + }, + }) + + return subject +} + +export async function createTeam(apollo, name) { + return apollo.mutate({ + mutation: gql` + mutation($name: String!) { + createTeam(name: $name) { + name + } + } + `, + variables: { + name: name, + }, + }) +} + +export async function addTeamMemberByEmail(apollo, userRole, userEmail, teamID) { + return apollo.mutate({ + mutation: gql` + mutation addTeamMemberByEmail( + $userRole: TeamMemberRole! + $userEmail: String! + $teamID: String! + ) { + addTeamMemberByEmail(userRole: $userRole, userEmail: $userEmail, teamID: $teamID) { + role + } + } + `, + variables: { + userRole: userRole, + userEmail: userEmail, + teamID: teamID, + }, + }) +} + +export async function updateTeamMemberRole(apollo, userID, newRole, teamID) { + return apollo.mutate({ + mutation: gql` + mutation updateTeamMemberRole( + $newRole: TeamMemberRole! + $userUid: String! + $teamID: String! + ) { + updateTeamMemberRole(newRole: $newRole, userUid: $userUid, teamID: $teamID) { + role + } + } + `, + variables: { + newRole: newRole, + userUid: userID, + teamID: teamID, + }, + }) +} + +export async function renameTeam(apollo, name, teamID) { + return apollo.mutate({ + mutation: gql` + mutation renameTeam($newName: String!, $teamID: String!) { + renameTeam(newName: $newName, teamID: $teamID) { + id + } + } + `, + variables: { + newName: name, + teamID: teamID, + }, + }) +} + +export async function removeTeamMember(apollo, userID, teamID) { + return apollo.mutate({ + mutation: gql` + mutation removeTeamMember($userUid: String!, $teamID: String!) { + removeTeamMember(userUid: $userUid, teamID: $teamID) + } + `, + variables: { + userUid: userID, + teamID: teamID, + }, + }) +} + +export async function deleteTeam(apollo, teamID) { + let response = undefined + while (true) { + response = await apollo.mutate({ + mutation: gql` + mutation($teamID: String!) { + deleteTeam(teamID: $teamID) + } + `, + variables: { + teamID: teamID, + }, + }) + if (response != undefined) break + } + return response +} + +export async function exitTeam(apollo, teamID) { + apollo.mutate({ + mutation: gql` + mutation($teamID: String!) { + leaveTeam(teamID: $teamID) + } + `, + variables: { + teamID: teamID, + }, + }) +} + +export async function rootCollectionsOfTeam(apollo, teamID) { + var collections = [] + var cursor = "" + while (true) { + var response = await apollo.query({ + query: gql` + query rootCollectionsOfTeam($teamID: String!, $cursor: String!) { + rootCollectionsOfTeam(teamID: $teamID, cursor: $cursor) { + id + title + } + } + `, + variables: { + teamID: teamID, + cursor: cursor, + }, + fetchPolicy: "no-cache", + }) + if (response.data.rootCollectionsOfTeam.length == 0) break + response.data.rootCollectionsOfTeam.forEach((collection) => { + collections.push(collection) + }) + cursor = collections[collections.length - 1].id + } + return collections +} + +export async function getCollectionChildren(apollo, collectionID) { + var children = [] + var response = await apollo.query({ + query: gql` + query getCollectionChildren($collectionID: String!) { + collection(collectionID: $collectionID) { + children { + id + title + } + } + } + `, + variables: { + collectionID: collectionID, + }, + fetchPolicy: "no-cache", + }) + response.data.collection.children.forEach((child) => { + children.push(child) + }) + return children +} + +export async function getCollectionRequests(apollo, collectionID) { + var requests = [] + var cursor = "" + while (true) { + var response = await apollo.query({ + query: gql` + query getCollectionRequests($collectionID: String!, $cursor: String) { + requestsInCollection(collectionID: $collectionID, cursor: $cursor) { + id + title + request + } + } + `, + variables: { + collectionID: collectionID, + cursor: cursor, + }, + fetchPolicy: "no-cache", + }) + + response.data.requestsInCollection.forEach((request) => { + requests.push(request) + }) + + if (response.data.requestsInCollection.length < 10) { + break + } + cursor = requests[requests.length - 1].id + } + return requests +} + +export async function renameCollection(apollo, title, id) { + let response = undefined + while (true) { + response = await apollo.mutate({ + mutation: gql` + mutation($newTitle: String!, $collectionID: String!) { + renameCollection(newTitle: $newTitle, collectionID: $collectionID) { + id + } + } + `, + variables: { + newTitle: title, + collectionID: id, + }, + }) + if (response != undefined) break + } + return response +} + +export async function updateRequest(apollo, request, requestName, requestID) { + let response = undefined + while (true) { + response = await apollo.mutate({ + mutation: gql` + mutation($data: UpdateTeamRequestInput!, $requestID: String!) { + updateRequest(data: $data, requestID: $requestID) { + id + } + } + `, + variables: { + data: { + request: JSON.stringify(request), + title: requestName, + }, + requestID: requestID, + }, + }) + if (response != undefined) break + } + return response +} + +export async function addChildCollection(apollo, title, id) { + let response = undefined + while (true) { + response = await apollo.mutate({ + mutation: gql` + mutation($childTitle: String!, $collectionID: String!) { + createChildCollection(childTitle: $childTitle, collectionID: $collectionID) { + id + } + } + `, + variables: { + childTitle: title, + collectionID: id, + }, + }) + if (response != undefined) break + } + return response +} + +export async function deleteCollection(apollo, id) { + let response = undefined + while (true) { + response = await apollo.mutate({ + mutation: gql` + mutation($collectionID: String!) { + deleteCollection(collectionID: $collectionID) + } + `, + variables: { + collectionID: id, + }, + }) + if (response != undefined) break + } + return response +} + +export async function deleteRequest(apollo, requestID) { + let response = undefined + while (true) { + response = await apollo.mutate({ + mutation: gql` + mutation($requestID: String!) { + deleteRequest(requestID: $requestID) + } + `, + variables: { + requestID: requestID, + }, + }) + if (response != undefined) break + } + return response +} + +export async function createNewRootCollection(apollo, title, id) { + let response = undefined + while (true) { + response = await apollo.mutate({ + mutation: gql` + mutation($title: String!, $teamID: String!) { + createRootCollection(title: $title, teamID: $teamID) { + id + } + } + `, + variables: { + title: title, + teamID: id, + }, + }) + if (response != undefined) break + } + return response +} + +export async function saveRequestAsTeams(apollo, request, title, teamID, collectionID) { + await apollo.mutate({ + mutation: gql` + mutation($data: CreateTeamRequestInput!, $collectionID: String!) { + createRequestInCollection(data: $data, collectionID: $collectionID) { + collection { + id + team { + id + name + } + } + } + } + `, + variables: { + collectionID: collectionID, + data: { + teamID: teamID, + title: title, + request: request, + }, + }, + }) +} + +export async function overwriteRequestTeams(apollo, request, title, requestID) { + await apollo.mutate({ + mutation: gql` + mutation updateRequest($data: UpdateTeamRequestInput!, $requestID: String!) { + updateRequest(data: $data, requestID: $requestID) { + id + title + } + } + `, + variables: { + requestID: requestID, + data: { + request: request, + title: title, + }, + }, + }) +} + +export async function importFromMyCollections(apollo, collectionID, teamID) { + let response = await apollo.mutate({ + mutation: gql` + mutation importFromMyCollections($fbCollectionPath: String!, $teamID: String!) { + importCollectionFromUserFirestore(fbCollectionPath: $fbCollectionPath, teamID: $teamID) { + id + title + } + } + `, + variables: { + fbCollectionPath: collectionID, + teamID: teamID, + }, + }) + return response.data != null +} + +export async function importFromJSON(apollo, collections, teamID) { + let response = await apollo.mutate({ + mutation: gql` + mutation importFromJSON($jsonString: String!, $teamID: String!) { + importCollectionsFromJSON(jsonString: $jsonString, teamID: $teamID) + } + `, + variables: { + jsonString: JSON.stringify(collections), + teamID: teamID, + }, + }) + return response.data != null +} + +export async function replaceWithJSON(apollo, collections, teamID) { + let response = await apollo.mutate({ + mutation: gql` + mutation replaceWithJSON($jsonString: String!, $teamID: String!) { + replaceCollectionsWithJSON(jsonString: $jsonString, teamID: $teamID) + } + `, + variables: { + jsonString: JSON.stringify(collections), + teamID: teamID, + }, + }) + return response.data != null +} + +export async function exportAsJSON(apollo, teamID) { + let response = await apollo.query({ + query: gql` + query exportAsJSON($teamID: String!) { + exportCollectionsToJSON(teamID: $teamID) + } + `, + variables: { + teamID: teamID, + }, + }) + return response.data.exportCollectionsToJSON +} diff --git a/lang/en-US.json b/lang/en-US.json index b32e93d64..1d86c5d73 100644 --- a/lang/en-US.json +++ b/lang/en-US.json @@ -305,5 +305,30 @@ "account_exists": "Account exists with different credential - Login to link both accounts", "confirm": "Confirm", "new_version_found": "New version found. Refresh to update.", - "size": "Size" + "size": "Size", + "exit": "Exit Team", + "string_length_insufficient": "Team name should be atleast 6 characters long", + "invalid_emailID_format": "Email ID format is invalid", + "teams": "Teams", + "new_team": "New Team", + "my_new_team": "My New Team", + "edit_team": "Edit Team", + "team_member_list": "Member List", + "invalid_team_name": "Please provide a valid name for the team", + "use_team": "Use Team", + "add_one_member": "(add at least one member)", + "permissions": "Permissions", + "email": "E-mail", + "create_new_team": "Create new team", + "new_team_created": "New team created", + "team_saved": "Team saved", + "team_name_empty": "Team name empty", + "disable_new_collection": "You do not have edit access to these collections", + "collection_added": "Collection added successfully", + "folder_added": "Folder added successfully", + "team_exited": "Team exited", + "disable_exit": "Only owner cannot exit the team", + "folder_renamed": "Folder renamed successfully", + "role_updated": "User role(s) updated successfully", + "user_removed": "User removed successfully" } diff --git a/layouts/default.vue b/layouts/default.vue index 17116bd0e..c09072f7a 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -16,9 +16,13 @@