From a3956433874d2e8e4d7c90b162217207f3447bff Mon Sep 17 00:00:00 2001 From: James George <25279263+jamesgeorge007@users.noreply.github.com> Date: Tue, 10 Sep 2024 00:23:33 -0700 Subject: [PATCH] fix: ensure cross-platform compatibility with file exports (#4336) --- .../components/collections/ImportExport.vue | 33 +++++++----- .../collections/graphql/ImportExport.vue | 8 +-- .../components/environments/ImportExport.vue | 50 +++++++++---------- .../environments/my/Environment.vue | 12 +++-- .../environments/teams/Environment.vue | 11 ++-- .../import-export/export/environment.ts | 43 ++++++---------- .../src/helpers/import-export/export/index.ts | 44 ++++++++-------- .../hoppscotch-common/src/platform/std/io.ts | 20 ++++---- 8 files changed, 114 insertions(+), 107 deletions(-) diff --git a/packages/hoppscotch-common/src/components/collections/ImportExport.vue b/packages/hoppscotch-common/src/components/collections/ImportExport.vue index 7bc85c676..726ea4eb5 100644 --- a/packages/hoppscotch-common/src/components/collections/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/collections/ImportExport.vue @@ -48,7 +48,7 @@ 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" @@ -389,27 +389,27 @@ const HoppMyCollectionsExporter: ImporterOrExporter = { applicableTo: ["personal-workspace"], isLoading: isHoppMyCollectionExporterInProgress, }, - action: () => { + action: async () => { if (!myCollections.value.length) { return toast.error(t("error.no_collections_to_export")) } isHoppMyCollectionExporterInProgress.value = true - const message = initializeDownloadCollection( + const message = await initializeDownloadFile( myCollectionsExporter(myCollections.value), - "Collections" + "hoppscotch-personal-collections" ) - if (E.isRight(message)) { - toast.success(t(message.right)) + E.isRight(message) + ? toast.success(t(message.right)) + : toast.error(t(message.left)) - platform.analytics?.logEvent({ - type: "HOPP_EXPORT_COLLECTION", - exporter: "json", - platform: "rest", - }) - } + platform.analytics?.logEvent({ + type: "HOPP_EXPORT_COLLECTION", + exporter: "json", + platform: "rest", + }) isHoppMyCollectionExporterInProgress.value = false }, @@ -436,7 +436,14 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = { ) if (E.isRight(res)) { - initializeDownloadCollection(res.right, "team-collections") + const message = await initializeDownloadFile( + res.right, + "hoppscotch-team-collections" + ) + + E.isRight(message) + ? toast.success(t(message.right)) + : toast.error(t(message.left)) 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..18062ca95 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,14 +133,14 @@ 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" + "hoppscotch-gql-collections" ) if (E.isLeft(message)) { diff --git a/packages/hoppscotch-common/src/components/environments/ImportExport.vue b/packages/hoppscotch-common/src/components/environments/ImportExport.vue index fc97315f4..2f86598d7 100644 --- a/packages/hoppscotch-common/src/components/environments/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/environments/ImportExport.vue @@ -13,36 +13,36 @@ import { Environment, NonSecretEnvironment } from "@hoppscotch/data" import * as E from "fp-ts/Either" import { ref } from "vue" +import { ImporterOrExporter } from "~/components/importExport/types" import { useI18n } from "~/composables/i18n" import { useToast } from "~/composables/toast" -import { ImporterOrExporter } from "~/components/importExport/types" +import { hoppEnvImporter } from "~/helpers/import-export/import/hoppEnv" import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource" import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource" -import { hoppEnvImporter } from "~/helpers/import-export/import/hoppEnv" import { - appendEnvironments, addGlobalEnvVariable, + appendEnvironments, environments$, } from "~/newstore/environments" -import { createTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment" -import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment" import { GQLError } from "~/helpers/backend/GQLClient" import { CreateTeamEnvironmentMutation } from "~/helpers/backend/graphql" -import { postmanEnvImporter } from "~/helpers/import-export/import/postmanEnv" +import { createTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment" import { insomniaEnvImporter } from "~/helpers/import-export/import/insomniaEnv" +import { postmanEnvImporter } from "~/helpers/import-export/import/postmanEnv" +import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment" -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 { computed } from "vue" import { useReadonlyStream } from "~/composables/stream" +import { initializeDownloadFile } from "~/helpers/import-export/export" import { environmentsExporter } from "~/helpers/import-export/export/environments" import { gistExporter } from "~/helpers/import-export/export/gist" import { platform } from "~/platform" +import IconInsomnia from "~icons/hopp/insomnia" +import IconPostman from "~icons/hopp/postman" +import IconFolderPlus from "~icons/lucide/folder-plus" +import IconUser from "~icons/lucide/user" const t = useI18n() const toast = useToast() @@ -67,19 +67,17 @@ const isTeamEnvironment = computed(() => { }) const environmentJson = computed(() => { - if ( - props.environmentType === "TEAM_ENV" && - props.teamEnvironments !== undefined - ) { - const teamEnvironments = props.teamEnvironments.map( - (x) => x.environment as Environment - ) - return teamEnvironments + if (isTeamEnvironment.value && props.teamEnvironments) { + return props.teamEnvironments.map((x) => x.environment) } return myEnvironments.value }) +const workspaceType = computed(() => + isTeamEnvironment.value ? "team" : "personal" +) + const HoppEnvironmentsImport: ImporterOrExporter = { metadata: { id: "import.from_json", @@ -105,7 +103,7 @@ const HoppEnvironmentsImport: ImporterOrExporter = { platform.analytics?.logEvent({ type: "HOPP_IMPORT_ENVIRONMENT", platform: "rest", - workspaceType: isTeamEnvironment.value ? "team" : "personal", + workspaceType: workspaceType.value, }) emit("hide-modal") @@ -138,7 +136,7 @@ const PostmanEnvironmentsImport: ImporterOrExporter = { platform.analytics?.logEvent({ type: "HOPP_IMPORT_ENVIRONMENT", platform: "rest", - workspaceType: isTeamEnvironment.value ? "team" : "personal", + workspaceType: workspaceType.value, }) emit("hide-modal") @@ -178,7 +176,7 @@ const insomniaEnvironmentsImport: ImporterOrExporter = { platform.analytics?.logEvent({ type: "HOPP_IMPORT_ENVIRONMENT", platform: "rest", - workspaceType: isTeamEnvironment.value ? "team" : "personal", + workspaceType: workspaceType.value, }) emit("hide-modal") @@ -214,7 +212,7 @@ const EnvironmentsImportFromGIST: ImporterOrExporter = { platform.analytics?.logEvent({ type: "HOPP_IMPORT_ENVIRONMENT", platform: "rest", - workspaceType: isTeamEnvironment.value ? "team" : "personal", + workspaceType: workspaceType.value, }) emit("hide-modal") }, @@ -230,14 +228,14 @@ 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" + `hoppscotch-${workspaceType.value}-environments` ) if (E.isLeft(message)) { diff --git a/packages/hoppscotch-common/src/components/environments/my/Environment.vue b/packages/hoppscotch-common/src/components/environments/my/Environment.vue index 0a1189a51..5464117d3 100644 --- a/packages/hoppscotch-common/src/components/environments/my/Environment.vue +++ b/packages/hoppscotch-common/src/components/environments/my/Environment.vue @@ -126,6 +126,8 @@ import { useService } from "dioc/vue" import { cloneDeep } from "lodash-es" import { computed, ref } from "vue" import { TippyComponent } from "vue-tippy" +import * as E from "fp-ts/Either" + import { exportAsJSON } from "~/helpers/import-export/export/environment" import { createEnvironment, @@ -163,11 +165,13 @@ const secretEnvironmentService = useService(SecretEnvironmentService) const isGlobalEnvironment = computed(() => props.environmentIndex === "Global") -const exportEnvironmentAsJSON = () => { +const exportEnvironmentAsJSON = async () => { const { environment, environmentIndex } = props - exportAsJSON(environment, environmentIndex) - ? toast.success(t("state.download_started")) - : toast.error(t("state.download_failed")) + + const message = await exportAsJSON(environment, environmentIndex) + E.isRight(message) + ? toast.success(t(message.right)) + : toast.error(t(message.left)) } const tippyActions = ref(null) diff --git a/packages/hoppscotch-common/src/components/environments/teams/Environment.vue b/packages/hoppscotch-common/src/components/environments/teams/Environment.vue index f6eb85d79..fdc0a683a 100644 --- a/packages/hoppscotch-common/src/components/environments/teams/Environment.vue +++ b/packages/hoppscotch-common/src/components/environments/teams/Environment.vue @@ -128,6 +128,7 @@ import * as TE from "fp-ts/TaskEither" import { pipe } from "fp-ts/function" import { ref } from "vue" import { TippyComponent } from "vue-tippy" +import * as E from "fp-ts/Either" import { useI18n } from "~/composables/i18n" import { GQLError } from "~/helpers/backend/GQLClient" @@ -161,10 +162,12 @@ const secretEnvironmentService = useService(SecretEnvironmentService) const confirmRemove = ref(false) -const exportEnvironmentAsJSON = () => - exportAsJSON(props.environment) - ? toast.success(t("state.download_started")) - : toast.error(t("state.download_failed")) +const exportEnvironmentAsJSON = async () => { + const message = await exportAsJSON(props.environment) + E.isRight(message) + ? toast.success(t(message.right)) + : toast.error(t(message.left)) +} const tippyActions = ref(null) const options = ref(null) diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts b/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts index 22b6dff58..ef95fcd27 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts @@ -1,9 +1,11 @@ import { Environment } from "@hoppscotch/data" -import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment" +import * as E from "fp-ts/Either" import { cloneDeep } from "lodash-es" -import { platform } from "~/platform" -const getEnvironmentJson = ( +import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment" +import { initializeDownloadFile } from "." + +const getEnvironmentJSON = ( environmentObj: TeamEnvironment | Environment, environmentIndex?: number | "Global" | null ) => { @@ -22,33 +24,20 @@ const getEnvironmentJson = ( : undefined } -export const exportAsJSON = ( +export const exportAsJSON = async ( environmentObj: Environment | TeamEnvironment, environmentIndex?: number | "Global" | null -): boolean => { - const dataToWrite = getEnvironmentJson(environmentObj, environmentIndex) +): Promise | E.Left> => { + const environmentJSON = getEnvironmentJSON(environmentObj, environmentIndex) - if (!dataToWrite) return false + if (!environmentJSON) { + return E.left("state.download_failed") + } - const file = new Blob([dataToWrite], { type: "application/json" }) - const url = URL.createObjectURL(file) + const message = await initializeDownloadFile( + environmentJSON, + JSON.parse(environmentJSON).name + ) - URL.revokeObjectURL(url) - - platform.io.saveFileWithDialog({ - data: dataToWrite, - contentType: "application/json", - // Extracts the path from url, removes fragment identifier and query parameters if any, appends the ".json" extension, and assigns it - suggestedFilename: `${ - url.split("/").pop()!.split("#")[0].split("?")[0] - }.json`, - filters: [ - { - name: "JSON file", - extensions: ["json"], - }, - ], - }) - - return true + return message } 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..84a14348a 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,36 @@ 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 + * Create a downloadable file from a collection/environment and prompts the user to download it. + * @param contentsJSON - JSON string of the collection * @param name - Name of the collection set as the file name + * @returns {Promise | E.Left>} - Returns a promise that resolves to an `Either` with `i18n` key for the status message */ -export const initializeDownloadCollection = ( - collectionJSON: string, +export const initializeDownloadFile = async ( + contentsJSON: string, name: string | null ) => { - const file = new Blob([collectionJSON], { type: "application/json" }) - const a = document.createElement("a") + const file = new Blob([contentsJSON], { type: "application/json" }) const url = URL.createObjectURL(file) - a.href = url - if (name) { - a.download = `${name}.json` - } else { - a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json` + const fileName = name ?? url.split("/").pop()!.split("#")[0].split("?")[0] + + const result = await platform.io.saveFileWithDialog({ + data: contentsJSON, + contentType: "application/json", + suggestedFilename: `${fileName}.json`, + filters: [ + { + name: "Hoppscotch Collection/Environment JSON file", + extensions: ["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/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()