feat: support import collections between workspaces (#4377)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Akash K
2024-09-30 18:42:09 +05:30
committed by GitHub
parent e4d9f82a75
commit fdf5bf34ed
9 changed files with 447 additions and 77 deletions

View File

@@ -4,6 +4,9 @@
"autoscroll": "Autoscroll",
"cancel": "Cancel",
"choose_file": "Choose a file",
"choose_workspace": "Choose a workspace",
"choose_collection": "Choose a collection",
"select_workspace": "Select a workspace",
"clear": "Clear",
"clear_all": "Clear all",
"clear_history": "Clear all History",
@@ -460,6 +463,8 @@
"from_json_description": "Import from Hoppscotch collection file",
"from_my_collections": "Import from Personal Collections",
"from_my_collections_description": "Import from Personal Collections file",
"from_all_collections": "Import from Another Workspace",
"from_all_collections_description": "Import any collection from Another Workspace to the current workspace",
"from_openapi": "Import from OpenAPI",
"from_openapi_description": "Import from OpenAPI specification file (YML/JSON)",
"from_postman": "Import from Postman",
@@ -947,7 +952,9 @@
"subscribed_success": "Successfully subscribed to topic: {topic}",
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}",
"unsubscribed_success": "Successfully unsubscribed from topic: {topic}",
"waiting_send_request": "Waiting to send request"
"waiting_send_request": "Waiting to send request",
"loading_workspaces": "Loading workspaces",
"loading_collections_in_workspace": "Loading collections in workspace"
},
"support": {
"changelog": "Read more about latest releases",

View File

@@ -186,6 +186,7 @@ declare module 'vue' {
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
ImportExportImportExportSourcesList: typeof import('./components/importExport/ImportExportSourcesList.vue')['default']
ImportExportImportExportStepsAllCollectionImport: typeof import('./components/importExport/ImportExportSteps/AllCollectionImport.vue')['default']
ImportExportImportExportStepsFileImport: typeof import('./components/importExport/ImportExportSteps/FileImport.vue')['default']
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']

View File

@@ -29,7 +29,7 @@ import {
import { defineStep } from "~/composables/step-components"
import MyCollectionImport from "~/components/importExport/ImportExportSteps/MyCollectionImport.vue"
import AllCollectionImport from "~/components/importExport/ImportExportSteps/AllCollectionImport.vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
@@ -61,7 +61,7 @@ const isPostmanImporterInProgress = ref(false)
const isInsomniaImporterInProgress = ref(false)
const isOpenAPIImporterInProgress = ref(false)
const isRESTImporterInProgress = ref(false)
const isPersonalCollectionImporterInProgress = ref(false)
const isAllCollectionImporterInProgress = ref(false)
const isHarImporterInProgress = ref(false)
const isGistImporterInProgress = ref(false)
@@ -209,19 +209,19 @@ const HoppRESTImporter: ImporterOrExporter = {
}),
}
const HoppMyCollectionImporter: ImporterOrExporter = {
const HoppAllCollectionImporter: ImporterOrExporter = {
metadata: {
id: "hopp_my_collection",
name: "import.from_my_collections",
title: "import.from_my_collections_description",
id: "hopp_all_collection",
name: "import.from_all_collections",
title: "import.from_all_collections_description",
icon: IconUser,
disabled: false,
applicableTo: ["team-workspace"],
disabled: !currentUser.value,
applicableTo: ["personal-workspace", "team-workspace"],
},
component: defineStep("my_collection_import", MyCollectionImport, () => ({
loading: isPersonalCollectionImporterInProgress.value,
async onImportFromMyCollection(content) {
isPersonalCollectionImporterInProgress.value = true
component: defineStep("all_collection_import", AllCollectionImport, () => ({
loading: isAllCollectionImporterInProgress.value,
async onImportCollection(content) {
isAllCollectionImporterInProgress.value = true
await handleImportToStore([content])
@@ -232,7 +232,7 @@ const HoppMyCollectionImporter: ImporterOrExporter = {
platform: "rest",
})
isPersonalCollectionImporterInProgress.value = false
isAllCollectionImporterInProgress.value = false
},
})),
}
@@ -351,7 +351,7 @@ const HoppInsomniaImporter: ImporterOrExporter = {
name: "import.from_insomnia",
title: "import.from_insomnia_description",
icon: IconInsomnia,
disabled: true,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: FileSource({
@@ -387,7 +387,7 @@ const HoppGistImporter: ImporterOrExporter = {
name: "import.from_gist",
title: "import.from_gist_description",
icon: IconGithub,
disabled: true,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: GistSource({
@@ -596,7 +596,7 @@ const HARImporter: ImporterOrExporter = {
const importerModules = computed(() => {
const enabledImporters = [
HoppRESTImporter,
HoppMyCollectionImporter,
HoppAllCollectionImporter,
HoppOpenAPIImporter,
HoppPostmanImporter,
HoppInsomniaImporter,
@@ -607,6 +607,10 @@ const importerModules = computed(() => {
const isTeams = props.collectionsType.type === "team-collections"
return enabledImporters.filter((importer) => {
if (importer.metadata.disabled) {
return false
}
return isTeams
? importer.metadata.applicableTo.includes("team-workspace")
: importer.metadata.applicableTo.includes("personal-workspace")

View File

@@ -1,18 +1,6 @@
<template>
<div class="flex flex-col">
<HoppSmartExpand v-if="showExpand">
<template #body>
<HoppSmartItem
v-for="importer in importers"
:key="importer.id"
:icon="importer.icon"
:label="t(`${importer.name}`)"
@click="emit('importer-selected', importer.id)"
/>
</template>
</HoppSmartExpand>
<div v-else class="flex flex-col space-y-2">
<div class="flex flex-col space-y-2">
<HoppSmartItem
v-for="importer in importers"
:key="importer.id"
@@ -59,7 +47,7 @@
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { Component, computed } from "vue"
import { Component } from "vue"
const t = useI18n()
@@ -73,7 +61,7 @@ type ImportExportEntryMeta = {
isVisible?: boolean
}
const props = defineProps<{
defineProps<{
importers: ImportExportEntryMeta[]
exporters: ImportExportEntryMeta[]
}>()
@@ -82,6 +70,4 @@ const emit = defineEmits<{
(e: "importer-selected", importerID: string): void
(e: "exporter-selected", exporterID: string): void
}>()
const showExpand = computed(() => props.importers.length >= 4)
</script>

View File

@@ -0,0 +1,366 @@
<template>
<div class="select-wrapper flex flex-col gap-2">
<div>
<p class="flex items-center">
<span
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary text-dividerDark"
>
<icon-lucide-check-circle class="svg-icons" />
</span>
<span>
{{ t(`action.choose_workspace`) }}
</span>
</p>
<div class="pl-10">
<div v-if="isLoadingTeams" class="flex gap-1 mt-2">
<HoppSmartSpinner />
{{ t("state.loading_workspaces") }}
</div>
<select
v-else
v-model="selectedWorkspaceID"
autocomplete="off"
class="select mt-2"
autofocus
>
<option :key="undefined" :value="undefined" disabled selected>
{{ t("action.select_workspace") }}
</option>
<option
v-for="workspace in workspaces"
:key="`workspace-${workspace.id}`"
:value="workspace.id"
class="bg-primary"
>
{{ workspace.name }}
</option>
</select>
</div>
</div>
<div v-if="showSelectCollections">
<p class="flex items-center">
<span
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary text-dividerDark"
>
<icon-lucide-check-circle class="svg-icons" />
</span>
<span>
{{ t(`action.choose_collection`) }}
</span>
</p>
<div class="pl-10">
<div v-if="isGettingWorkspaceRootCollections" class="flex gap-1 mt-2">
<HoppSmartSpinner />
{{ t("state.loading_collections_in_workspace") }}
</div>
<select
v-else
v-model="selectedCollectionID"
autocomplete="off"
class="select mt-2"
autofocus
>
<option :key="undefined" :value="undefined" disabled selected>
{{ t("collection.select") }}
</option>
<option
v-for="collection in selectableCollections"
:key="collection.id"
:value="collection.id"
class="bg-primary"
>
{{ collection.title }}
</option>
</select>
</div>
</div>
</div>
<div class="my-4">
<HoppButtonPrimary
class="w-full"
:label="t('import.title')"
:loading="loading"
:disabled="!hasSelectedCollectionID || loading"
@click="getCollectionDetailsAndImport"
/>
</div>
</template>
<script setup lang="ts">
import {
GQLHeader,
HoppCollection,
HoppGQLAuth,
HoppGQLRequest,
HoppRESTAuth,
HoppRESTHeader,
HoppRESTRequest,
makeCollection,
} from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { computed, ref, watch } from "vue"
import { useI18n } from "~/composables/i18n"
import { useReadonlyStream } from "~/composables/stream"
import { runGQLQuery } from "~/helpers/backend/GQLClient"
import { RootCollectionsOfTeamDocument } from "~/helpers/backend/graphql"
import { TEAMS_BACKEND_PAGE_SIZE } from "~/helpers/teams/TeamCollectionAdapter"
import { getRESTCollection, restCollections$ } from "~/newstore/collections"
import { WorkspaceService } from "~/services/workspace.service"
import * as E from "fp-ts/Either"
import {
getCollectionChildCollections,
getSingleCollection,
} from "~/helpers/teams/TeamCollection"
import { getCollectionChildRequests } from "~/helpers/teams/TeamRequest"
import { useToast } from "~/composables/toast"
const workspaceService = useService(WorkspaceService)
const teamListAdapter = workspaceService.acquireTeamListAdapter(null)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const isLoadingTeams = useReadonlyStream(teamListAdapter.loading$, false)
const t = useI18n()
defineProps<{
loading: boolean
}>()
const selectedCollectionID = ref<string | undefined>(undefined)
const hasSelectedCollectionID = computed(() => {
return selectedCollectionID.value !== undefined
})
const personalCollections = useReadonlyStream(restCollections$, [])
const selectedWorkspaceID = ref<string | undefined>(undefined)
const isGettingWorkspaceRootCollections = ref(false)
const selectableCollections = ref<
{
id: string
title: string
data?: string | null
}[]
>([])
const toast = useToast()
watch(
selectedWorkspaceID,
async () => {
// reset the selected collection when the workspace changes
selectedCollectionID.value = undefined
if (!selectedWorkspaceID.value) {
// do some cleanup on the previous workspace selection
selectableCollections.value = []
return
}
if (selectedWorkspaceID.value === "personal") {
selectableCollections.value = personalCollections.value.map(
(collection, collectionIndex) => ({
id: `${collectionIndex}`, // because we don't have an ID for personal collections
title: collection.name,
})
)
return
}
isGettingWorkspaceRootCollections.value = true
const res = await getWorkspaceRootCollections(selectedWorkspaceID.value)
if (E.isLeft(res)) {
console.error(res.left)
isGettingWorkspaceRootCollections.value = false
return
}
selectableCollections.value = res.right
isGettingWorkspaceRootCollections.value = false
},
{
immediate: true,
}
)
const emit = defineEmits<{
(e: "importCollection", content: HoppCollection): void
}>()
const showSelectCollections = computed(() => {
return !!selectedWorkspaceID.value
})
const workspaces = computed(() => {
const allWorkspaces = [
{
id: "personal",
name: t("workspace.personal"),
},
]
myTeams.value?.forEach((team) => {
allWorkspaces.push({
id: team.id,
name: team.name,
})
})
return allWorkspaces
})
const getWorkspaceRootCollections = async (workspaceID: string) => {
const totalCollections: {
id: string
title: string
data?: string | null
}[] = []
while (true) {
const result = await runGQLQuery({
query: RootCollectionsOfTeamDocument,
variables: {
teamID: workspaceID,
cursor:
totalCollections.length > 0
? totalCollections[totalCollections.length - 1].id
: undefined,
},
})
if (E.isLeft(result)) {
return E.left(result.left)
}
totalCollections.push(...result.right.rootCollectionsOfTeam)
if (result.right.rootCollectionsOfTeam.length < TEAMS_BACKEND_PAGE_SIZE) {
break
}
}
return E.right(totalCollections)
}
const convertToInheritedProperties = (
data?: string | null
): {
auth: HoppRESTAuth | HoppGQLAuth
headers: Array<HoppRESTHeader | GQLHeader>
} => {
const collectionLevelAuthAndHeaders = data
? (JSON.parse(data) as {
auth: HoppRESTAuth | HoppGQLAuth
headers: Array<HoppRESTHeader | GQLHeader>
})
: null
const headers = collectionLevelAuthAndHeaders?.headers ?? []
const auth = collectionLevelAuthAndHeaders?.auth ?? {
authType: "none",
authActive: true,
}
return {
auth,
headers,
}
}
const getTeamCollection = async (
collectionID: string
): Promise<E.Either<any, HoppCollection>> => {
const rootCollectionRes = await getSingleCollection(collectionID)
if (E.isLeft(rootCollectionRes)) {
return E.left(rootCollectionRes.left)
}
const rootCollection = rootCollectionRes.right.collection
if (!rootCollection) {
return E.left("ROOT_COLLECTION_NOT_FOUND")
}
const childRequests = await getCollectionChildRequests(collectionID)
if (E.isLeft(childRequests)) {
return E.left(childRequests.left)
}
const childCollectionsRes = await getCollectionChildCollections(collectionID)
if (E.isLeft(childCollectionsRes)) {
return E.left(childCollectionsRes.left)
}
if (!childCollectionsRes.right.collection) {
return E.left("CHILD_COLLECTIONS_NOT_FOUND")
}
const childCollectionExpandedPromises =
childCollectionsRes.right.collection.children.map((col) =>
getTeamCollection(col.id)
)
const childCollectionPromiseRes = await Promise.all(
childCollectionExpandedPromises
)
const hasAnyError = childCollectionPromiseRes.some((res) => E.isLeft(res))
if (hasAnyError) {
return E.left("CHILD_COLLECTIONS_NOT_FOUND")
}
const unwrappedChildCollections = childCollectionPromiseRes.map(
(res) => (res as E.Right<HoppCollection>).right
)
const collectionInHoppFormat: HoppCollection = makeCollection({
name: rootCollection.title,
...convertToInheritedProperties(rootCollection.data),
folders: unwrappedChildCollections,
requests: childRequests.right.requestsInCollection.map((req) => {
return JSON.parse(req.request) as HoppRESTRequest | HoppGQLRequest
}),
})
return E.right(collectionInHoppFormat)
}
const getCollectionDetailsAndImport = async () => {
if (!selectedCollectionID.value) {
return
}
let collectionToImport: HoppCollection
if (selectedWorkspaceID.value === "personal") {
collectionToImport = getRESTCollection(parseInt(selectedCollectionID.value))
} else {
const res = await getTeamCollection(selectedCollectionID.value)
if (E.isLeft(res)) {
toast.error(t("import.failed"))
return
}
collectionToImport = res.right
}
emit("importCollection", collectionToImport)
}
</script>

View File

@@ -1,3 +1,8 @@
import { runGQLQuery } from "../backend/GQLClient"
import {
GetCollectionChildrenDocument,
GetSingleCollectionDocument,
} from "../backend/graphql"
import { TeamRequest } from "./TeamRequest"
/**
@@ -10,3 +15,19 @@ export interface TeamCollection {
requests: TeamRequest[] | null
data: string | null
}
export const getSingleCollection = (collectionID: string) =>
runGQLQuery({
query: GetSingleCollectionDocument,
variables: {
collectionID,
},
})
export const getCollectionChildCollections = (collectionID: string) =>
runGQLQuery({
query: GetCollectionChildrenDocument,
variables: {
collectionID,
},
})

View File

@@ -27,7 +27,7 @@ import {
} from "~/helpers/backend/graphql"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
const TEAMS_BACKEND_PAGE_SIZE = 10
export const TEAMS_BACKEND_PAGE_SIZE = 10
/**
* Finds the parent of a collection and returns the REFERENCE (or null)

View File

@@ -1,4 +1,9 @@
import { HoppRESTRequest } from "@hoppscotch/data"
import { runGQLQuery } from "../backend/GQLClient"
import {
GetCollectionRequestsDocument,
GetSingleRequestDocument,
} from "../backend/graphql"
/**
* Defines how a Teams request is represented in TeamCollectionAdapter
@@ -9,3 +14,19 @@ export interface TeamRequest {
title: string
request: HoppRESTRequest
}
export const getCollectionChildRequests = (collectionID: string) =>
runGQLQuery({
query: GetCollectionRequestsDocument,
variables: {
collectionID,
},
})
export const getSingleRequest = (requestID: string) =>
runGQLQuery({
query: GetSingleRequestDocument,
variables: {
requestID,
},
})

View File

@@ -8,19 +8,15 @@ import axios from "axios"
import { Service } from "dioc"
import * as E from "fp-ts/Either"
import { Ref, ref } from "vue"
import { runGQLQuery } from "../backend/GQLClient"
import {
GetCollectionChildrenDocument,
GetCollectionRequestsDocument,
GetSingleCollectionDocument,
GetSingleRequestDocument,
} from "../backend/graphql"
import { TeamCollection } from "./TeamCollection"
import { getSingleCollection, TeamCollection } from "./TeamCollection"
import { platform } from "~/platform"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
import { TeamRequest } from "./TeamRequest"
import {
getSingleRequest,
getCollectionChildRequests,
TeamRequest,
} from "./TeamRequest"
type CollectionSearchMeta = {
isSearchResult?: boolean
@@ -552,38 +548,6 @@ export class TeamSearchService extends Service {
}
}
const getSingleCollection = (collectionID: string) =>
runGQLQuery({
query: GetSingleCollectionDocument,
variables: {
collectionID,
},
})
const getSingleRequest = (requestID: string) =>
runGQLQuery({
query: GetSingleRequestDocument,
variables: {
requestID,
},
})
const getCollectionChildCollections = (collectionID: string) =>
runGQLQuery({
query: GetCollectionChildrenDocument,
variables: {
collectionID,
},
})
const getCollectionChildRequests = (collectionID: string) =>
runGQLQuery({
query: GetCollectionRequestsDocument,
variables: {
collectionID,
},
})
const formatTeamsSearchResultsForSpotlight = (
request: {
collectionID: string