diff --git a/packages/hoppscotch-common/src/components/collections/ImportExport.vue b/packages/hoppscotch-common/src/components/collections/ImportExport.vue index 714311dd8..a6d6afc90 100644 --- a/packages/hoppscotch-common/src/components/collections/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/collections/ImportExport.vue @@ -30,7 +30,7 @@ import { defineStep } from "~/composables/step-components" import { useI18n } from "~/composables/i18n" import { useToast } from "~/composables/toast" -import { appendRESTCollections, restCollections$ } from "~/newstore/collections" +import { restCollections$ } from "~/newstore/collections" import MyCollectionImport from "~/components/importExport/ImportExportSteps/MyCollectionImport.vue" import IconFolderPlus from "~icons/lucide/folder-plus" @@ -47,13 +47,14 @@ import { getTeamCollectionJSON } from "~/helpers/backend/helpers" import { platform } from "~/platform" -import { initializeDownloadCollection } from "~/helpers/import-export/export" +import { initializeDownloadFile } from "~/helpers/import-export/export" import { gistExporter } from "~/helpers/import-export/export/gist" -import { myCollectionsExporter } from "~/helpers/import-export/export/myCollections" import { teamCollectionsExporter } from "~/helpers/import-export/export/teamCollections" import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource" import { ImporterOrExporter } from "~/components/importExport/types" +import { useService } from "dioc/vue" +import { NewWorkspaceService } from "~/services/new-workspace" import { TeamWorkspace } from "~/services/workspace.service" const t = useI18n() @@ -84,15 +85,43 @@ const currentUser = useReadonlyStream( const myCollections = useReadonlyStream(restCollections$, []) +const workspaceService = useService(NewWorkspaceService) + +const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle + const showImportFailedError = () => { toast.error(t("import.failed")) } const handleImportToStore = async (collections: HoppCollection[]) => { - const importResult = - props.collectionsType.type === "my-collections" - ? await importToPersonalWorkspace(collections) - : await importToTeamsWorkspace(collections) + if (props.collectionsType.type === "my-collections") { + if (!activeWorkspaceHandle.value) { + return + } + + const collectionHandleResult = await workspaceService.importRESTCollections( + activeWorkspaceHandle.value, + collections + ) + + if (E.isLeft(collectionHandleResult)) { + // INVALID_WORKSPACE_HANDLE + return toast.error(t("import.failed")) + } + + const resultHandle = collectionHandleResult.right + + if (resultHandle.value.type === "invalid") { + // WORKSPACE_INVALIDATED + } + + toast.success(t("state.file_imported")) + emit("hide-modal") + + return + } + + const importResult = await importToTeamsWorkspace(collections) if (E.isRight(importResult)) { toast.success(t("state.file_imported")) @@ -102,13 +131,6 @@ const handleImportToStore = async (collections: HoppCollection[]) => { } } -const importToPersonalWorkspace = (collections: HoppCollection[]) => { - appendRESTCollections(collections) - return E.right({ - success: true, - }) -} - function translateToTeamCollectionFormat(x: HoppCollection) { const folders: HoppCollection[] = (x.folders ?? []).map( translateToTeamCollectionFormat @@ -388,28 +410,34 @@ const HoppMyCollectionsExporter: ImporterOrExporter = { applicableTo: ["personal-workspace"], isLoading: isHoppMyCollectionExporterInProgress, }, - action: () => { + action: async () => { if (!myCollections.value.length) { return toast.error(t("error.no_collections_to_export")) } + if (!activeWorkspaceHandle.value) { + return + } + isHoppMyCollectionExporterInProgress.value = true - const message = initializeDownloadCollection( - myCollectionsExporter(myCollections.value), - "Collections" + const result = await workspaceService.exportRESTCollections( + activeWorkspaceHandle.value, + myCollections.value ) - if (E.isRight(message)) { - toast.success(t(message.right)) - - platform.analytics?.logEvent({ - type: "HOPP_EXPORT_COLLECTION", - exporter: "json", - platform: "rest", - }) + if (E.isLeft(result)) { + // INVALID_WORKSPACE_HANDLE } + toast.success(t("state.download_started")) + + platform.analytics?.logEvent({ + type: "HOPP_EXPORT_COLLECTION", + exporter: "json", + platform: "rest", + }) + isHoppMyCollectionExporterInProgress.value = false }, } @@ -443,10 +471,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = { return toast.error(t("error.no_collections_to_export")) } - initializeDownloadCollection( - exportCollectionsToJSON, - "team-collections" - ) + initializeDownloadFile(exportCollectionsToJSON, "team-collections") platform.analytics?.logEvent({ type: "HOPP_EXPORT_COLLECTION", diff --git a/packages/hoppscotch-common/src/components/collections/graphql/ImportExport.vue b/packages/hoppscotch-common/src/components/collections/graphql/ImportExport.vue index a9dc667b8..add0f3365 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/ImportExport.vue @@ -21,7 +21,7 @@ import { GistSource } from "~/helpers/import-export/import/import-sources/GistSo import IconFolderPlus from "~icons/lucide/folder-plus" import IconUser from "~icons/lucide/user" -import { initializeDownloadCollection } from "~/helpers/import-export/export" +import { initializeDownloadFile } from "~/helpers/import-export/export" import { useReadonlyStream } from "~/composables/stream" import { platform } from "~/platform" @@ -133,12 +133,12 @@ const GqlCollectionsHoppExporter: ImporterOrExporter = { disabled: false, applicableTo: ["personal-workspace", "team-workspace"], }, - action: () => { + action: async () => { if (!gqlCollections.value.length) { return toast.error(t("error.no_collections_to_export")) } - const message = initializeDownloadCollection( + const message = await initializeDownloadFile( gqlCollectionsExporter(gqlCollections.value), "GQLCollections" ) diff --git a/packages/hoppscotch-common/src/components/environments/ImportExport.vue b/packages/hoppscotch-common/src/components/environments/ImportExport.vue index fc97315f4..3a510247e 100644 --- a/packages/hoppscotch-common/src/components/environments/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/environments/ImportExport.vue @@ -37,7 +37,7 @@ import IconFolderPlus from "~icons/lucide/folder-plus" import IconPostman from "~icons/hopp/postman" import IconInsomnia from "~icons/hopp/insomnia" import IconUser from "~icons/lucide/user" -import { initializeDownloadCollection } from "~/helpers/import-export/export" +import { initializeDownloadFile } from "~/helpers/import-export/export" import { computed } from "vue" import { useReadonlyStream } from "~/composables/stream" import { environmentsExporter } from "~/helpers/import-export/export/environments" @@ -230,12 +230,12 @@ const HoppEnvironmentsExport: ImporterOrExporter = { disabled: false, applicableTo: ["personal-workspace", "team-workspace"], }, - action: () => { + action: async () => { if (!environmentJson.value.length) { return toast.error(t("error.no_environments_to_export")) } - const message = initializeDownloadCollection( + const message = await initializeDownloadFile( environmentsExporter(environmentJson.value), "Environments" ) diff --git a/packages/hoppscotch-common/src/components/new-collections/rest/Collection.vue b/packages/hoppscotch-common/src/components/new-collections/rest/Collection.vue index 149efed62..f8ed08884 100644 --- a/packages/hoppscotch-common/src/components/new-collections/rest/Collection.vue +++ b/packages/hoppscotch-common/src/components/new-collections/rest/Collection.vue @@ -110,7 +110,8 @@ :shortcut="['X']" @click=" () => { - emit('export-data'), hide() + emit('export-collection', collectionView.collectionID) + hide() } " /> @@ -189,7 +190,7 @@ const emit = defineEmits<{ event: "edit-root-collection", payload: { collectionIndexPath: string; collectionName: string } ): void - (event: "export-data"): void + (event: "export-collection", collectionIndexPath: string): void (event: "remove-child-collection", collectionIndexPath: string): void (event: "remove-root-collection", collectionIndexPath: string): void (event: "toggle-children"): void diff --git a/packages/hoppscotch-common/src/components/new-collections/rest/index.vue b/packages/hoppscotch-common/src/components/new-collections/rest/index.vue index 11ed484b4..4f8604ab7 100644 --- a/packages/hoppscotch-common/src/components/new-collections/rest/index.vue +++ b/packages/hoppscotch-common/src/components/new-collections/rest/index.vue @@ -27,7 +27,7 @@ v-tippy="{ theme: 'tooltip' }" :icon="IconImport" :title="t('modal.import_export')" - @click="() => {}" + @click="displayModalImportExport(true)" /> @@ -50,6 +50,7 @@ @edit-child-collection="editChildCollection" @edit-root-collection="editRootCollection" @edit-collection-properties="editCollectionProperties" + @export-collection="exportCollection" @remove-child-collection="removeChildCollection" @remove-root-collection="removeRootCollection" @select-pick="onSelectPick" @@ -143,6 +144,13 @@ @resolve="resolveConfirmModal" /> + + + + { if (!show) resetSelectedData() } +const displayModalImportExport = (show: boolean) => { + showImportExportModal.value = show + + if (!show) resetSelectedData() +} + const displayModalEditProperties = (show: boolean) => { showModalEditProperties.value = show @@ -1102,6 +1117,40 @@ const setCollectionProperties = async (updatedCollectionProps: { displayModalEditProperties(false) } +const exportCollection = async (collectionIndexPath: string) => { + const collectionHandleResult = await workspaceService.getCollectionHandle( + props.workspaceHandle, + collectionIndexPath + ) + + if (E.isLeft(collectionHandleResult)) { + // INVALID_WORKSPACE_HANDLE | INVALID_COLLECTION_ID | INVALID_PATH + return + } + + const collectionHandle = collectionHandleResult.right + + if (collectionHandle.value.type === "invalid") { + // WORKSPACE_INVALIDATED + return + } + + const collection = navigateToFolderWithIndexPath( + restCollectionState.value, + collectionIndexPath.split("/").map((id) => parseInt(id)) + ) as HoppCollection + + const result = await workspaceService.exportRESTCollection( + collectionHandle, + collection + ) + + if (E.isLeft(result)) { + // INVALID_COLLECTION_HANDLE + return + } +} + const shareRequest = (request: HoppRESTRequest) => { if (currentUser.value) { // Opens the share request modal if the user is logged in diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/index.ts b/packages/hoppscotch-common/src/helpers/import-export/export/index.ts index 23e7fe5b2..f25c5cadb 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/export/index.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/export/index.ts @@ -1,32 +1,31 @@ import * as E from "fp-ts/Either" +import { platform } from "~/platform" + /** * Create a downloadable file from a collection and prompts the user to download it. * @param collectionJSON - JSON string of the collection * @param name - Name of the collection set as the file name */ -export const initializeDownloadCollection = ( +export const initializeDownloadFile = async ( collectionJSON: string, name: string | null ) => { - const file = new Blob([collectionJSON], { type: "application/json" }) - const a = document.createElement("a") - const url = URL.createObjectURL(file) - a.href = url + const result = await platform.io.saveFileWithDialog({ + data: collectionJSON, + contentType: "application/json", + suggestedFilename: `${name ?? "collection"}.json`, + filters: [ + { + name: "Hoppscotch Collection/Environment JSON file", + extensions: ["json"], + }, + ], + }) - if (name) { - a.download = `${name}.json` - } else { - a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json` + if (result.type === "unknown" || result.type === "saved") { + return E.right("state.download_started") } - document.body.appendChild(a) - a.click() - - setTimeout(() => { - document.body.removeChild(a) - URL.revokeObjectURL(url) - }, 1000) - - return E.right("state.download_started") + return E.left("state.download_failed") } diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/myCollections.ts b/packages/hoppscotch-common/src/helpers/import-export/export/myCollections.ts deleted file mode 100644 index 9a167e4ee..000000000 --- a/packages/hoppscotch-common/src/helpers/import-export/export/myCollections.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { HoppCollection } from "@hoppscotch/data" - -export const myCollectionsExporter = (myCollections: HoppCollection[]) => { - return JSON.stringify(myCollections, null, 2) -} diff --git a/packages/hoppscotch-common/src/platform/std/io.ts b/packages/hoppscotch-common/src/platform/std/io.ts index b8fd50ffc..70e55399b 100644 --- a/packages/hoppscotch-common/src/platform/std/io.ts +++ b/packages/hoppscotch-common/src/platform/std/io.ts @@ -13,15 +13,17 @@ export const browserIODef: IOPlatformDef = { const url = URL.createObjectURL(file) a.href = url - a.download = pipe( - url, - S.split("/"), - RNEA.last, - S.split("#"), - RNEA.head, - S.split("?"), - RNEA.head - ) + a.download = + opts.suggestedFilename ?? + pipe( + url, + S.split("/"), + RNEA.last, + S.split("#"), + RNEA.head, + S.split("?"), + RNEA.head + ) document.body.appendChild(a) a.click() diff --git a/packages/hoppscotch-common/src/services/new-workspace/index.ts b/packages/hoppscotch-common/src/services/new-workspace/index.ts index 2d2a93dd8..497f8bea1 100644 --- a/packages/hoppscotch-common/src/services/new-workspace/index.ts +++ b/packages/hoppscotch-common/src/services/new-workspace/index.ts @@ -383,6 +383,99 @@ export class NewWorkspaceService extends Service { return E.right(result.right) } + public async importRESTCollections( + workspaceHandle: HandleRef, + collections: HoppCollection[] + ): Promise< + E.Either< + WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, + HandleRef + > + > { + if (workspaceHandle.value.type === "invalid") { + return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" }) + } + + const provider = this.registeredProviders.get( + workspaceHandle.value.data.providerID + ) + + if (!provider) { + return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" }) + } + + const result = await provider.importRESTCollections( + workspaceHandle, + collections + ) + + if (E.isLeft(result)) { + return E.left({ type: "PROVIDER_ERROR", error: result.left }) + } + + return E.right(result.right) + } + + public async exportRESTCollections( + workspaceHandle: HandleRef, + collections: HoppCollection[] + ): Promise< + E.Either, void> + > { + if (workspaceHandle.value.type === "invalid") { + return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" }) + } + + const provider = this.registeredProviders.get( + workspaceHandle.value.data.providerID + ) + + if (!provider) { + return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" }) + } + + const result = await provider.exportRESTCollections( + workspaceHandle, + collections + ) + + if (E.isLeft(result)) { + return E.left({ type: "PROVIDER_ERROR", error: result.left }) + } + + return E.right(result.right) + } + + public async exportRESTCollection( + collectionHandle: HandleRef, + collection: HoppCollection + ): Promise< + E.Either, void> + > { + if (collectionHandle.value.type === "invalid") { + return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" }) + } + + const provider = this.registeredProviders.get( + collectionHandle.value.data.providerID + ) + + if (!provider) { + return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" }) + } + + const result = await provider.exportRESTCollection( + collectionHandle, + collection + ) + + if (E.isLeft(result)) { + return E.left({ type: "PROVIDER_ERROR", error: result.left }) + } + + return E.right(result.right) + } + public async getRESTCollectionChildrenView( collectionHandle: HandleRef ): Promise< diff --git a/packages/hoppscotch-common/src/services/new-workspace/provider.ts b/packages/hoppscotch-common/src/services/new-workspace/provider.ts index 41b2597ee..5886d7422 100644 --- a/packages/hoppscotch-common/src/services/new-workspace/provider.ts +++ b/packages/hoppscotch-common/src/services/new-workspace/provider.ts @@ -67,4 +67,17 @@ export interface WorkspaceProvider { removeRESTRequest( requestHandle: HandleRef ): Promise> + + importRESTCollections( + workspaceHandle: HandleRef, + collections: HoppCollection[] + ): Promise>> + exportRESTCollections( + workspaceHandle: HandleRef, + collections: HoppCollection[] + ): Promise> + exportRESTCollection( + collectionHandle: HandleRef, + collection: HoppCollection + ): Promise> } diff --git a/packages/hoppscotch-common/src/services/new-workspace/providers/personal.workspace.ts b/packages/hoppscotch-common/src/services/new-workspace/providers/personal.workspace.ts index 03d960306..4c9de8f60 100644 --- a/packages/hoppscotch-common/src/services/new-workspace/providers/personal.workspace.ts +++ b/packages/hoppscotch-common/src/services/new-workspace/providers/personal.workspace.ts @@ -15,6 +15,7 @@ import { useStreamStatic } from "~/composables/stream" import { addRESTCollection, addRESTFolder, + appendRESTCollections, editRESTCollection, editRESTFolder, editRESTRequest, @@ -49,6 +50,7 @@ import { HoppGQLHeader } from "~/helpers/graphql" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import IconUser from "~icons/lucide/user" import { NewWorkspaceService } from ".." +import { initializeDownloadFile } from "~/helpers/import-export/export" export class PersonalWorkspaceProviderService extends Service @@ -217,7 +219,7 @@ export class PersonalWorkspaceProviderService collectionHandle.value.data.providerID !== this.providerID || collectionHandle.value.data.workspaceID !== "personal" ) { - return Promise.resolve(E.left("INVALID_WORKSPACE_HANDLE" as const)) + return Promise.resolve(E.left("INVALID_COLLECTION_HANDLE" as const)) } const { collectionID } = collectionHandle.value.data @@ -393,6 +395,86 @@ export class PersonalWorkspaceProviderService return Promise.resolve(E.right(undefined)) } + public importRESTCollections( + workspaceHandle: HandleRef, + collections: HoppCollection[] + ): Promise>> { + if ( + workspaceHandle.value.type !== "ok" || + workspaceHandle.value.data.providerID !== this.providerID || + workspaceHandle.value.data.workspaceID !== "personal" + ) { + return Promise.resolve(E.left("INVALID_WORKSPACE_HANDLE" as const)) + } + + appendRESTCollections(collections) + + const newCollectionName = collections[0].name + const newCollectionID = + this.restCollectionState.value.state.length.toString() + + return Promise.resolve( + E.right( + computed(() => { + if ( + workspaceHandle.value.type !== "ok" || + workspaceHandle.value.data.providerID !== this.providerID || + workspaceHandle.value.data.workspaceID !== "personal" + ) { + return { + type: "invalid" as const, + reason: "WORKSPACE_INVALIDATED" as const, + } + } + + return { + type: "ok", + data: { + providerID: this.providerID, + workspaceID: workspaceHandle.value.data.workspaceID, + collectionID: newCollectionID, + name: newCollectionName, + }, + } + }) + ) + ) + } + + public exportRESTCollections( + workspaceHandle: HandleRef, + collections: HoppCollection[] + ): Promise> { + if ( + workspaceHandle.value.type !== "ok" || + workspaceHandle.value.data.providerID !== this.providerID || + workspaceHandle.value.data.workspaceID !== "personal" + ) { + return Promise.resolve(E.left("INVALID_COLLECTION_HANDLE" as const)) + } + + initializeDownloadFile(JSON.stringify(collections, null, 2), "Collections") + + return Promise.resolve(E.right(undefined)) + } + + public exportRESTCollection( + collectionHandle: HandleRef, + collection: HoppCollection + ): Promise> { + if ( + collectionHandle.value.type !== "ok" || + collectionHandle.value.data.providerID !== this.providerID || + collectionHandle.value.data.workspaceID !== "personal" + ) { + return Promise.resolve(E.left("INVALID_COLLECTION_HANDLE" as const)) + } + + initializeDownloadFile(JSON.stringify(collection, null, 2), collection.name) + + return Promise.resolve(E.right(undefined)) + } + public getCollectionHandle( workspaceHandle: HandleRef, collectionID: string