diff --git a/packages/hoppscotch-selfhost-web/package.json b/packages/hoppscotch-selfhost-web/package.json index 1d02524f2..7e86b6251 100644 --- a/packages/hoppscotch-selfhost-web/package.json +++ b/packages/hoppscotch-selfhost-web/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@hoppscotch/common": "workspace:^", + "@hoppscotch/data": "workspace:^", "axios": "^0.21.4", "buffer": "^6.0.3", "firebase": "^9.8.4", @@ -66,6 +67,7 @@ "vite-plugin-static-copy": "^0.12.0", "vite-plugin-vue-layouts": "^0.7.0", "vite-plugin-windicss": "^1.8.8", + "vitest": "^0.29.3", "vue-tsc": "^1.0.9", "windicss": "^3.5.6" } diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLChildUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLChildUserCollection.graphql new file mode 100644 index 000000000..54dc80c48 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLChildUserCollection.graphql @@ -0,0 +1,11 @@ +mutation CreateGQLChildUserCollection( + $title: String! + $parentUserCollectionID: ID! +) { + createGQLChildUserCollection( + title: $title + parentUserCollectionID: $parentUserCollectionID + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLRootUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLRootUserCollection.graphql new file mode 100644 index 000000000..eecdebe17 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLRootUserCollection.graphql @@ -0,0 +1,5 @@ +mutation CreateGQLRootUserCollection($title: String!) { + createGQLRootUserCollection(title: $title) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLUserRequest.graphql new file mode 100644 index 000000000..02423e864 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateGQLUserRequest.graphql @@ -0,0 +1,13 @@ +mutation CreateGQLUserRequest( + $title: String! + $request: String! + $collectionID: ID! +) { + createGQLUserRequest( + title: $title + request: $request + collectionID: $collectionID + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTChildUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTChildUserCollection.graphql new file mode 100644 index 000000000..40b38aca7 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTChildUserCollection.graphql @@ -0,0 +1,11 @@ +mutation CreateRESTChildUserCollection( + $title: String! + $parentUserCollectionID: ID! +) { + createRESTChildUserCollection( + title: $title + parentUserCollectionID: $parentUserCollectionID + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTRootUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTRootUserCollection.graphql new file mode 100644 index 000000000..8c54f0c2b --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTRootUserCollection.graphql @@ -0,0 +1,5 @@ +mutation CreateRESTRootUserCollection($title: String!) { + createRESTRootUserCollection(title: $title) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTUserRequest.graphql new file mode 100644 index 000000000..01d51b0c0 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/CreateRESTUserRequest.graphql @@ -0,0 +1,13 @@ +mutation CreateRESTUserRequest( + $collectionID: ID! + $title: String! + $request: String! +) { + createRESTUserRequest( + collectionID: $collectionID + title: $title + request: $request + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserCollection.graphql new file mode 100644 index 000000000..cafa7b08a --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserCollection.graphql @@ -0,0 +1,3 @@ +mutation DeleteUserCollection($userCollectionID: ID!) { + deleteUserCollection(userCollectionID: $userCollectionID) +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserRequest.graphql new file mode 100644 index 000000000..c4855e807 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/DeleteUserRequest.graphql @@ -0,0 +1,3 @@ +mutation DeleteUserRequest($requestID: ID!) { + deleteUserRequest(id: $requestID) +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserCollection.graphql new file mode 100644 index 000000000..ac280122f --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserCollection.graphql @@ -0,0 +1,8 @@ +mutation MoveUserCollection($destCollectionID: ID, $userCollectionID: ID!) { + moveUserCollection( + destCollectionID: $destCollectionID + userCollectionID: $userCollectionID + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserRequest.graphql new file mode 100644 index 000000000..38f8e6ca7 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/MoveUserRequest.graphql @@ -0,0 +1,15 @@ +mutation MoveUserRequest( + $sourceCollectionID: ID! + $requestID: ID! + $destinationCollectionID: ID! + $nextRequestID: ID +) { + moveUserRequest( + sourceCollectionID: $sourceCollectionID + requestID: $requestID + destinationCollectionID: $destinationCollectionID + nextRequestID: $nextRequestID + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/RenameUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/RenameUserCollection.graphql new file mode 100644 index 000000000..35603cea6 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/RenameUserCollection.graphql @@ -0,0 +1,8 @@ +mutation RenameUserCollection($userCollectionID: ID!, $newTitle: String!) { + renameUserCollection( + userCollectionID: $userCollectionID + newTitle: $newTitle + ) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateGQLUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateGQLUserRequest.graphql new file mode 100644 index 000000000..253fed7db --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateGQLUserRequest.graphql @@ -0,0 +1,5 @@ +mutation UpdateGQLUserRequest($id: ID!, $request: String!, $title: String) { + updateGQLUserRequest(id: $id, request: $request, title: $title) { + id + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateRESTUserRequest.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateRESTUserRequest.graphql new file mode 100644 index 000000000..b7899df9e --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateRESTUserRequest.graphql @@ -0,0 +1,7 @@ +mutation UpdateRESTUserRequest($id: ID!, $title: String!, $request: String!) { + updateRESTUserRequest(id: $id, title: $title, request: $request) { + id + collectionID + request + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserCollectionOrder.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserCollectionOrder.graphql new file mode 100644 index 000000000..c9e665be0 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/UpdateUserCollectionOrder.graphql @@ -0,0 +1,6 @@ +mutation UpdateUserCollectionOrder($collectionID: ID!, $nextCollectionID: ID) { + updateUserCollectionOrder( + collectionID: $collectionID + nextCollectionID: $nextCollectionID + ) +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/ExportUserCollectionsToJSON.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/ExportUserCollectionsToJSON.graphql new file mode 100644 index 000000000..09f54adb9 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/ExportUserCollectionsToJSON.graphql @@ -0,0 +1,12 @@ +query ExportUserCollectionsToJSON( + $collectionID: ID + $collectionType: ReqType! +) { + exportUserCollectionsToJSON( + collectionID: $collectionID + collectionType: $collectionType + ) { + collectionType + exportedCollection + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetRootGQLUserCollections.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetRootGQLUserCollections.graphql new file mode 100644 index 000000000..fcc80d993 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetRootGQLUserCollections.graphql @@ -0,0 +1,13 @@ +query GetGQLRootUserCollections { + # the frontend doesnt paginate right now, so giving take a big enough value to get all collections at once + rootGQLUserCollections(take: 99999) { + id + title + type + childrenGQL { + id + title + type + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/queries/GetUserRootCollections.graphql b/packages/hoppscotch-selfhost-web/src/api/queries/GetUserRootCollections.graphql new file mode 100644 index 000000000..0b6842339 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/queries/GetUserRootCollections.graphql @@ -0,0 +1,13 @@ +query GetUserRootCollections { + # the frontend doesnt paginate right now, so giving take a big enough value to get all collections at once + rootRESTUserCollections(take: 99999) { + id + title + type + childrenREST { + id + title + type + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionCreated.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionCreated.graphql new file mode 100644 index 000000000..99870281c --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionCreated.graphql @@ -0,0 +1,10 @@ +subscription UserCollectionCreated { + userCollectionCreated { + parent { + id + } + id + title + type + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionMoved.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionMoved.graphql new file mode 100644 index 000000000..ed69fa99b --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionMoved.graphql @@ -0,0 +1,9 @@ +subscription UserCollectionMoved { + userCollectionMoved { + id + parent { + id + } + type + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionOrderUpdated.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionOrderUpdated.graphql new file mode 100644 index 000000000..6b181af88 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionOrderUpdated.graphql @@ -0,0 +1,17 @@ +subscription UserCollectionOrderUpdated { + userCollectionOrderUpdated { + userCollection { + id + parent { + id + } + } + + nextUserCollection { + id + parent { + id + } + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionRemoved.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionRemoved.graphql new file mode 100644 index 000000000..a3deba560 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionRemoved.graphql @@ -0,0 +1,6 @@ +subscription UserCollectionRemoved { + userCollectionRemoved { + id + type + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionUpdated.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionUpdated.graphql new file mode 100644 index 000000000..3fd8487d0 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserCollectionUpdated.graphql @@ -0,0 +1,10 @@ +subscription userCollectionUpdated { + userCollectionUpdated { + id + title + type + parent { + id + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestCreated.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestCreated.graphql new file mode 100644 index 000000000..462bc2461 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestCreated.graphql @@ -0,0 +1,9 @@ +subscription UserRequestCreated { + userRequestCreated { + id + collectionID + title + request + type + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestDeleted.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestDeleted.graphql new file mode 100644 index 000000000..6ef26f14c --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestDeleted.graphql @@ -0,0 +1,9 @@ +subscription UserRequestDeleted { + userRequestDeleted { + id + collectionID + title + request + type + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestMoved.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestMoved.graphql new file mode 100644 index 000000000..01a0fb24a --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestMoved.graphql @@ -0,0 +1,13 @@ +subscription UserRequestMoved { + userRequestMoved { + request { + id + collectionID + type + } + nextRequest { + id + collectionID + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestUpdated.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestUpdated.graphql new file mode 100644 index 000000000..b8c7f6e27 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRequestUpdated.graphql @@ -0,0 +1,9 @@ +subscription UserRequestUpdated { + userRequestUpdated { + id + collectionID + title + request + type + } +} diff --git a/packages/hoppscotch-selfhost-web/src/lib/sync/index.ts b/packages/hoppscotch-selfhost-web/src/lib/sync/index.ts index 64ad4b712..aa5e9d294 100644 --- a/packages/hoppscotch-selfhost-web/src/lib/sync/index.ts +++ b/packages/hoppscotch-selfhost-web/src/lib/sync/index.ts @@ -79,7 +79,7 @@ export const getSyncInitFunction = >( ) } - startSubscriptions() + stopSubscriptions = startSubscriptions() } function stopListeningToSubscriptions() { diff --git a/packages/hoppscotch-selfhost-web/src/lib/sync/mapper.ts b/packages/hoppscotch-selfhost-web/src/lib/sync/mapper.ts index 19d93cc1b..b303243a6 100644 --- a/packages/hoppscotch-selfhost-web/src/lib/sync/mapper.ts +++ b/packages/hoppscotch-selfhost-web/src/lib/sync/mapper.ts @@ -1,32 +1,41 @@ -export const createMapper = () => { - const indexBackendIDMap = new Map() - const backendIdIndexMap = new Map() +export const createMapper = < + LocalIDType extends string | number, + BackendIDType extends string | number +>() => { + const backendIDByLocalIDMap = new Map< + LocalIDType, + BackendIDType | undefined + >() + const localIDByBackendIDMap = new Map< + BackendIDType, + LocalIDType | undefined + >() return { - addEntry(localIndex: number, backendId: string) { - indexBackendIDMap.set(localIndex, backendId) - backendIdIndexMap.set(backendId, localIndex) + addEntry(localIdentifier: LocalIDType, backendIdentifier: BackendIDType) { + backendIDByLocalIDMap.set(localIdentifier, backendIdentifier) + localIDByBackendIDMap.set(backendIdentifier, localIdentifier) }, getValue() { - return indexBackendIDMap + return backendIDByLocalIDMap }, - getBackendIdByIndex(localIndex: number) { - return indexBackendIDMap.get(localIndex) + getBackendIDByLocalID(localIdentifier: LocalIDType) { + return backendIDByLocalIDMap.get(localIdentifier) }, - getIndexByBackendId(backendId: string) { - return backendIdIndexMap.get(backendId) + getLocalIDByBackendID(backendId: BackendIDType) { + return localIDByBackendIDMap.get(backendId) }, - removeEntry(backendId?: string, index?: number) { + removeEntry(backendId?: BackendIDType, index?: LocalIDType) { if (backendId) { - const index = backendIdIndexMap.get(backendId) + const index = localIDByBackendIDMap.get(backendId) - backendIdIndexMap.delete(backendId) - index && indexBackendIDMap.delete(index) + localIDByBackendIDMap.delete(backendId) + index && backendIDByLocalIDMap.delete(index) } else if (index) { - const backendId = indexBackendIDMap.get(index) + const backendId = backendIDByLocalIDMap.get(index) - indexBackendIDMap.delete(index) - backendId && backendIdIndexMap.delete(backendId) + backendIDByLocalIDMap.delete(index) + backendId && localIDByBackendIDMap.delete(backendId) } }, } diff --git a/packages/hoppscotch-selfhost-web/src/main.ts b/packages/hoppscotch-selfhost-web/src/main.ts index 5ece10f56..93a663532 100644 --- a/packages/hoppscotch-selfhost-web/src/main.ts +++ b/packages/hoppscotch-selfhost-web/src/main.ts @@ -1,10 +1,12 @@ import { createHoppApp } from "@hoppscotch/common" import { def as authDef } from "./platform/auth" import { def as environmentsDef } from "./platform/environments/environments.platform" +import { def as collectionsDef } from "./platform/collections/collections.platform" createHoppApp("#app", { auth: authDef, sync: { environments: environmentsDef, + collections: collectionsDef, }, }) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.api.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.api.ts new file mode 100644 index 000000000..b29de53ea --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.api.ts @@ -0,0 +1,302 @@ +import { + runGQLQuery, + runGQLSubscription, + runMutation, +} from "@hoppscotch/common/helpers/backend/GQLClient" +import { + CreateRestRootUserCollectionDocument, + CreateRestRootUserCollectionMutation, + CreateRestRootUserCollectionMutationVariables, + CreateRestUserRequestMutation, + CreateRestUserRequestMutationVariables, + CreateRestUserRequestDocument, + CreateRestChildUserCollectionMutation, + CreateRestChildUserCollectionMutationVariables, + CreateRestChildUserCollectionDocument, + DeleteUserCollectionMutation, + DeleteUserCollectionMutationVariables, + DeleteUserCollectionDocument, + RenameUserCollectionMutation, + RenameUserCollectionMutationVariables, + RenameUserCollectionDocument, + MoveUserCollectionMutation, + MoveUserCollectionMutationVariables, + MoveUserCollectionDocument, + DeleteUserRequestMutation, + DeleteUserRequestMutationVariables, + DeleteUserRequestDocument, + MoveUserRequestDocument, + MoveUserRequestMutation, + MoveUserRequestMutationVariables, + UpdateUserCollectionOrderMutation, + UpdateUserCollectionOrderMutationVariables, + UpdateUserCollectionOrderDocument, + GetUserRootCollectionsQuery, + GetUserRootCollectionsQueryVariables, + GetUserRootCollectionsDocument, + UserCollectionCreatedDocument, + UserCollectionUpdatedDocument, + UserCollectionRemovedDocument, + UserCollectionMovedDocument, + UserCollectionOrderUpdatedDocument, + ExportUserCollectionsToJsonQuery, + ExportUserCollectionsToJsonQueryVariables, + ExportUserCollectionsToJsonDocument, + UserRequestCreatedDocument, + UserRequestUpdatedDocument, + UserRequestMovedDocument, + UserRequestDeletedDocument, + UpdateRestUserRequestMutation, + UpdateRestUserRequestMutationVariables, + UpdateRestUserRequestDocument, + CreateGqlRootUserCollectionMutation, + CreateGqlRootUserCollectionMutationVariables, + CreateGqlRootUserCollectionDocument, + CreateGqlUserRequestMutation, + CreateGqlUserRequestMutationVariables, + CreateGqlUserRequestDocument, + CreateGqlChildUserCollectionMutation, + CreateGqlChildUserCollectionMutationVariables, + CreateGqlChildUserCollectionDocument, + UpdateGqlUserRequestMutation, + UpdateGqlUserRequestMutationVariables, + UpdateGqlUserRequestDocument, + GetGqlRootUserCollectionsQuery, + GetGqlRootUserCollectionsQueryVariables, + GetGqlRootUserCollectionsDocument, + ReqType, +} from "../../api/generated/graphql" + +export const createRESTRootUserCollection = (title: string) => + runMutation< + CreateRestRootUserCollectionMutation, + CreateRestRootUserCollectionMutationVariables, + "" + >(CreateRestRootUserCollectionDocument, { + title, + })() + +export const createGQLRootUserCollection = (title: string) => + runMutation< + CreateGqlRootUserCollectionMutation, + CreateGqlRootUserCollectionMutationVariables, + "" + >(CreateGqlRootUserCollectionDocument, { + title, + })() + +export const createRESTUserRequest = ( + title: string, + request: string, + collectionID: string +) => + runMutation< + CreateRestUserRequestMutation, + CreateRestUserRequestMutationVariables, + "" + >(CreateRestUserRequestDocument, { + title, + request, + collectionID, + })() + +export const createGQLUserRequest = ( + title: string, + request: string, + collectionID: string +) => + runMutation< + CreateGqlUserRequestMutation, + CreateGqlUserRequestMutationVariables, + "" + >(CreateGqlUserRequestDocument, { + title, + request, + collectionID, + })() + +export const createRESTChildUserCollection = ( + title: string, + parentUserCollectionID: string +) => + runMutation< + CreateRestChildUserCollectionMutation, + CreateRestChildUserCollectionMutationVariables, + "" + >(CreateRestChildUserCollectionDocument, { + title, + parentUserCollectionID, + })() + +export const createGQLChildUserCollection = ( + title: string, + parentUserCollectionID: string +) => + runMutation< + CreateGqlChildUserCollectionMutation, + CreateGqlChildUserCollectionMutationVariables, + "" + >(CreateGqlChildUserCollectionDocument, { + title, + parentUserCollectionID, + })() + +export const deleteUserCollection = (userCollectionID: string) => + runMutation< + DeleteUserCollectionMutation, + DeleteUserCollectionMutationVariables, + "" + >(DeleteUserCollectionDocument, { + userCollectionID, + })() + +export const renameUserCollection = ( + userCollectionID: string, + newTitle: string +) => + runMutation< + RenameUserCollectionMutation, + RenameUserCollectionMutationVariables, + "" + >(RenameUserCollectionDocument, { userCollectionID, newTitle })() + +export const moveUserCollection = ( + sourceCollectionID: string, + destinationCollectionID?: string +) => + runMutation< + MoveUserCollectionMutation, + MoveUserCollectionMutationVariables, + "" + >(MoveUserCollectionDocument, { + userCollectionID: sourceCollectionID, + destCollectionID: destinationCollectionID, + })() + +export const editUserRequest = ( + requestID: string, + title: string, + request: string +) => + runMutation< + UpdateRestUserRequestMutation, + UpdateRestUserRequestMutationVariables, + "" + >(UpdateRestUserRequestDocument, { + id: requestID, + request, + title, + })() + +export const editGQLUserRequest = ( + requestID: string, + title: string, + request: string +) => + runMutation< + UpdateGqlUserRequestMutation, + UpdateGqlUserRequestMutationVariables, + "" + >(UpdateGqlUserRequestDocument, { + id: requestID, + request, + title, + })() + +export const deleteUserRequest = (requestID: string) => + runMutation< + DeleteUserRequestMutation, + DeleteUserRequestMutationVariables, + "" + >(DeleteUserRequestDocument, { + requestID, + })() + +export const moveUserRequest = ( + sourceCollectionID: string, + destinationCollectionID: string, + requestID: string, + nextRequestID?: string +) => + runMutation( + MoveUserRequestDocument, + { + sourceCollectionID, + destinationCollectionID, + requestID, + nextRequestID, + } + )() + +export const updateUserCollectionOrder = ( + collectionID: string, + nextCollectionID?: string +) => + runMutation< + UpdateUserCollectionOrderMutation, + UpdateUserCollectionOrderMutationVariables, + "" + >(UpdateUserCollectionOrderDocument, { + collectionID, + nextCollectionID, + })() + +export const getUserRootCollections = () => + runGQLQuery< + GetUserRootCollectionsQuery, + GetUserRootCollectionsQueryVariables, + "" + >({ + query: GetUserRootCollectionsDocument, + }) + +export const getGQLRootUserCollections = () => + runGQLQuery< + GetGqlRootUserCollectionsQuery, + GetGqlRootUserCollectionsQueryVariables, + "" + >({ + query: GetGqlRootUserCollectionsDocument, + }) + +export const exportUserCollectionsToJSON = ( + collectionID?: string, + collectionType: ReqType.Rest | ReqType.Gql = ReqType.Rest +) => + runGQLQuery< + ExportUserCollectionsToJsonQuery, + ExportUserCollectionsToJsonQueryVariables, + "" + >({ + query: ExportUserCollectionsToJsonDocument, + variables: { collectionID, collectionType }, + }) + +export const runUserCollectionCreatedSubscription = () => + runGQLSubscription({ query: UserCollectionCreatedDocument }) + +export const runUserCollectionUpdatedSubscription = () => + runGQLSubscription({ query: UserCollectionUpdatedDocument }) + +export const runUserCollectionRemovedSubscription = () => + runGQLSubscription({ query: UserCollectionRemovedDocument }) + +export const runUserCollectionMovedSubscription = () => + runGQLSubscription({ query: UserCollectionMovedDocument }) + +export const runUserCollectionOrderUpdatedSubscription = () => + runGQLSubscription({ + query: UserCollectionOrderUpdatedDocument, + }) + +export const runUserRequestCreatedSubscription = () => + runGQLSubscription({ query: UserRequestCreatedDocument }) + +export const runUserRequestUpdatedSubscription = () => + runGQLSubscription({ query: UserRequestUpdatedDocument }) + +export const runUserRequestMovedSubscription = () => + runGQLSubscription({ query: UserRequestMovedDocument }) + +export const runUserRequestDeletedSubscription = () => + runGQLSubscription({ query: UserRequestDeletedDocument }) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.mapper.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.mapper.ts new file mode 100644 index 000000000..3316426dc --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.mapper.ts @@ -0,0 +1,456 @@ +import { + graphqlCollectionStore, + navigateToFolderWithIndexPath, + restCollectionStore, +} from "@hoppscotch/common/newstore/collections" +import { createMapper } from "../../lib/sync/mapper" +import { + restCollectionsMapper, + collectionReorderOrMovingOperations, + restRequestsMapper, +} from "./collections.sync" +import { gqlCollectionsMapper, gqlRequestsMapper } from "./gqlCollections.sync" + +function reorderItems(array: unknown[], from: number, to: number) { + const item = array.splice(from, 1)[0] + if (from < to) { + array.splice(to - 1, 0, item) + } else { + array.splice(to, 0, item) + } +} + +type RequestType = "REST" | "GQL" + +export function moveCollectionInMapper( + folderPath: string, + destinationPath?: string, + collectionType: RequestType = "REST" +) { + const indexes = folderPath.split("/") + indexes.pop() + const collectionPath = indexes.join("/") + + const { collectionsMapper, requestsMapper, collectionStore } = + getMappersAndStoreByType(collectionType) + + // Store the backend id of the folder to move for adding it to the destinationPath + const collectionToMoveBackendID = + collectionsMapper.getBackendIDByLocalID(folderPath) + + // Remove the request from its current position + collectionsMapper.removeEntry(undefined, folderPath) + + // We are assuming moveRequestInMapper is called after the item is moved in the store, + // so we'll fetch the index of the last added item + 1 to add to the mapper + // but in the case of the same parent, the destinationPath will change + // eg: + // 0. Collection 0 + // 1. Collection 1 + // in the above example, if we move Collection 0 to Collection 1 ( folderPath: 0, destinationPath: 1 ), + // the effective index of Collection 1, when using navigateToFolderWithIndexPath will be 0 + // so we check if the moving is between same parent folders / collections and adds a workaround for this + const isSameParentPath = + getParentPathFromPath(folderPath) == getParentPathFromPath(destinationPath) + + let changedDestinationPath: string | undefined + + if (isSameParentPath) { + const lastFolderPathIndex = folderPath.split("/").pop() + const folderIndex = lastFolderPathIndex && parseInt(lastFolderPathIndex) + + const lastDestinationPathIndex = + destinationPath && destinationPath.split("/").pop() + const destinationIndex = + destinationPath && + lastDestinationPathIndex && + parseInt(lastDestinationPathIndex) + + if ( + (folderIndex == 0 || folderIndex) && + (destinationIndex == 0 || destinationIndex) && + folderIndex < destinationIndex + ) { + const destinationParentPath = getParentPathFromPath(destinationPath) + changedDestinationPath = destinationParentPath + ? `${destinationParentPath}/${destinationIndex - 1}` + : `${destinationIndex - 1}` + } + } + + const destinationFolder = + changedDestinationPath && + navigateToFolderWithIndexPath( + collectionStore.value.state, + changedDestinationPath.split("/").map((pathIndex) => parseInt(pathIndex)) + ) + + const destinationCollectionID = + destinationPath && collectionsMapper.getBackendIDByLocalID(destinationPath) + + if (destinationFolder && collectionToMoveBackendID) { + const destinationIndex = destinationFolder.folders.length + + const newPath = `${destinationPath}/${destinationIndex}` + collectionsMapper.addEntry(newPath, collectionToMoveBackendID) + + changeParentForAllChildrenFromMapper(folderPath, newPath, collectionType) + + collectionToMoveBackendID && + collectionReorderOrMovingOperations.push({ + sourceCollectionID: collectionToMoveBackendID, + destinationCollectionID, + reorderOperation: { + fromPath: folderPath, + toPath: `${changedDestinationPath}/${destinationIndex}`, + }, + }) + } + + // destinationPath won't be there, when moving to the root + if (!destinationPath && collectionToMoveBackendID) { + const destinationIndex = collectionStore.value.state.length + const newPath = `${destinationIndex}` + + collectionsMapper.addEntry(newPath, collectionToMoveBackendID) + + changeParentForAllChildrenFromMapper(folderPath, newPath, collectionType) + } + + reorderIndexesAfterEntryRemoval( + collectionPath, + collectionsMapper, + collectionType + ) + reorderIndexesAfterEntryRemoval( + collectionPath, + requestsMapper, + collectionType + ) +} + +export function moveRequestInMapper( + requestIndex: number, + path: string, + destinationPath: string, + requestType: RequestType +) { + const { collectionStore, requestsMapper } = + getMappersAndStoreByType(requestType) + + // Store the backend id of the request to move for adding it to the destinationPath + const requestToMoveBackendID = requestsMapper.getBackendIDByLocalID( + `${path}/${requestIndex}` + ) + + // Remove the request from its current position + requestsMapper.removeEntry(undefined, `${path}/${requestIndex}`) + reorderIndexesAfterEntryRemoval(path, requestsMapper, requestType) + + // We are assuming moveRequestInMapper is called after the item is moved in the store, + // so we'll fetch the index of the last added item + 1 to add to the mapper + const destinationFolder = navigateToFolderWithIndexPath( + collectionStore.value.state, + destinationPath.split("/").map((pathIndex) => parseInt(pathIndex)) + ) + + if (destinationFolder && requestToMoveBackendID) { + const destinationIndex = destinationFolder.requests.length + + requestsMapper.addEntry( + `${destinationPath}/${destinationIndex}`, + requestToMoveBackendID + ) + } +} + +// we allow reordering in the same parent collection right now +export function reorderRequestsMapper( + requestIndex: number, + path: string, + nextRequestIndex: number, + requestType: RequestType +) { + const { requestsMapper } = getMappersAndStoreByType(requestType) + + const directChildren = getDirectChildrenEntriesFromMapper( + path, + requestsMapper + ) + + reorderItems(directChildren, requestIndex, nextRequestIndex) + + directChildren.forEach((item, index) => { + item[1] && requestsMapper.addEntry(`${path}/${index}`, item[1]) + }) +} +// we allow reordering in the same parent collection right now + +export function reorderCollectionsInMapper( + collectionPath: string, + destinationCollectionPath: string, + requestType: RequestType +) { + const { requestsMapper, collectionsMapper } = + getMappersAndStoreByType(requestType) + + const indexes = collectionPath.split("/") + indexes.pop() + const parentCollectionPath = indexes.join("/") + + const directChildren = getDirectChildrenEntriesFromMapper( + parentCollectionPath, + collectionsMapper + ) + + const collectionIndex = collectionPath.split("/").pop() + const destinationIndex = destinationCollectionPath.split("/").pop() + + collectionIndex && + destinationIndex && + reorderItems( + directChildren, + parentCollectionPath + ? parseInt(collectionIndex) + : parseInt(collectionPath), + parentCollectionPath + ? parseInt(destinationIndex) + : parseInt(destinationCollectionPath) + ) + + const previousCollectionEntries: Record< + string, + [string, string | undefined][] + > = {} + + const previousRequestEntries: Record = + {} + + directChildren.forEach(([path, backendID], index) => { + const newPath = parentCollectionPath + ? `${parentCollectionPath}/${index}` + : `${index}` + + const indexes = path.split("/") + const childIndex = indexes.pop() + + if (childIndex && index != parseInt(childIndex)) { + backendID && collectionsMapper.addEntry(newPath, backendID) + + const existingCollectionsOnNewPath = getChildrenEntriesFromMapper( + newPath, + collectionsMapper + ) + + const existingRequestsOnNewPath = getChildrenEntriesFromMapper( + newPath, + requestsMapper + ) + + previousCollectionEntries[newPath] = existingCollectionsOnNewPath + previousRequestEntries[newPath] = existingRequestsOnNewPath + + removeAllChildCollectionsFromMapper(newPath, requestType) + removeAllChildRequestsFromMapper(newPath, requestType) + + if (path in previousCollectionEntries && path in previousRequestEntries) { + previousCollectionEntries[path].forEach(([previousPath, backendID]) => { + const pattern = new RegExp(`^(${path})\/`) + + const updatedPath = previousPath.replace(pattern, `${newPath}/`) + + backendID && collectionsMapper.addEntry(updatedPath, backendID) + }) + + previousRequestEntries[path].forEach(([previousPath, backendID]) => { + const pattern = new RegExp(`^(${path})\/`) + + const updatedPath = previousPath.replace(pattern, `${newPath}/`) + + backendID && requestsMapper.addEntry(updatedPath, backendID) + }) + } else { + changeParentForAllChildrenFromMapper(path, newPath, requestType) + } + } + }) +} + +export function removeAndReorderEntries( + localIndex: string, + collectionType: RequestType +) { + const { collectionsMapper, requestsMapper } = + getMappersAndStoreByType(collectionType) + + // get the collectionPath from the localIndex + const indexes = localIndex.split("/") + indexes.pop() + const collectionPath = indexes.join("/") + + collectionsMapper.removeEntry(undefined, localIndex) + + removeAllChildCollectionsFromMapper(localIndex, collectionType) + removeAllChildRequestsFromMapper(localIndex, collectionType) + + reorderIndexesAfterEntryRemoval( + collectionPath, + collectionsMapper, + collectionType + ) + reorderIndexesAfterEntryRemoval( + collectionPath, + requestsMapper, + collectionType + ) +} + +export function removeAllChildRequestsFromMapper( + collectionPath: string, + requestType: RequestType +) { + const { requestsMapper } = getMappersAndStoreByType(requestType) + + const childRequestMapperEntries = getChildrenEntriesFromMapper( + collectionPath, + requestsMapper + ) + + childRequestMapperEntries.forEach(([path]) => { + typeof path == "string" && requestsMapper.removeEntry(undefined, path) + }) +} + +export function removeAllChildCollectionsFromMapper( + collectionPath: string, + collectionType: RequestType +) { + const { collectionsMapper } = getMappersAndStoreByType(collectionType) + + const childCollectionMapperEntries = getChildrenEntriesFromMapper( + collectionPath, + collectionsMapper + ) + + childCollectionMapperEntries.forEach(([path]) => { + typeof path == "string" && collectionsMapper.removeEntry(undefined, path) + }) +} + +export function changeParentForAllChildrenFromMapper( + currentParentPath: string, + newParentPath: string, + collectionType: RequestType +) { + const { collectionsMapper, requestsMapper } = + getMappersAndStoreByType(collectionType) + + const childCollectionsMapperEntries = getChildrenEntriesFromMapper( + currentParentPath, + collectionsMapper + ) + + const childRequestsMapperEntries = getChildrenEntriesFromMapper( + currentParentPath, + requestsMapper + ) + + const pattern = new RegExp(`^(${currentParentPath})`) + + childCollectionsMapperEntries.forEach(([path, backendID]) => { + const newPath = + typeof path == "string" && path.replace(pattern, newParentPath) + + if (newPath && typeof backendID == "string") { + collectionsMapper.removeEntry(undefined, path) + collectionsMapper.addEntry(newPath, backendID) + } + }) + + childRequestsMapperEntries.forEach(([path, backendID]) => { + const newPath = + typeof path == "string" && path.replace(pattern, newParentPath) + + if (newPath && typeof backendID == "string") { + requestsMapper.removeEntry(undefined, path) + requestsMapper.addEntry(newPath, backendID) + } + }) +} + +export function getChildrenEntriesFromMapper( + path: string, + mapper: ReturnType> +) { + let mapperEntries = Array.from(mapper.getValue().entries()) + + // if there are no path( eg: "" ), all the entries are children, so return the entire mapperEntries without filtering + if (!path) return mapperEntries + + mapperEntries = mapperEntries.filter((entry) => { + const pattern = new RegExp(`^${path}\/(\\w+)\/?.*$`) + + return !!(typeof entry[0] == "string" && entry[0].match(pattern)) + }) + + return mapperEntries +} + +export function getDirectChildrenEntriesFromMapper( + path: string, + mapper: ReturnType> +) { + let mapperEntries = Array.from(mapper.getValue().entries()) + + mapperEntries = mapperEntries.filter((entry) => { + const pattern = new RegExp(path ? `^${path}\/\\d+$` : `^\\d+$`) + + return !!(typeof entry[0] == "string" && entry[0].match(pattern)) + }) + + return mapperEntries +} + +export function reorderIndexesAfterEntryRemoval( + pathToReorder: string, + mapper: ReturnType>, + requestType: RequestType +) { + const directChildren = getDirectChildrenEntriesFromMapper( + pathToReorder, + mapper + ) + + directChildren.forEach(([path, backendID], index) => { + const indexes = path.split("/").map((index) => parseInt(index)) + const childIndex = indexes.pop() + const collectionPath = indexes.join("/") + + if (childIndex != index && backendID) { + const newPath = collectionPath ? `${collectionPath}/${index}` : `${index}` + + mapper.removeEntry(undefined, path) + mapper.addEntry(newPath, backendID) + changeParentForAllChildrenFromMapper(path, newPath, requestType) + } + }) +} + +function getParentPathFromPath(path: string | undefined) { + const indexes = path ? path.split("/") : [] + indexes.pop() + + return indexes.join("/") +} + +export function getMappersAndStoreByType(type: "GQL" | "REST") { + const isGQL = type == "GQL" + + const collectionsMapper = isGQL ? gqlCollectionsMapper : restCollectionsMapper + + const requestsMapper = isGQL ? gqlRequestsMapper : restRequestsMapper + + const collectionStore = isGQL ? graphqlCollectionStore : restCollectionStore + + return { collectionsMapper, requestsMapper, collectionStore } +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts new file mode 100644 index 000000000..118bc326d --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts @@ -0,0 +1,824 @@ +import { authEvents$, def as platformAuth } from "@platform/auth" +import { CollectionsPlatformDef } from "@hoppscotch/common/platform/collections" +import { runDispatchWithOutSyncing } from "../../lib/sync" + +import { + exportUserCollectionsToJSON, + runUserCollectionCreatedSubscription, + runUserCollectionMovedSubscription, + runUserCollectionOrderUpdatedSubscription, + runUserCollectionRemovedSubscription, + runUserCollectionUpdatedSubscription, + runUserRequestCreatedSubscription, + runUserRequestDeletedSubscription, + runUserRequestMovedSubscription, + runUserRequestUpdatedSubscription, +} from "./collections.api" +import { + collectionReorderOrMovingOperations, + collectionsSyncer, + restCollectionsOperations, + restCollectionsMapper, + restRequestsMapper, +} from "./collections.sync" +import { + moveCollectionInMapper, + removeAndReorderEntries, + reorderIndexesAfterEntryRemoval, + reorderCollectionsInMapper, + getMappersAndStoreByType, +} from "./collections.mapper" + +import * as E from "fp-ts/Either" +import { + addRESTCollection, + setRESTCollections, + editRESTCollection, + removeRESTCollection, + moveRESTFolder, + updateRESTCollectionOrder, + saveRESTRequestAs, + navigateToFolderWithIndexPath, + editRESTRequest, + removeRESTRequest, + moveRESTRequest, + updateRESTRequestOrder, + addRESTFolder, + editRESTFolder, + removeRESTFolder, + addGraphqlFolder, + addGraphqlCollection, + editGraphqlFolder, + editGraphqlCollection, + removeGraphqlFolder, + removeGraphqlCollection, + saveGraphqlRequestAs, + editGraphqlRequest, + moveGraphqlRequest, + removeGraphqlRequest, + setGraphqlCollections, +} from "@hoppscotch/common/newstore/collections" +import { runGQLSubscription } from "@hoppscotch/common/helpers/backend/GQLClient" +import { + HoppCollection, + HoppGQLRequest, + HoppRESTRequest, +} from "@hoppscotch/data" +import { + gqlCollectionsOperations, + gqlCollectionsSyncer, +} from "./gqlCollections.sync" +import { ReqType } from "../../api/generated/graphql" + +function initCollectionsSync() { + const currentUser$ = platformAuth.getCurrentUserStream() + collectionsSyncer.startStoreSync() + collectionsSyncer.setupSubscriptions(setupSubscriptions) + + gqlCollectionsSyncer.startStoreSync() + + loadUserRootCollections("REST") + loadUserRootCollections("GQL") + + // TODO: test & make sure the auth thing is working properly + currentUser$.subscribe(async (user) => { + if (user) { + loadUserRootCollections("REST") + loadUserRootCollections("GQL") + } + }) + + authEvents$.subscribe((event) => { + if (event.event == "login" || event.event == "token_refresh") { + collectionsSyncer.startListeningToSubscriptions() + } + + if (event.event == "logout") { + collectionsSyncer.stopListeningToSubscriptions() + } + }) +} + +type ExportedUserCollectionREST = { + id?: string + folders: ExportedUserCollectionREST[] + requests: Array + name: string +} + +type ExportedUserCollectionGQL = { + id?: string + folders: ExportedUserCollectionGQL[] + requests: Array + name: string +} + +function exportedCollectionToHoppCollection( + collection: ExportedUserCollectionREST | ExportedUserCollectionGQL, + collectionType: "REST" | "GQL" +): HoppCollection { + if (collectionType == "REST") { + const restCollection = collection as ExportedUserCollectionREST + + return { + v: 1, + name: restCollection.name, + folders: restCollection.folders.map((folder) => + exportedCollectionToHoppCollection(folder, collectionType) + ), + requests: restCollection.requests.map( + ({ + v, + auth, + body, + endpoint, + headers, + method, + name, + params, + preRequestScript, + testScript, + }) => ({ + v, + auth, + body, + endpoint, + headers, + method, + name, + params, + preRequestScript, + testScript, + }) + ), + } + } else { + const gqlCollection = collection as ExportedUserCollectionGQL + + return { + v: 1, + name: gqlCollection.name, + folders: gqlCollection.folders.map((folder) => + exportedCollectionToHoppCollection(folder, collectionType) + ), + requests: gqlCollection.requests.map(({ v, auth, headers, name }) => ({ + v, + auth, + headers, + name, + })) as HoppGQLRequest[], + } + } +} + +function addMapperEntriesForExportedCollection( + collection: ExportedUserCollectionREST | ExportedUserCollectionGQL, + localPath: string, + collectionType: "REST" | "GQL" +) { + const { collectionsMapper, requestsMapper } = + getMappersAndStoreByType(collectionType) + + if (collection.id) { + collectionsMapper.addEntry(localPath, collection.id) + + collection.folders.forEach((folder, index) => { + addMapperEntriesForExportedCollection( + folder, + `${localPath}/${index}`, + collectionType + ) + }) + + collection.requests.forEach((request, index) => { + const requestID = request.id + + requestID && requestsMapper.addEntry(`${localPath}/${index}`, requestID) + }) + } +} + +async function loadUserRootCollections(collectionType: "REST" | "GQL") { + const res = await exportUserCollectionsToJSON( + undefined, + collectionType == "REST" ? ReqType.Rest : ReqType.Gql + ) + + if (E.isRight(res)) { + const collectionsJSONString = + res.right.exportUserCollectionsToJSON.exportedCollection + const exportedCollections = ( + JSON.parse(collectionsJSONString) as Array< + ExportedUserCollectionGQL | ExportedUserCollectionREST + > + ).map((collection) => ({ v: 1, ...collection })) + + runDispatchWithOutSyncing(() => { + collectionType == "REST" + ? setRESTCollections( + exportedCollections.map( + (collection) => + exportedCollectionToHoppCollection( + collection, + "REST" + ) as HoppCollection + ) + ) + : setGraphqlCollections( + exportedCollections.map( + (collection) => + exportedCollectionToHoppCollection( + collection, + "GQL" + ) as HoppCollection + ) + ) + + exportedCollections.forEach((collection, index) => + addMapperEntriesForExportedCollection( + collection, + `${index}`, + collectionType + ) + ) + }) + } +} + +function setupSubscriptions() { + let subs: ReturnType[1][] = [] + + const userCollectionCreatedSub = setupUserCollectionCreatedSubscription() + const userCollectionUpdatedSub = setupUserCollectionUpdatedSubscription() + const userCollectionRemovedSub = setupUserCollectionRemovedSubscription() + const userCollectionMovedSub = setupUserCollectionMovedSubscription() + const userCollectionOrderUpdatedSub = + setupUserCollectionOrderUpdatedSubscription() + const userRequestCreatedSub = setupUserRequestCreatedSubscription() + const userRequestUpdatedSub = setupUserRequestUpdatedSubscription() + const userRequestDeletedSub = setupUserRequestDeletedSubscription() + const userRequestMovedSub = setupUserRequestMovedSubscription() + + subs = [ + userCollectionCreatedSub, + userCollectionUpdatedSub, + userCollectionRemovedSub, + userCollectionMovedSub, + userCollectionOrderUpdatedSub, + userRequestCreatedSub, + userRequestUpdatedSub, + userRequestDeletedSub, + userRequestMovedSub, + ] + + return () => { + subs.forEach((sub) => sub.unsubscribe()) + } +} + +function setupUserCollectionCreatedSubscription() { + const [userCollectionCreated$, userCollectionCreatedSub] = + runUserCollectionCreatedSubscription() + + userCollectionCreated$.subscribe((res) => { + if (E.isRight(res)) { + const collectionType = res.right.userCollectionCreated.type + + const { collectionsMapper, collectionStore } = + getMappersAndStoreByType(collectionType) + + const userCollectionBackendID = res.right.userCollectionCreated.id + const parentCollectionID = res.right.userCollectionCreated.parent?.id + + const userCollectionLocalID = collectionsMapper.getLocalIDByBackendID( + userCollectionBackendID + ) + + // collection already exists in store ( this instance created it ) + if (userCollectionLocalID) { + return + } + + const parentCollectionPath = + parentCollectionID && + collectionsMapper.getLocalIDByBackendID(parentCollectionID) + + // only folders will have parent collection id + if (parentCollectionID && parentCollectionPath) { + runDispatchWithOutSyncing(() => { + collectionType == "GQL" + ? addGraphqlFolder( + res.right.userCollectionCreated.title, + parentCollectionPath + ) + : addRESTFolder( + res.right.userCollectionCreated.title, + parentCollectionPath + ) + + const parentCollection = navigateToFolderWithIndexPath( + collectionStore.value.state, + parentCollectionPath + .split("/") + .map((pathIndex) => parseInt(pathIndex)) + ) + + if (parentCollection) { + const folderIndex = parentCollection.folders.length - 1 + collectionsMapper.addEntry( + `${parentCollectionPath}/${folderIndex}`, + userCollectionBackendID + ) + } + }) + } else { + // root collections won't have parentCollectionID + runDispatchWithOutSyncing(() => { + collectionType == "GQL" + ? addGraphqlCollection({ + name: res.right.userCollectionCreated.title, + folders: [], + requests: [], + v: 1, + }) + : addRESTCollection({ + name: res.right.userCollectionCreated.title, + folders: [], + requests: [], + v: 1, + }) + + const localIndex = collectionStore.value.state.length - 1 + collectionsMapper.addEntry(`${localIndex}`, userCollectionBackendID) + }) + } + } + }) + + return userCollectionCreatedSub +} + +function setupUserCollectionUpdatedSubscription() { + const [userCollectionUpdated$, userCollectionUpdatedSub] = + runUserCollectionUpdatedSubscription() + + userCollectionUpdated$.subscribe((res) => { + if (E.isRight(res)) { + const collectionType = res.right.userCollectionUpdated.type + + const { collectionsMapper } = getMappersAndStoreByType(collectionType) + + const updatedCollectionBackendID = res.right.userCollectionUpdated.id + const updatedCollectionLocalPath = + collectionsMapper.getLocalIDByBackendID(updatedCollectionBackendID) + + const isFolder = + updatedCollectionLocalPath && + updatedCollectionLocalPath.split("/").length > 1 + + // updated collection is a folder + if (isFolder) { + runDispatchWithOutSyncing(() => { + collectionType == "REST" + ? editRESTFolder(updatedCollectionLocalPath, { + name: res.right.userCollectionUpdated.title, + }) + : editGraphqlFolder(updatedCollectionLocalPath, { + name: res.right.userCollectionUpdated.title, + }) + }) + } + + // updated collection is a root collection + if (updatedCollectionLocalPath && !isFolder) { + runDispatchWithOutSyncing(() => { + collectionType == "REST" + ? editRESTCollection(parseInt(updatedCollectionLocalPath), { + name: res.right.userCollectionUpdated.title, + }) + : editGraphqlCollection(parseInt(updatedCollectionLocalPath), { + name: res.right.userCollectionUpdated.title, + }) + }) + } + } + }) + + return userCollectionUpdatedSub +} + +function setupUserCollectionMovedSubscription() { + const [userCollectionMoved$, userCollectionMovedSub] = + runUserCollectionMovedSubscription() + + userCollectionMoved$.subscribe((res) => { + if (E.isRight(res)) { + const movedMetadata = res.right.userCollectionMoved + + const sourcePath = restCollectionsMapper.getLocalIDByBackendID( + movedMetadata.id + ) + + let destinationPath: string | undefined + + if (movedMetadata.parent?.id) { + destinationPath = restCollectionsMapper.getLocalIDByBackendID( + movedMetadata.parent?.id + ) + } + + const hasAlreadyHappened = hasReorderingOrMovingAlreadyHappened( + { + sourceCollectionID: movedMetadata.id, + destinationCollectionID: movedMetadata.parent?.id, + sourcePath, + destinationPath, + }, + "MOVING" + ) + + if (!hasAlreadyHappened) { + sourcePath && + runDispatchWithOutSyncing(() => { + moveRESTFolder(sourcePath, destinationPath ?? null) + }) + + sourcePath && + moveCollectionInMapper(sourcePath, destinationPath, "REST") + } + } + }) + + return userCollectionMovedSub +} + +function setupUserCollectionRemovedSubscription() { + const [userCollectionRemoved$, userCollectionRemovedSub] = + runUserCollectionRemovedSubscription() + + userCollectionRemoved$.subscribe((res) => { + if (E.isRight(res)) { + const removedCollectionBackendID = res.right.userCollectionRemoved.id + const collectionType = res.right.userCollectionRemoved.type + + const { collectionsMapper } = getMappersAndStoreByType(collectionType) + + const collectionsOperations = + collectionType == "REST" + ? restCollectionsOperations + : gqlCollectionsOperations + + const removedCollectionLocalPath = + collectionsMapper.getLocalIDByBackendID(removedCollectionBackendID) + + // TODO: seperate operations for rest and gql + const isInOperations = !!collectionsOperations.find( + (operation) => + operation.type == "COLLECTION_REMOVED" && + operation.collectionBackendID == removedCollectionBackendID + ) + + // the collection is already removed + if (!removedCollectionLocalPath || isInOperations) { + return + } + + const isFolder = + removedCollectionLocalPath && + removedCollectionLocalPath.split("/").length > 1 + + if (removedCollectionLocalPath && isFolder) { + runDispatchWithOutSyncing(() => { + collectionType == "REST" + ? removeRESTFolder(removedCollectionLocalPath) + : removeGraphqlFolder(removedCollectionLocalPath) + }) + } + + if (removedCollectionLocalPath && !isFolder) { + runDispatchWithOutSyncing(() => { + collectionType == "REST" + ? removeRESTCollection(parseInt(removedCollectionLocalPath)) + : removeGraphqlCollection(parseInt(removedCollectionLocalPath)) + }) + } + + removedCollectionLocalPath && + removeAndReorderEntries(removedCollectionLocalPath, collectionType) + } + }) + + return userCollectionRemovedSub +} + +function setupUserCollectionOrderUpdatedSubscription() { + const [userCollectionOrderUpdated$, userCollectionOrderUpdatedSub] = + runUserCollectionOrderUpdatedSubscription() + + userCollectionOrderUpdated$.subscribe((res) => { + if (E.isRight(res)) { + const { userCollection, nextUserCollection } = + res.right.userCollectionOrderUpdated + + const sourceCollectionID = userCollection.id + const destinationCollectionID = nextUserCollection?.id + + const sourcePath = + restCollectionsMapper.getLocalIDByBackendID(sourceCollectionID) + + let destinationPath: string | undefined + + if (destinationCollectionID) { + destinationPath = restCollectionsMapper.getLocalIDByBackendID( + destinationCollectionID + ) + } + + const hasAlreadyHappened = hasReorderingOrMovingAlreadyHappened( + { + sourceCollectionID, + destinationCollectionID, + sourcePath, + destinationPath, + }, + "REORDERING" + ) + + if (!hasAlreadyHappened) { + runDispatchWithOutSyncing(() => { + if ( + sourcePath && + destinationPath && + sourceCollectionID && + destinationCollectionID + ) { + updateRESTCollectionOrder(sourcePath, destinationPath) + reorderCollectionsInMapper(sourcePath, destinationPath, "REST") + } + }) + } + } + }) + + return userCollectionOrderUpdatedSub +} + +function setupUserRequestCreatedSubscription() { + const [userRequestCreated$, userRequestCreatedSub] = + runUserRequestCreatedSubscription() + + userRequestCreated$.subscribe((res) => { + if (E.isRight(res)) { + const collectionID = res.right.userRequestCreated.collectionID + const request = JSON.parse(res.right.userRequestCreated.request) + const requestID = res.right.userRequestCreated.id + + const requestType = res.right.userRequestCreated.type + + const { collectionsMapper, requestsMapper, collectionStore } = + getMappersAndStoreByType(requestType) + + const hasAlreadyHappened = + !!requestsMapper.getLocalIDByBackendID(requestID) + + if (hasAlreadyHappened) { + return + } + + const collectionPath = + collectionsMapper.getLocalIDByBackendID(collectionID) + + if (collectionID && collectionPath) { + runDispatchWithOutSyncing(() => { + requestType == "REST" + ? saveRESTRequestAs(collectionPath, request) + : saveGraphqlRequestAs(collectionPath, request) + + const target = navigateToFolderWithIndexPath( + collectionStore.value.state, + collectionPath.split("/").map((index) => parseInt(index)) + ) + + const requestPath = + target && `${collectionPath}/${target?.requests.length - 1}` + + requestPath && requestsMapper.addEntry(requestPath, requestID) + }) + } + } + }) + + return userRequestCreatedSub +} + +function setupUserRequestUpdatedSubscription() { + const [userRequestUpdated$, userRequestUpdatedSub] = + runUserRequestUpdatedSubscription() + + userRequestUpdated$.subscribe((res) => { + if (E.isRight(res)) { + const requestType = res.right.userRequestUpdated.type + + const { requestsMapper, collectionsMapper } = + getMappersAndStoreByType(requestType) + + const requestPath = requestsMapper.getLocalIDByBackendID( + res.right.userRequestUpdated.id + ) + + const indexes = requestPath?.split("/") + const requestIndex = indexes && indexes[indexes?.length - 1] + const requestParentPath = collectionsMapper.getLocalIDByBackendID( + res.right.userRequestUpdated.collectionID + ) + + requestIndex && + requestParentPath && + runDispatchWithOutSyncing(() => { + requestType == "REST" + ? editRESTRequest( + requestParentPath, + parseInt(requestIndex), + JSON.parse(res.right.userRequestUpdated.request) + ) + : editGraphqlRequest( + requestParentPath, + parseInt(requestIndex), + JSON.parse(res.right.userRequestUpdated.request) + ) + }) + } + }) + + return userRequestUpdatedSub +} + +function setupUserRequestMovedSubscription() { + const [userRequestMoved$, userRequestMovedSub] = + runUserRequestMovedSubscription() + + userRequestMoved$.subscribe((res) => { + if (E.isRight(res)) { + const requestType = res.right.userRequestMoved.request.type + + const { collectionsMapper } = getMappersAndStoreByType(requestType) + + const requestID = res.right.userRequestMoved.request.id + const requestIndex = getRequestIndexFromRequestID(requestID) + + const sourceCollectionPath = getCollectionPathFromRequestID(requestID) + + const destinationCollectionID = + res.right.userRequestMoved.request.collectionID + const destinationCollectionPath = collectionsMapper.getLocalIDByBackendID( + destinationCollectionID + ) + + const nextRequest = res.right.userRequestMoved.nextRequest + + // there is no nextRequest, so request is moved + if ( + requestIndex && + sourceCollectionPath && + destinationCollectionPath && + !nextRequest + ) { + runDispatchWithOutSyncing(() => { + requestType == "REST" + ? moveRESTRequest( + sourceCollectionPath, + parseInt(requestIndex), + destinationCollectionPath + ) + : moveGraphqlRequest( + sourceCollectionPath, + parseInt(requestIndex), + destinationCollectionPath + ) + }) + } + + // there is nextRequest, so request is reordered + if ( + requestIndex && + sourceCollectionPath && + destinationCollectionPath && + nextRequest && + // we don't have request reordering for graphql yet + requestType == "REST" + ) { + const nextRequestIndex = getRequestIndexFromRequestID(nextRequest.id) + + nextRequestIndex && + runDispatchWithOutSyncing(() => { + updateRESTRequestOrder( + parseInt(requestIndex), + parseInt(nextRequestIndex), + destinationCollectionPath + ) + }) + } + } + }) + + return userRequestMovedSub +} + +function setupUserRequestDeletedSubscription() { + const [userRequestDeleted$, userRequestDeletedSub] = + runUserRequestDeletedSubscription() + + userRequestDeleted$.subscribe((res) => { + if (E.isRight(res)) { + const requestType = res.right.userRequestDeleted.type + + const { requestsMapper, collectionsMapper } = + getMappersAndStoreByType(requestType) + + const deletedRequestPath = requestsMapper.getLocalIDByBackendID( + res.right.userRequestDeleted.id + ) + + const indexes = deletedRequestPath?.split("/") + const requestIndex = indexes && indexes[indexes?.length - 1] + const requestParentPath = collectionsMapper.getLocalIDByBackendID( + res.right.userRequestDeleted.collectionID + ) + + requestIndex && + requestParentPath && + runDispatchWithOutSyncing(() => { + requestType == "REST" + ? removeRESTRequest(requestParentPath, parseInt(requestIndex)) + : removeGraphqlRequest(requestParentPath, parseInt(requestIndex)) + }) + + deletedRequestPath && + reorderIndexesAfterEntryRemoval( + deletedRequestPath, + requestsMapper, + requestType + ) + } + }) + + return userRequestDeletedSub +} + +export const def: CollectionsPlatformDef = { + initCollectionsSync, +} + +function getRequestIndexFromRequestID(requestID: string) { + const requestPath = restRequestsMapper.getLocalIDByBackendID(requestID) + + /** + * requestPath is in the form collectionPath/requestIndex, + * so to get requestIndex we just split the requestPath with / and get the last element + */ + const requestPathIndexes = requestPath?.split("/") + const requestIndex = + requestPathIndexes && requestPathIndexes[requestPathIndexes?.length - 1] + + return requestIndex +} + +function getCollectionPathFromRequestID(requestID: string) { + const requestPath = restRequestsMapper.getLocalIDByBackendID(requestID) + const requestPathIndexes = requestPath?.split("/") + + // requestIndex will be the last element, remove it + requestPathIndexes?.pop() + + return requestPathIndexes?.join("/") +} + +function hasReorderingOrMovingAlreadyHappened( + incomingOperation: { + sourceCollectionID: string + destinationCollectionID: string | undefined + sourcePath: string | undefined + destinationPath: string | undefined + }, + type: "REORDERING" | "MOVING" +) { + const { + sourcePath, + sourceCollectionID, + destinationCollectionID, + destinationPath, + } = incomingOperation + + // TODO: implement this as a module + // Something like, SyncOperations.hasAlreadyHappened( type: "REORDER_COLLECTIONS", payload ) + return !!collectionReorderOrMovingOperations.find((reorderOperation) => + reorderOperation.sourceCollectionID == sourceCollectionID && + reorderOperation.destinationCollectionID == destinationCollectionID && + type == "MOVING" + ? reorderOperation.reorderOperation.fromPath == destinationPath + : reorderOperation.reorderOperation.fromPath == sourcePath && + type == "MOVING" + ? reorderOperation.reorderOperation.toPath == sourcePath + : reorderOperation.reorderOperation.toPath == destinationPath + ) +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.sync.ts new file mode 100644 index 000000000..321a91bcb --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.sync.ts @@ -0,0 +1,462 @@ +import { + navigateToFolderWithIndexPath, + removeGraphqlCollection, + removeRESTCollection, + removeRESTRequest, + restCollectionStore, +} from "@hoppscotch/common/newstore/collections" +import { + getSettingSubject, + settingsStore, +} from "@hoppscotch/common/newstore/settings" + +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" + +import { getSyncInitFunction, runDispatchWithOutSyncing } from "../../lib/sync" + +import { StoreSyncDefinitionOf } from "../../lib/sync" +import { createMapper } from "../../lib/sync/mapper" +import { + createRESTChildUserCollection, + createRESTRootUserCollection, + createRESTUserRequest, + deleteUserCollection, + deleteUserRequest, + editUserRequest, + moveUserCollection, + moveUserRequest, + renameUserCollection, + updateUserCollectionOrder, +} from "./collections.api" + +import * as E from "fp-ts/Either" +import { + removeAndReorderEntries, + moveCollectionInMapper, + reorderIndexesAfterEntryRemoval, + reorderCollectionsInMapper, + reorderRequestsMapper, + moveRequestInMapper, +} from "./collections.mapper" +import { gqlCollectionsMapper } from "./gqlCollections.sync" + +// restCollectionsMapper uses the collectionPath as the local identifier +export const restCollectionsMapper = createMapper() + +// restRequestsMapper uses the collectionPath/requestIndex as the local identifier +export const restRequestsMapper = createMapper() + +// temp implementation untill the backend implements an endpoint that accepts an entire collection +// TODO: use importCollectionsJSON to do this +const recursivelySyncCollections = async ( + collection: HoppCollection, + collectionPath: string, + parentUserCollectionID?: string +) => { + let parentCollectionID = parentUserCollectionID + + // if parentUserCollectionID does not exist, create the collection as a root collection + if (!parentUserCollectionID) { + const res = await createRESTRootUserCollection(collection.name) + + if (E.isRight(res)) { + parentCollectionID = res.right.createRESTRootUserCollection.id + restCollectionsMapper.addEntry(collectionPath, parentCollectionID) + } else { + parentCollectionID = undefined + } + } else { + // if parentUserCollectionID exists, create the collection as a child collection + const res = await createRESTChildUserCollection( + collection.name, + parentUserCollectionID + ) + + if (E.isRight(res)) { + const childCollectionId = res.right.createRESTChildUserCollection.id + restCollectionsMapper.addEntry(collectionPath, childCollectionId) + } + } + + // create the requests + if (parentCollectionID) { + collection.requests.forEach(async (request, index) => { + const res = + parentCollectionID && + (await createRESTUserRequest( + request.name, + JSON.stringify(request), + parentCollectionID + )) + + if (res && E.isRight(res)) { + const requestId = res.right.createRESTUserRequest.id + restRequestsMapper.addEntry(`${collectionPath}/${index}`, requestId) + } + }) + } + + // create the folders aka child collections + if (parentCollectionID) + collection.folders.forEach(async (folder, index) => { + recursivelySyncCollections( + folder, + `${collectionPath}/${index}`, + parentCollectionID + ) + }) +} + +// TODO: generalize this +// TODO: ask backend to send enough info on the subscription to not need this +export const collectionReorderOrMovingOperations: { + sourceCollectionID: string + destinationCollectionID?: string + reorderOperation: { + fromPath: string + toPath?: string + } +}[] = [] + +type OperationStatus = "pending" | "completed" + +type OperationCollectionRemoved = { + type: "COLLECTION_REMOVED" + collectionBackendID: string + status: OperationStatus +} + +export const restCollectionsOperations: Array = [] + +export const storeSyncDefinition: StoreSyncDefinitionOf< + typeof restCollectionStore +> = { + appendCollections({ entries }) { + let indexStart = restCollectionStore.value.state.length - entries.length + + entries.forEach((collection) => { + recursivelySyncCollections(collection, `${indexStart}`) + indexStart++ + }) + }, + async addCollection({ collection }) { + const lastCreatedCollectionIndex = + restCollectionStore.value.state.length - 1 + + await recursivelySyncCollections( + collection, + `${lastCreatedCollectionIndex}` + ) + + removeDuplicateCollectionsFromStore("REST") + }, + async removeCollection({ collectionIndex }) { + const backendIdentifier = restCollectionsMapper.getBackendIDByLocalID( + `${collectionIndex}` + ) + + if (backendIdentifier) { + restCollectionsOperations.push({ + collectionBackendID: backendIdentifier, + type: "COLLECTION_REMOVED", + status: "pending", + }) + await deleteUserCollection(backendIdentifier) + removeAndReorderEntries(`${collectionIndex}`, "REST") + } + }, + editCollection({ partialCollection: collection, collectionIndex }) { + const backendIdentifier = restCollectionsMapper.getBackendIDByLocalID( + `${collectionIndex}` + ) + + if (backendIdentifier && collection.name) { + renameUserCollection(backendIdentifier, collection.name) + } + }, + async addFolder({ name, path }) { + const parentCollectionBackendID = + restCollectionsMapper.getBackendIDByLocalID(path) + + if (parentCollectionBackendID) { + // TODO: remove this replaceAll thing when updating the mapper + const res = await createRESTChildUserCollection( + name, + parentCollectionBackendID + ) + + // after the folder is created add the path of the folder with its backend id to the mapper + if (E.isRight(res)) { + const folderBackendID = res.right.createRESTChildUserCollection.id + const parentCollection = navigateToFolderWithIndexPath( + restCollectionStore.value.state, + path.split("/").map((index) => parseInt(index)) + ) + + if (parentCollection && parentCollection.folders.length > 0) { + const folderIndex = parentCollection.folders.length - 1 + restCollectionsMapper.addEntry( + `${path}/${folderIndex}`, + folderBackendID + ) + } + } + } + }, + editFolder({ folder, path }) { + const folderBackendId = restCollectionsMapper.getBackendIDByLocalID( + `${path}` + ) + + const folderName = folder.name + + if (folderBackendId && folderName) { + renameUserCollection(folderBackendId, folderName) + } + }, + async removeFolder({ path }) { + const folderBackendId = restCollectionsMapper.getBackendIDByLocalID( + `${path}` + ) + + if (folderBackendId) { + await deleteUserCollection(folderBackendId) + removeAndReorderEntries(path, "REST") + } + }, + async moveFolder({ destinationPath, path }) { + const sourceCollectionBackendID = + restCollectionsMapper.getBackendIDByLocalID(path) + + const destinationCollectionBackendID = destinationPath + ? restCollectionsMapper.getBackendIDByLocalID(destinationPath) + : undefined + + if (sourceCollectionBackendID) { + await moveUserCollection( + sourceCollectionBackendID, + destinationCollectionBackendID + ) + + moveCollectionInMapper(path, destinationPath ?? undefined, "REST") + } + }, + editRequest({ path, requestIndex, requestNew }) { + const requestPath = `${path}/${requestIndex}` + + const requestBackendID = + restRequestsMapper.getBackendIDByLocalID(requestPath) + + if (requestBackendID) { + editUserRequest( + requestBackendID, + (requestNew as HoppRESTRequest).name, + JSON.stringify(requestNew) + ) + } + }, + async saveRequestAs({ path, request }) { + const parentCollectionBackendID = + restCollectionsMapper.getBackendIDByLocalID(path) + + if (parentCollectionBackendID) { + const res = await createRESTUserRequest( + (request as HoppRESTRequest).name, + JSON.stringify(request), + parentCollectionBackendID + ) + + const existingPath = + E.isRight(res) && + restRequestsMapper.getLocalIDByBackendID( + res.right.createRESTUserRequest.id + ) + + // remove the request if it is already existing ( can happen when the subscription fired before the mutation is resolved ) + if (existingPath) { + const indexes = existingPath.split("/") + const existingRequestIndex = indexes.pop() + const existingRequestParentPath = indexes.join("/") + + runDispatchWithOutSyncing(() => { + existingRequestIndex && + removeRESTRequest( + existingRequestParentPath, + parseInt(existingRequestIndex) + ) + }) + } + + const parentCollection = navigateToFolderWithIndexPath( + restCollectionStore.value.state, + path.split("/").map((index) => parseInt(index)) + ) + + if (parentCollection) { + const lastCreatedRequestIndex = parentCollection.requests.length - 1 + + if (E.isRight(res)) { + restRequestsMapper.addEntry( + `${path}/${lastCreatedRequestIndex}`, + res.right.createRESTUserRequest.id + ) + } + } + } + }, + async removeRequest({ path, requestIndex }) { + const requestPath = `${path}/${requestIndex}` + const requestBackendID = + restRequestsMapper.getBackendIDByLocalID(requestPath) + + if (requestBackendID) { + await deleteUserRequest(requestBackendID) + restRequestsMapper.removeEntry(requestPath) + reorderIndexesAfterEntryRemoval(path, restRequestsMapper, "REST") + } + }, + moveRequest({ destinationPath, path, requestIndex }) { + moveOrReorderRequests(requestIndex, path, destinationPath) + }, + updateRequestOrder({ + destinationCollectionPath, + destinationRequestIndex, + requestIndex, + }) { + /** + * currently the FE implementation only supports reordering requests between the same collection, + * so destinationCollectionPath and sourceCollectionPath will be same + */ + moveOrReorderRequests( + requestIndex, + destinationCollectionPath, + destinationCollectionPath, + destinationRequestIndex + ) + }, + async updateCollectionOrder({ + collectionIndex: collectionPath, + destinationCollectionIndex: destinationCollectionPath, + }) { + const sourceBackendID = + restCollectionsMapper.getBackendIDByLocalID(collectionPath) + + const destinationBackendID = restCollectionsMapper.getBackendIDByLocalID( + destinationCollectionPath + ) + + if (sourceBackendID) { + collectionReorderOrMovingOperations.push({ + sourceCollectionID: sourceBackendID, + destinationCollectionID: destinationBackendID, + reorderOperation: { + fromPath: `${parseInt(destinationCollectionPath) - 1}`, + toPath: destinationCollectionPath, + }, + }) + + await updateUserCollectionOrder(sourceBackendID, destinationBackendID) + + const currentSourcePath = + restCollectionsMapper.getLocalIDByBackendID(sourceBackendID) + + const hasAlreadyHappened = !!( + currentSourcePath == `${parseInt(destinationCollectionPath) - 1}` + ) + + if (!hasAlreadyHappened) { + reorderCollectionsInMapper( + collectionPath, + destinationCollectionPath, + "REST" + ) + } + } + }, +} + +export const collectionsSyncer = getSyncInitFunction( + restCollectionStore, + storeSyncDefinition, + () => settingsStore.value.syncCollections, + getSettingSubject("syncCollections") +) + +async function moveOrReorderRequests( + requestIndex: number, + path: string, + destinationPath: string, + nextRequestIndex?: number +) { + const sourceCollectionBackendID = + restCollectionsMapper.getBackendIDByLocalID(path) + const destinationCollectionBackendID = + restCollectionsMapper.getBackendIDByLocalID(destinationPath) + + const requestBackendID = restRequestsMapper.getBackendIDByLocalID( + `${path}/${requestIndex}` + ) + + let nextRequestBackendID: string | undefined + + // we only need this for reordering requests, not for moving requests + if (nextRequestIndex) { + nextRequestBackendID = restRequestsMapper.getBackendIDByLocalID( + `${destinationPath}/${nextRequestIndex}` + ) + } + + if ( + sourceCollectionBackendID && + destinationCollectionBackendID && + requestBackendID + ) { + await moveUserRequest( + sourceCollectionBackendID, + destinationCollectionBackendID, + requestBackendID, + nextRequestBackendID + ) + + if (nextRequestBackendID && nextRequestIndex) { + reorderRequestsMapper(requestIndex, path, nextRequestIndex, "REST") + } else { + moveRequestInMapper(requestIndex, path, destinationPath, "REST") + } + } +} + +export function removeDuplicateCollectionsFromStore( + collectionType: "REST" | "GQL" +) { + const collectionsMapper = + collectionType === "REST" ? restCollectionsMapper : gqlCollectionsMapper + + const mapperEntries = Array.from(collectionsMapper.getValue().entries()) + + const seenBackendIDs = new Set() + + const localIDsToRemove = new Set() + + mapperEntries.forEach(([localID, backendID]) => { + if (backendID && seenBackendIDs.has(backendID)) { + localIDsToRemove.add(localID) + } else { + backendID && seenBackendIDs.add(backendID) + } + }) + + localIDsToRemove.forEach((localID) => { + collectionType === "REST" + ? removeRESTCollection(parseInt(localID)) + : removeGraphqlCollection(parseInt(localID)) + + collectionsMapper.removeEntry(undefined, localID) + + const indexes = localID.split("/") + indexes.pop() + const parentPath = indexes.join("/") + + reorderIndexesAfterEntryRemoval(parentPath, collectionsMapper, "REST") + }) +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/gqlCollections.sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/gqlCollections.sync.ts new file mode 100644 index 000000000..99a37867b --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/gqlCollections.sync.ts @@ -0,0 +1,347 @@ +import { + graphqlCollectionStore, + navigateToFolderWithIndexPath, + removeGraphqlRequest, +} from "@hoppscotch/common/newstore/collections" +import { + getSettingSubject, + settingsStore, +} from "@hoppscotch/common/newstore/settings" + +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" + +import { getSyncInitFunction, runDispatchWithOutSyncing } from "../../lib/sync" + +import { StoreSyncDefinitionOf } from "../../lib/sync" +import { createMapper } from "../../lib/sync/mapper" +import { + createGQLChildUserCollection, + createGQLRootUserCollection, + createGQLUserRequest, + deleteUserCollection, + deleteUserRequest, + editGQLUserRequest, + moveUserRequest, + renameUserCollection, +} from "./collections.api" + +import * as E from "fp-ts/Either" +import { + moveRequestInMapper, + removeAndReorderEntries, + reorderIndexesAfterEntryRemoval, + reorderRequestsMapper, +} from "./collections.mapper" +import { removeDuplicateCollectionsFromStore } from "./collections.sync" + +// gqlCollectionsMapper uses the collectionPath as the local identifier +export const gqlCollectionsMapper = createMapper() + +// gqlRequestsMapper uses the collectionPath/requestIndex as the local identifier +export const gqlRequestsMapper = createMapper() + +// temp implementation untill the backend implements an endpoint that accepts an entire collection +// TODO: use importCollectionsJSON to do this +const recursivelySyncCollections = async ( + collection: HoppCollection, + collectionPath: string, + parentUserCollectionID?: string +) => { + let parentCollectionID = parentUserCollectionID + + // if parentUserCollectionID does not exist, create the collection as a root collection + if (!parentUserCollectionID) { + const res = await createGQLRootUserCollection(collection.name) + + if (E.isRight(res)) { + parentCollectionID = res.right.createGQLRootUserCollection.id + gqlCollectionsMapper.addEntry(collectionPath, parentCollectionID) + } else { + parentCollectionID = undefined + } + } else { + // if parentUserCollectionID exists, create the collection as a child collection + const res = await createGQLChildUserCollection( + collection.name, + parentUserCollectionID + ) + + if (E.isRight(res)) { + const childCollectionId = res.right.createGQLChildUserCollection.id + gqlCollectionsMapper.addEntry(collectionPath, childCollectionId) + } + } + + // create the requests + if (parentCollectionID) { + collection.requests.forEach(async (request, index) => { + const res = + parentCollectionID && + (await createGQLUserRequest( + request.name, + JSON.stringify(request), + parentCollectionID + )) + + if (res && E.isRight(res)) { + const requestId = res.right.createGQLUserRequest.id + gqlRequestsMapper.addEntry(`${collectionPath}/${index}`, requestId) + } + }) + } + + // create the folders aka child collections + if (parentCollectionID) + collection.folders.forEach(async (folder, index) => { + recursivelySyncCollections( + folder, + `${collectionPath}/${index}`, + parentCollectionID + ) + }) +} + +// TODO: generalize this +// TODO: ask backend to send enough info on the subscription to not need this +export const collectionReorderOrMovingOperations: { + sourceCollectionID: string + destinationCollectionID?: string + reorderOperation: { + fromPath: string + toPath?: string + } +}[] = [] + +type OperationStatus = "pending" | "completed" + +type OperationCollectionRemoved = { + type: "COLLECTION_REMOVED" + collectionBackendID: string + status: OperationStatus +} + +export const gqlCollectionsOperations: Array = [] + +export const storeSyncDefinition: StoreSyncDefinitionOf< + typeof graphqlCollectionStore +> = { + appendCollections({ entries }) { + let indexStart = graphqlCollectionStore.value.state.length - entries.length + + entries.forEach((collection) => { + recursivelySyncCollections(collection, `${indexStart}`) + indexStart++ + }) + }, + async addCollection({ collection }) { + const lastCreatedCollectionIndex = + graphqlCollectionStore.value.state.length - 1 + + await recursivelySyncCollections( + collection, + `${lastCreatedCollectionIndex}` + ) + + removeDuplicateCollectionsFromStore("GQL") + }, + async removeCollection({ collectionIndex }) { + const backendIdentifier = gqlCollectionsMapper.getBackendIDByLocalID( + `${collectionIndex}` + ) + + if (backendIdentifier) { + gqlCollectionsOperations.push({ + collectionBackendID: backendIdentifier, + type: "COLLECTION_REMOVED", + status: "pending", + }) + await deleteUserCollection(backendIdentifier) + removeAndReorderEntries(`${collectionIndex}`, "GQL") + } + }, + editCollection({ collection, collectionIndex }) { + const backendIdentifier = gqlCollectionsMapper.getBackendIDByLocalID( + `${collectionIndex}` + ) + + if (backendIdentifier && collection.name) { + renameUserCollection(backendIdentifier, collection.name) + } + }, + async addFolder({ name, path }) { + const parentCollectionBackendID = + gqlCollectionsMapper.getBackendIDByLocalID(path) + + if (parentCollectionBackendID) { + // TODO: remove this replaceAll thing when updating the mapper + const res = await createGQLChildUserCollection( + name, + parentCollectionBackendID + ) + + // after the folder is created add the path of the folder with its backend id to the mapper + if (E.isRight(res)) { + const folderBackendID = res.right.createGQLChildUserCollection.id + const parentCollection = navigateToFolderWithIndexPath( + graphqlCollectionStore.value.state, + path.split("/").map((index) => parseInt(index)) + ) + + if (parentCollection && parentCollection.folders.length > 0) { + const folderIndex = parentCollection.folders.length - 1 + gqlCollectionsMapper.addEntry( + `${path}/${folderIndex}`, + folderBackendID + ) + } + } + } + }, + editFolder({ folder, path }) { + const folderBackendId = gqlCollectionsMapper.getBackendIDByLocalID( + `${path}` + ) + + if (folderBackendId) { + renameUserCollection(folderBackendId, folder.name) + } + }, + async removeFolder({ path }) { + const folderBackendId = gqlCollectionsMapper.getBackendIDByLocalID( + `${path}` + ) + + if (folderBackendId) { + await deleteUserCollection(folderBackendId) + removeAndReorderEntries(path, "GQL") + } + }, + editRequest({ path, requestIndex, requestNew }) { + const requestPath = `${path}/${requestIndex}` + + const requestBackendID = + gqlRequestsMapper.getBackendIDByLocalID(requestPath) + + if (requestBackendID) { + editGQLUserRequest( + requestBackendID, + (requestNew as HoppRESTRequest).name, + JSON.stringify(requestNew) + ) + } + }, + async saveRequestAs({ path, request }) { + const parentCollectionBackendID = + gqlCollectionsMapper.getBackendIDByLocalID(path) + + if (parentCollectionBackendID) { + const res = await createGQLUserRequest( + (request as HoppRESTRequest).name, + JSON.stringify(request), + parentCollectionBackendID + ) + + const existingPath = + E.isRight(res) && + gqlRequestsMapper.getLocalIDByBackendID( + res.right.createGQLUserRequest.id + ) + + // remove the request if it is already existing ( can happen when the subscription fired before the mutation is resolved ) + if (existingPath) { + const indexes = existingPath.split("/") + const existingRequestIndex = indexes.pop() + const existingRequestParentPath = indexes.join("/") + + runDispatchWithOutSyncing(() => { + existingRequestIndex && + removeGraphqlRequest( + existingRequestParentPath, + parseInt(existingRequestIndex) + ) + }) + } + + const parentCollection = navigateToFolderWithIndexPath( + graphqlCollectionStore.value.state, + path.split("/").map((index) => parseInt(index)) + ) + + if (parentCollection) { + const lastCreatedRequestIndex = parentCollection.requests.length - 1 + + if (E.isRight(res)) { + gqlRequestsMapper.addEntry( + `${path}/${lastCreatedRequestIndex}`, + res.right.createGQLUserRequest.id + ) + } + } + } + }, + async removeRequest({ path, requestIndex }) { + const requestPath = `${path}/${requestIndex}` + const requestBackendID = + gqlRequestsMapper.getBackendIDByLocalID(requestPath) + + if (requestBackendID) { + await deleteUserRequest(requestBackendID) + gqlRequestsMapper.removeEntry(requestPath) + reorderIndexesAfterEntryRemoval(path, gqlRequestsMapper, "GQL") + } + }, + moveRequest({ destinationPath, path, requestIndex }) { + moveOrReorderRequests(requestIndex, path, destinationPath) + }, +} + +export const gqlCollectionsSyncer = getSyncInitFunction( + graphqlCollectionStore, + storeSyncDefinition, + () => settingsStore.value.syncCollections, + getSettingSubject("syncCollections") +) + +async function moveOrReorderRequests( + requestIndex: number, + path: string, + destinationPath: string, + nextRequestIndex?: number +) { + const sourceCollectionBackendID = + gqlCollectionsMapper.getBackendIDByLocalID(path) + const destinationCollectionBackendID = + gqlCollectionsMapper.getBackendIDByLocalID(destinationPath) + + const requestBackendID = gqlRequestsMapper.getBackendIDByLocalID( + `${path}/${requestIndex}` + ) + + let nextRequestBackendID: string | undefined + + // we only need this for reordering requests, not for moving requests + if (nextRequestIndex) { + nextRequestBackendID = gqlRequestsMapper.getBackendIDByLocalID( + `${destinationPath}/${nextRequestIndex}` + ) + } + + if ( + sourceCollectionBackendID && + destinationCollectionBackendID && + requestBackendID + ) { + await moveUserRequest( + sourceCollectionBackendID, + destinationCollectionBackendID, + requestBackendID, + nextRequestBackendID + ) + + if (nextRequestBackendID && nextRequestIndex) { + reorderRequestsMapper(requestIndex, path, nextRequestIndex, "GQL") + } else { + moveRequestInMapper(requestIndex, path, destinationPath, "GQL") + } + } +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/environments/environments.platform.ts b/packages/hoppscotch-selfhost-web/src/platform/environments/environments.platform.ts index 3258f1308..df5d29b38 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/environments/environments.platform.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/environments/environments.platform.ts @@ -166,7 +166,7 @@ function setupUserEnvironmentUpdatedSubscription() { } else { // handle the case for normal environments - const localIndex = environmentsMapper.getIndexByBackendId(id) + const localIndex = environmentsMapper.getLocalIDByBackendID(id) if (localIndex && name) { runDispatchWithOutSyncing(() => { @@ -197,7 +197,7 @@ function setupUserEnvironmentDeletedSubscription() { if (E.isRight(res)) { const { id } = res.right.userEnvironmentDeleted - const localIndex = environmentsMapper.getIndexByBackendId(id) + const localIndex = environmentsMapper.getLocalIDByBackendID(id) if (localIndex) { runDispatchWithOutSyncing(() => { diff --git a/packages/hoppscotch-selfhost-web/src/platform/environments/environments.sync.ts b/packages/hoppscotch-selfhost-web/src/platform/environments/environments.sync.ts index 32b80238b..8bc8db1c9 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/environments/environments.sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/environments/environments.sync.ts @@ -17,8 +17,8 @@ import { updateUserEnvironment, } from "./environments.api" -export const environmentsMapper = createMapper() -export const globalEnvironmentMapper = createMapper() +export const environmentsMapper = createMapper() +export const globalEnvironmentMapper = createMapper() export const storeSyncDefinition: StoreSyncDefinitionOf< typeof environmentsStore @@ -73,7 +73,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< } }, updateEnvironment({ envIndex, updatedEnv }) { - const backendId = environmentsMapper.getBackendIdByIndex(envIndex) + const backendId = environmentsMapper.getBackendIDByLocalID(envIndex) console.log(environmentsMapper) if (backendId) { @@ -81,7 +81,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< } }, async deleteEnvironment({ envIndex }) { - const backendId = environmentsMapper.getBackendIdByIndex(envIndex) + const backendId = environmentsMapper.getBackendIDByLocalID(envIndex) if (backendId) { await deleteUserEnvironment(backendId)() @@ -89,14 +89,14 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< } }, setGlobalVariables({ entries }) { - const backendId = globalEnvironmentMapper.getBackendIdByIndex(0) + const backendId = globalEnvironmentMapper.getBackendIDByLocalID(0) if (backendId) { updateUserEnvironment(backendId, { name: "", variables: entries })() } }, clearGlobalVariables() { - const backendId = globalEnvironmentMapper.getBackendIdByIndex(0) + const backendId = globalEnvironmentMapper.getBackendIDByLocalID(0) if (backendId) { clearGlobalEnvironmentVariables(backendId) diff --git a/packages/hoppscotch-selfhost-web/src/tests/collections.sync.spec.ts b/packages/hoppscotch-selfhost-web/src/tests/collections.sync.spec.ts new file mode 100644 index 000000000..5c75d45d8 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/tests/collections.sync.spec.ts @@ -0,0 +1,1516 @@ +import { describe, it, beforeEach, expect } from "vitest" +import { + changeParentForAllChildrenFromMapper, + getChildrenEntriesFromMapper, + getDirectChildrenEntriesFromMapper, + moveCollectionInMapper, + moveRequestInMapper, + removeAllChildCollectionsFromMapper, + removeAllChildRequestsFromMapper, + removeAndReorderEntries, + reorderCollectionsInMapper, + reorderIndexesAfterEntryRemoval, + reorderRequestsMapper, +} from "@platform/collections/collections.mapper" + +import { + restCollectionsMapper, + restRequestsMapper, +} from "@platform/collections/collections.sync" +import { createMapper } from "@lib/sync/mapper" +import { + addRESTCollection, + // moveRESTFolder, + setRESTCollections, +} from "@hoppscotch/common/newstore/collections" + +import { HoppRESTRequest, makeCollection } from "@hoppscotch/data" + +const getEntriesArrayFromMapper = ( + mapper: ReturnType> +) => Array.from(mapper.getValue().entries()) + +describe("getChildrenEntriesFromMapper", () => { + beforeEach(cleanUpAndSeedMappers) + + it("getChildrenEntriesFromMapper - get children of root collection", () => { + const childrenCollections = getChildrenEntriesFromMapper( + "0", + restCollectionsMapper + ) + + const childrenRequests = getChildrenEntriesFromMapper( + "0", + restRequestsMapper + ) + + expect(childrenCollections).toMatchInlineSnapshot(` + [ + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + [ + "0/1/0", + "Folder 3", + ], + ] + `) + + expect(childrenRequests).toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + ] + `) + }) + + it("getChildrenEntriesFromMapper - get children of folder", () => { + const childrenCollections = getChildrenEntriesFromMapper( + "0/1", + restCollectionsMapper + ) + + const childrenRequests = getChildrenEntriesFromMapper( + "0/1", + restRequestsMapper + ) + + expect(childrenCollections).toMatchInlineSnapshot(` + [ + [ + "0/1/0", + "Folder 3", + ], + ] + `) + + expect(childrenRequests).toMatchInlineSnapshot(` + [ + [ + "0/1/0/0", + "Request 2", + ], + ] + `) + }) + + it("getChildrenEntriesFromMapper - get children when the path is empty aka rootlevel", () => { + const childrenCollections = getChildrenEntriesFromMapper( + "", + restCollectionsMapper + ) + + const childrenRequests = getChildrenEntriesFromMapper( + "", + restRequestsMapper + ) + + expect(childrenCollections).toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + [ + "0/1/0", + "Folder 3", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + [ + "1/0/0", + "Folder 5", + ], + ] + `) + + expect(childrenRequests).toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + ] + `) + }) +}) + +describe("getDirectChildrenEntriesFromMapper", () => { + beforeEach(cleanUpAndSeedMappers) + + it("getDirectChildrenEntriesFromMapper - get direct children of root collection", () => { + const childrenCollections = getDirectChildrenEntriesFromMapper( + "0", + restCollectionsMapper + ) + + const childrenRequests = getDirectChildrenEntriesFromMapper( + "0", + restRequestsMapper + ) + + expect(childrenCollections).toMatchInlineSnapshot(` + [ + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + ] + `) + + expect(childrenRequests).toMatchInlineSnapshot(` + [ + [ + "0/0", + "Request 3", + ], + ] + `) + }) + + it("getDirectChildrenEntriesFromMapper - get direct children of folder", () => { + const childrenCollections = getDirectChildrenEntriesFromMapper( + "0/1", + restCollectionsMapper + ) + + const childrenRequests = getDirectChildrenEntriesFromMapper( + "0/1", + restRequestsMapper + ) + + expect(childrenCollections).toMatchInlineSnapshot(` + [ + [ + "0/1/0", + "Folder 3", + ], + ] + `) + + expect(childrenRequests).toMatchInlineSnapshot("[]") + }) + + it("getDirectChildrenEntriesFromMapper - get direct children when the path is empty aka rootlevel", () => { + const childrenCollections = getDirectChildrenEntriesFromMapper( + "", + restCollectionsMapper + ) + + const childrenRequests = getDirectChildrenEntriesFromMapper( + "", + restRequestsMapper + ) + + expect(childrenCollections).toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "1", + "Collection 2", + ], + ] + `) + + // we do not have root level requests, so this will always be empty + expect(childrenRequests).toMatchInlineSnapshot("[]") + }) +}) + +describe("removeAllChildCollectionsFromMapper", () => { + beforeEach(cleanUpAndSeedMappers) + + it("removeAllChildCollectionsFromMapper - remove all child collections of a root collection", () => { + removeAllChildCollectionsFromMapper("0", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + [ + "1/0/0", + "Folder 5", + ], + ] + `) + }) + + it("removeAllChildCollectionsFromMapper - remove all child collections of a folder", () => { + removeAllChildCollectionsFromMapper("1/0", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + [ + "0/1/0", + "Folder 3", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + ] + `) + }) + + it("removeAllChildCollectionsFromMapper - remove all child collections when the path is empty aka rootlevel", () => { + removeAllChildCollectionsFromMapper("", "REST") + + expect( + getEntriesArrayFromMapper(restCollectionsMapper) + ).toMatchInlineSnapshot("[]") + }) +}) + +describe("removeAllChildRequestsFromMapper", () => { + beforeEach(cleanUpAndSeedMappers) + + it("removeAllChildRequestsFromMapper - remove all child requests of a root collection", () => { + removeAllChildRequestsFromMapper("0", "REST") + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + ] + `) + }) + + it("removeAllChildRequestsFromMapper - remove all child requests of a folder", () => { + removeAllChildRequestsFromMapper("1/0", "REST") + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + ] + `) + }) + + it("removeAllChildRequestsFromMapper - remove all child requests when the path is empty aka rootlevel", () => { + removeAllChildRequestsFromMapper("", "REST") + + expect(getEntriesArrayFromMapper(restRequestsMapper)).toMatchInlineSnapshot( + "[]" + ) + }) +}) + +describe("changeParentForAllChildrenFromMapper", () => { + beforeEach(cleanUpAndSeedMappers) + + it("changes parent for all children", () => { + restCollectionsMapper.removeEntry(undefined, "0") + removeAllChildCollectionsFromMapper("0", "REST") + removeAllChildRequestsFromMapper("0", "REST") + + // for our usecases we assume that the newParentPath does not exist + // so we are not caring about children that are already present in the newParentPath + // also remember this function does not rename the parent, in this case, parentCollection "1" will still be named 1, but it won't have any children + changeParentForAllChildrenFromMapper("1", "0", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "1", + "Collection 2", + ], + [ + "0/0", + "Folder 4", + ], + [ + "0/0/0", + "Folder 5", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0/0", + "Request 4", + ], + [ + "0/0", + "Request 6", + ], + [ + "0/1", + "Request 7", + ], + ] + `) + }) +}) + +describe("reorderIndexesAfterEntryRemoval", () => { + beforeEach(cleanUpAndSeedMappers) + + it("reorderIndexesAfterEntryRemoval - reorder a collection", () => { + restCollectionsMapper.removeEntry(undefined, "0/0") + removeAllChildCollectionsFromMapper("0/0", "REST") + removeAllChildRequestsFromMapper("0/0", "REST") + + reorderIndexesAfterEntryRemoval("0", restCollectionsMapper, "REST") + reorderIndexesAfterEntryRemoval("0", restRequestsMapper, "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + [ + "1/0/0", + "Folder 5", + ], + [ + "0/0", + "Folder 2", + ], + [ + "0/0/0", + "Folder 3", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)).toMatchInlineSnapshot( + ` + [ + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + [ + "0/0/0/0", + "Request 2", + ], + ] + ` + ) + }) + + it("reorderIndexesAfterEntryRemoval - reorder when the path is empty aka rootlevel", () => { + restCollectionsMapper.removeEntry(undefined, "0") + removeAllChildCollectionsFromMapper("0", "REST") + removeAllChildRequestsFromMapper("0", "REST") + + reorderIndexesAfterEntryRemoval("", restCollectionsMapper, "REST") + reorderIndexesAfterEntryRemoval("", restRequestsMapper, "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 2", + ], + [ + "0/0", + "Folder 4", + ], + [ + "0/0/0", + "Folder 5", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)).toMatchInlineSnapshot( + ` + [ + [ + "0/0/0/0", + "Request 4", + ], + [ + "0/0", + "Request 6", + ], + [ + "0/1", + "Request 7", + ], + ] + ` + ) + }) +}) + +describe("removeAndReorderEntries", () => { + beforeEach(cleanUpAndSeedMappers) + + it("removeAndReorderEntries - removing a collection", () => { + removeAndReorderEntries("0", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 2", + ], + [ + "0/0", + "Folder 4", + ], + [ + "0/0/0", + "Folder 5", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0/0", + "Request 4", + ], + [ + "0/0", + "Request 6", + ], + [ + "0/1", + "Request 7", + ], + ] + `) + }) + + it("removeAndReorderEntries - removing a folder", () => { + removeAndReorderEntries("0/0", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + [ + "1/0/0", + "Folder 5", + ], + [ + "0/0", + "Folder 2", + ], + [ + "0/0/0", + "Folder 3", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + [ + "0/0/0/0", + "Request 2", + ], + ] + `) + }) +}) + +describe("moveRequestsInMapper", () => { + beforeEach(() => { + cleanUpAndSeedMappers() + // the moveRequestInMapper function uses the collections store + cleanUpAndSeedStore() + }) + + it("moveRequestsInMapper - move request from collection to collection", () => { + moveRequestInMapper(0, "1", "0", "REST") + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 7", + ], + [ + "0/1", + "Request 6", + ], + ] + `) + }) + + it("moveRequestsInMapper - move request from folder to collection", () => { + moveRequestInMapper(0, "0/1/0", "1", "REST") + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + [ + "1/2", + "Request 2", + ], + ] + `) + }) + + it("moveRequestsInMapper - move request from folder to folder", () => { + moveRequestInMapper(0, "0/1/0", "1/0/0", "REST") + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + [ + "1/0/0/1", + "Request 2", + ], + ] + `) + }) +}) + +describe("moveCollectionInMapper", () => { + beforeEach(() => { + cleanUpAndSeedMappers() + // the moveCollectionInMapper function uses the collections store + cleanUpAndSeedStore() + }) + + it("moveCollectionInMapper - move folder form collection to collection", () => { + // moveRESTFolder("1/0/0", "0") + moveCollectionInMapper("1/0/0", "0", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + [ + "0/1/0", + "Folder 3", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + [ + "0/2", + "Folder 5", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + [ + "0/2/0", + "Request 4", + ], + ] + `) + }) + + it("moveCollectionsInMapper - move folder from folder to folder", () => { + // moveRESTFolder("1/0/0", "0/1/0") + moveCollectionInMapper("1/0/0", "0/1/0", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + [ + "0/1/0", + "Folder 3", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + [ + "0/1/0/0", + "Folder 5", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + [ + "0/1/0/0/0", + "Request 4", + ], + ] + `) + }) + + it("moveCollectionsInMapper - move folder in same collection to another folder in same collection", () => { + moveCollectionInMapper("0/1/0", "0/0", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + [ + "1/0/0", + "Folder 5", + ], + [ + "0/0/0", + "Folder 3", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + [ + "0/0/0/0", + "Request 2", + ], + ] + `) + }) +}) + +describe("reorderRequestsMapper", () => { + beforeEach(() => { + cleanUpAndSeedMappers() + + // just adding some extra requests specifically for testing this function + restRequestsMapper.addEntry("1/2", "Requests 8") + restRequestsMapper.addEntry("1/3", "Requests 9") + restRequestsMapper.addEntry("1/4", "Requests 10") + }) + + it("reorderRequestsMapper - reorders a request up down", () => { + reorderRequestsMapper(0, "1", 3, "REST") + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 7", + ], + [ + "1/1", + "Requests 8", + ], + [ + "1/2", + "Request 6", + ], + [ + "1/3", + "Requests 9", + ], + [ + "1/4", + "Requests 10", + ], + ] + `) + }) + + it("reorderRequestsMapper - reorders a request down up", () => { + reorderRequestsMapper(3, "1", 2, "REST") + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + [ + "1/2", + "Requests 9", + ], + [ + "1/3", + "Requests 8", + ], + [ + "1/4", + "Requests 10", + ], + ] + `) + }) +}) + +describe("reorderCollectionsMapper", () => { + beforeEach(() => { + cleanUpAndSeedMappers() + + // adding some extra collections specifically for testing this function + restCollectionsMapper.addEntry("2", "Collection 3") + restCollectionsMapper.addEntry("3", "Collection 4") + restCollectionsMapper.addEntry("4", "Collection 5") + restCollectionsMapper.addEntry("5", "Collection 6") + }) + + it("reorderCollectionsMapper - reorders a collection up down", () => { + reorderCollectionsInMapper("2", "5", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + [ + "0/1/0", + "Folder 3", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + [ + "1/0/0", + "Folder 5", + ], + [ + "2", + "Collection 4", + ], + [ + "3", + "Collection 5", + ], + [ + "4", + "Collection 3", + ], + [ + "5", + "Collection 6", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + ] + `) + }) + + it("reorderCollectionsMapper - reorders a folder up down", () => { + // add extra folders to test this case + restCollectionsMapper.addEntry("1/1", "Folder 6") + restCollectionsMapper.addEntry("1/1/0", "Child Folder 1") + restCollectionsMapper.addEntry("1/2", "Folder 7") + restCollectionsMapper.addEntry("1/2/0", "Child Folder 2") + restCollectionsMapper.addEntry("1/3", "Folder 8") + restCollectionsMapper.addEntry("1/3/0", "Child Folder 3") + restCollectionsMapper.addEntry("1/4", "Folder 9") + restCollectionsMapper.addEntry("1/4/0", "Child Folder 4") + + reorderCollectionsInMapper("1/2", "1/4", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + [ + "0/1/0", + "Folder 3", + ], + [ + "1", + "Collection 2", + ], + [ + "1/0", + "Folder 4", + ], + [ + "1/0/0", + "Folder 5", + ], + [ + "2", + "Collection 3", + ], + [ + "3", + "Collection 4", + ], + [ + "4", + "Collection 5", + ], + [ + "5", + "Collection 6", + ], + [ + "1/1", + "Folder 6", + ], + [ + "1/1/0", + "Child Folder 1", + ], + [ + "1/2", + "Folder 8", + ], + [ + "1/3", + "Folder 7", + ], + [ + "1/4", + "Folder 9", + ], + [ + "1/4/0", + "Child Folder 4", + ], + [ + "1/2/0", + "Child Folder 3", + ], + [ + "1/3/0", + "Child Folder 2", + ], + ] + `) + + expect(getEntriesArrayFromMapper(restRequestsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0/0/0", + "Request 1", + ], + [ + "0/1/0/0", + "Request 2", + ], + [ + "0/0", + "Request 3", + ], + [ + "1/0/0/0", + "Request 4", + ], + [ + "1/0", + "Request 6", + ], + [ + "1/1", + "Request 7", + ], + ] + `) + }) + + it("reorderRequestsMapper - reorders a collection down up", () => { + reorderCollectionsInMapper("3", "1", "REST") + + expect(getEntriesArrayFromMapper(restCollectionsMapper)) + .toMatchInlineSnapshot(` + [ + [ + "0", + "Collection 1", + ], + [ + "0/0", + "Folder 1", + ], + [ + "0/1", + "Folder 2", + ], + [ + "0/1/0", + "Folder 3", + ], + [ + "1", + "Collection 4", + ], + [ + "2", + "Collection 2", + ], + [ + "3", + "Collection 3", + ], + [ + "4", + "Collection 5", + ], + [ + "5", + "Collection 6", + ], + [ + "2/0", + "Folder 4", + ], + [ + "2/0/0", + "Folder 5", + ], + ] + `) + }) +}) + +function cleanUpAndSeedMappers() { + // remove all collections + Array.from(restCollectionsMapper.getValue().entries()).forEach(([path]) => { + restCollectionsMapper.removeEntry(undefined, path) + }) + + // remove all requests + Array.from(restRequestsMapper.getValue().entries()).forEach(([path]) => { + restRequestsMapper.removeEntry(undefined, path) + }) + + // populate sample collections and requests + /** + * 0. Collection 1 + * 0. Folder 1 + * 0. Request 1 + * 1. Folder 2 + * 0. Folder 3 + * 0.Request 2 + * 0. Request 3 + * 1. Collection 2 + * 0. Folder 4 + * 0. Folder 5 + * 0. Request 4 + * 0. Request 6 + * 1. Request 7 + */ + restCollectionsMapper.addEntry("0", "Collection 1") + + restCollectionsMapper.addEntry("0/0", "Folder 1") + restCollectionsMapper.addEntry("0/1", "Folder 2") + restCollectionsMapper.addEntry("0/1/0", "Folder 3") + + restRequestsMapper.addEntry("0/0/0", "Request 1") + restRequestsMapper.addEntry("0/1/0/0", "Request 2") + restRequestsMapper.addEntry("0/0", "Request 3") + restCollectionsMapper.addEntry("1", "Collection 2") + restCollectionsMapper.addEntry("1/0", "Folder 4") + restCollectionsMapper.addEntry("1/0/0", "Folder 5") + + restRequestsMapper.addEntry("1/0/0/0", "Request 4") + restRequestsMapper.addEntry("1/0", "Request 6") + restRequestsMapper.addEntry("1/1", "Request 7") +} + +function cleanUpAndSeedStore() { + // reset the store + setRESTCollections([]) + + /** + * * 0. Collection 1 + * 0. Folder 1 + * 0. Request 1 + * 1. Folder 2 + * 0. Folder 3 + * 0.Request 2 + * 0. Request 3 + * 1. Collection 2 + * 0. Folder 4 + * 0. Folder 5 + * 0. Request 4 + * 0. Request 6 + * 1. Request 7 + */ + + addRESTCollection( + makeCollection({ + name: "Collection 1", + folders: [ + makeCollection({ + name: "Folder 1", + folders: [], + requests: [makeEmptyRequest("Request 1")], + }), + makeCollection({ + name: "Folder 2", + folders: [ + makeCollection({ + name: "Folder 3", + folders: [], + requests: [makeEmptyRequest("Request 2")], + }), + ], + requests: [], + }), + ], + requests: [makeEmptyRequest("Request 3")], + }) + ) + + addRESTCollection( + makeCollection({ + name: "Collection 2", + folders: [ + makeCollection({ + name: "Folder 4", + folders: [ + makeCollection({ + name: "Folder 5", + folders: [], + requests: [makeEmptyRequest("Request 4")], + }), + ], + requests: [], + }), + ], + requests: [makeEmptyRequest("Request 6"), makeEmptyRequest("Request 7")], + }) + ) +} + +function makeEmptyRequest(name: string): HoppRESTRequest { + return { + name, + auth: { + authType: "none", + authActive: false, + }, + endpoint: "", + body: { + contentType: null, + body: null, + }, + headers: [], + params: [], + method: "GET", + preRequestScript: "", + v: "1", + testScript: "", + } +}