diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts index 0559e2b8a..093c25393 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -21,7 +21,11 @@ import { TEAM_MEMBER_NOT_FOUND, } from '../errors'; import { PubSubService } from '../pubsub/pubsub.service'; -import { escapeSqlLikeString, isValidLength } from 'src/utils'; +import { + escapeSqlLikeString, + isValidLength, + transformCollectionData, +} from 'src/utils'; import * as E from 'fp-ts/Either'; import * as O from 'fp-ts/Option'; import { @@ -134,11 +138,13 @@ export class TeamCollectionService { }, }); + const data = transformCollectionData(collection.right.data); + const result: CollectionFolder = { name: collection.right.title, folders: childrenCollectionObjects, requests: requests.map((x) => x.request), - data: JSON.stringify(collection.right.data), + data, }; return E.right(result); @@ -309,11 +315,13 @@ export class TeamCollectionService { * @returns TeamCollection model */ private cast(teamCollection: DBTeamCollection): TeamCollection { + const data = transformCollectionData(teamCollection.data); + return { id: teamCollection.id, title: teamCollection.title, parentID: teamCollection.parentID, - data: !teamCollection.data ? null : JSON.stringify(teamCollection.data), + data, }; } diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts index dc8c55892..69358854c 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts @@ -25,7 +25,11 @@ import { UserCollectionExportJSONData, } from './user-collections.model'; import { ReqType } from 'src/types/RequestTypes'; -import { isValidLength, stringToJson } from 'src/utils'; +import { + isValidLength, + stringToJson, + transformCollectionData, +} from 'src/utils'; import { CollectionFolder } from 'src/types/CollectionFolder'; @Injectable() @@ -43,13 +47,15 @@ export class UserCollectionService { * @returns UserCollection model */ private cast(collection: UserCollection) { + const data = transformCollectionData(collection.data); + return { id: collection.id, title: collection.title, type: collection.type, parentID: collection.parentID, userID: collection.userUid, - data: !collection.data ? null : JSON.stringify(collection.data), + data, }; } @@ -871,6 +877,8 @@ export class UserCollectionService { }, }); + const data = transformCollectionData(collection.right.data); + const result: CollectionFolder = { id: collection.right.id, name: collection.right.title, @@ -882,7 +890,7 @@ export class UserCollectionService { ...(x.request as Record), // type casting x.request of type Prisma.JSONValue to an object to enable spread }; }), - data: JSON.stringify(collection.right.data), + data, }; return E.right(result); diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index cc32967cb..5be926174 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -1,21 +1,21 @@ import { ExecutionContext, HttpException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { GqlExecutionContext } from '@nestjs/graphql'; +import { Prisma } from '@prisma/client'; +import * as A from 'fp-ts/Array'; +import * as E from 'fp-ts/Either'; import { pipe } from 'fp-ts/lib/function'; import * as O from 'fp-ts/Option'; -import * as TE from 'fp-ts/TaskEither'; import * as T from 'fp-ts/Task'; -import * as E from 'fp-ts/Either'; -import * as A from 'fp-ts/Array'; -import { TeamMemberRole } from './team/team.model'; -import { User } from './user/user.model'; +import * as TE from 'fp-ts/TaskEither'; +import { AuthProvider } from './auth/helper'; import { ENV_EMPTY_AUTH_PROVIDERS, ENV_NOT_FOUND_KEY_AUTH_PROVIDERS, ENV_NOT_SUPPORT_AUTH_PROVIDERS, JSON_INVALID, } from './errors'; -import { AuthProvider } from './auth/helper'; +import { TeamMemberRole } from './team/team.model'; import { RESTError } from './types/RESTError'; /** @@ -297,3 +297,22 @@ export function calculateExpirationDate(expiresOn: null | number) { if (expiresOn === null) return null; return new Date(Date.now() + expiresOn * 24 * 60 * 60 * 1000); } + +/* + * Transforms the collection level properties (authorization & headers) under the `data` field. + * Preserves `null` values and prevents duplicate stringification. + * + * @param {Prisma.JsonValue} collectionData - The team collection data to transform. + * @returns {string | null} The transformed team collection data as a string. + */ +export function transformCollectionData( + collectionData: Prisma.JsonValue, +): string | null { + if (!collectionData) { + return null; + } + + return typeof collectionData === 'string' + ? collectionData + : JSON.stringify(collectionData); +} diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index fb4e05e7e..3399bca45 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -194,7 +194,8 @@ "save_to_collection": "Save to Collection", "select": "Select a Collection", "select_location": "Select location", - "details": "Details" + "details": "Details", + "duplicated": "Collection duplicated" }, "confirm": { "close_unsaved_tab": "Are you sure you want to close this tab?", diff --git a/packages/hoppscotch-common/src/components/collections/Collection.vue b/packages/hoppscotch-common/src/components/collections/Collection.vue index bb3cfca4f..60860a1b0 100644 --- a/packages/hoppscotch-common/src/components/collections/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/Collection.vue @@ -102,6 +102,11 @@ @keyup.r="requestAction?.$el.click()" @keyup.n="folderAction?.$el.click()" @keyup.e="edit?.$el.click()" + @keyup.d=" + showDuplicateCollectionAction + ? duplicateAction?.$el.click() + : null + " @keyup.delete="deleteAction?.$el.click()" @keyup.x="exportAction?.$el.click()" @keyup.p="propertiesAction?.$el.click()" @@ -144,6 +149,20 @@ } " /> + (), { id: "", @@ -274,6 +296,7 @@ const props = withDefaults( exportLoading: false, hasNoTeamAccess: false, isLastItem: false, + duplicateLoading: false, } ) @@ -283,6 +306,7 @@ const emit = defineEmits<{ (event: "add-folder"): void (event: "edit-collection"): void (event: "edit-properties"): void + (event: "duplicate-collection"): void (event: "export-data"): void (event: "remove-collection"): void (event: "drop-event", payload: DataTransfer): void @@ -297,6 +321,7 @@ const tippyActions = ref(null) const requestAction = ref(null) const folderAction = ref(null) const edit = ref(null) +const duplicateAction = ref(null) const deleteAction = ref(null) const exportAction = ref(null) const options = ref(null) @@ -314,6 +339,11 @@ const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, { parentID: "", }) +const currentUser = useReadonlyStream( + platform.auth.getCurrentUserStream(), + platform.auth.getCurrentUser() +) + // Used to determine if the collection is being dragged to a different destination // This is used to make the highlight effect work watch( @@ -340,10 +370,25 @@ const collectionName = computed(() => { return (props.data as TeamCollection).title }) +const showDuplicateCollectionAction = computed(() => { + // Show if the user is not logged in + if (!currentUser.value) { + return true + } + + if (props.collectionsType === "team-collections") { + return true + } + + // Duplicate collection action is disabled on SH until the issue with syncing is resolved + return !platform.platformFeatureFlags + .duplicateCollectionDisabledInPersonalWorkspace +}) + watch( - () => props.exportLoading, - (val) => { - if (!val) { + () => [props.exportLoading, props.duplicateCollectionLoading], + ([newExportLoadingVal, newDuplicateCollectionLoadingVal]) => { + if (!newExportLoadingVal && !newDuplicateCollectionLoadingVal) { options.value!.tippy?.hide() } } diff --git a/packages/hoppscotch-common/src/components/collections/ImportExport.vue b/packages/hoppscotch-common/src/components/collections/ImportExport.vue index 714311dd8..bdf18d26e 100644 --- a/packages/hoppscotch-common/src/components/collections/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/collections/ImportExport.vue @@ -418,7 +418,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = { metadata: { id: "hopp_team_collections", name: "export.as_json", - title: "export.as_json_description", + title: "export.as_json", icon: IconUser, disabled: false, applicableTo: ["team-workspace"], @@ -435,18 +435,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = { ) if (E.isRight(res)) { - const { exportCollectionsToJSON } = res.right - - if (!JSON.parse(exportCollectionsToJSON).length) { - isHoppTeamCollectionExporterInProgress.value = false - - return toast.error(t("error.no_collections_to_export")) - } - - initializeDownloadCollection( - exportCollectionsToJSON, - "team-collections" - ) + initializeDownloadCollection(res.right, "team-collections") platform.analytics?.logEvent({ type: "HOPP_EXPORT_COLLECTION", @@ -454,7 +443,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = { platform: "rest", }) } else { - toast.error(res.left.error.toString()) + toast.error(res.left) } } @@ -492,11 +481,6 @@ const HoppGistCollectionsExporter: ImporterOrExporter = { } if (E.isRight(collectionJSON)) { - if (!JSON.parse(collectionJSON.right).length) { - isHoppGistCollectionExporterInProgress.value = false - return toast.error(t("error.no_collections_to_export")) - } - const res = await gistExporter(collectionJSON.right, accessToken) if (E.isLeft(res)) { @@ -513,6 +497,8 @@ const HoppGistCollectionsExporter: ImporterOrExporter = { }) platform.io.openExternalLink(res.right) + } else { + toast.error(collectionJSON.left) } isHoppGistCollectionExporterInProgress.value = false @@ -589,9 +575,7 @@ const getCollectionJSON = async () => { props.collectionsType.selectedTeam?.teamID ) - return E.isRight(res) - ? E.right(res.right.exportCollectionsToJSON) - : E.left(res.left) + return E.isRight(res) ? E.right(res.right) : E.left(res.left) } if (props.collectionsType.type === "my-collections") { diff --git a/packages/hoppscotch-common/src/components/collections/MyCollections.vue b/packages/hoppscotch-common/src/components/collections/MyCollections.vue index 810549cd3..a379d47d3 100644 --- a/packages/hoppscotch-common/src/components/collections/MyCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/MyCollections.vue @@ -71,6 +71,13 @@ collection: node.data.data.data, }) " + @duplicate-collection=" + node.data.type === 'collections' && + emit('duplicate-collection', { + pathOrID: node.id, + collectionSyncID: node.data.data.data.id, + }) + " @edit-properties=" node.data.type === 'collections' && emit('edit-properties', { @@ -146,6 +153,13 @@ folder: node.data.data.data, }) " + @duplicate-collection=" + node.data.type === 'folders' && + emit('duplicate-collection', { + pathOrID: node.id, + collectionSyncID: node.data.data.data.id, + }) + " @edit-properties=" node.data.type === 'folders' && emit('edit-properties', { @@ -447,6 +461,13 @@ const emit = defineEmits<{ folder: HoppCollection } ): void + ( + event: "duplicate-collection", + payload: { + pathOrID: string + collectionSyncID?: string + } + ): void ( event: "edit-properties", payload: { diff --git a/packages/hoppscotch-common/src/components/collections/Request.vue b/packages/hoppscotch-common/src/components/collections/Request.vue index 01e1ba0f4..7e6c6c8bd 100644 --- a/packages/hoppscotch-common/src/components/collections/Request.vue +++ b/packages/hoppscotch-common/src/components/collections/Request.vue @@ -20,7 +20,7 @@ @dragover="handleDragOver($event)" @dragleave="resetDragState" @dragend="resetDragState" - @contextmenu.prevent="options?.tippy.show()" + @contextmenu.prevent="options?.tippy?.show()" >
@@ -211,7 +210,7 @@ const props = defineProps({ default: "my-collections", required: true, }, - duplicateLoading: { + duplicateRequestLoading: { type: Boolean, default: false, required: false, @@ -259,7 +258,7 @@ const emit = defineEmits<{ (event: "update-last-request-order", payload: DataTransfer): void }>() -const tippyActions = ref(null) +const tippyActions = ref(null) const edit = ref(null) const deleteAction = ref(null) const options = ref(null) @@ -277,10 +276,10 @@ const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, { }) watch( - () => props.duplicateLoading, + () => props.duplicateRequestLoading, (val) => { if (!val) { - options.value!.tippy.hide() + options.value!.tippy?.hide() } } ) diff --git a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue index c9abd2de4..861e94efc 100644 --- a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue @@ -61,6 +61,7 @@ :export-loading="exportLoading" :has-no-team-access="hasNoTeamAccess || isShowingSearchResults" :collection-move-loading="collectionMoveLoading" + :duplicate-collection-loading="duplicateCollectionLoading" :is-last-item="node.data.isLastItem" :is-selected=" isSelected({ @@ -89,6 +90,12 @@ collection: node.data.data.data, }) " + @duplicate-collection=" + node.data.type === 'collections' && + emit('duplicate-collection', { + pathOrID: node.data.data.data.id, + }) + " @edit-properties=" node.data.type === 'collections' && emit('edit-properties', { @@ -149,6 +156,7 @@ :export-loading="exportLoading" :has-no-team-access="hasNoTeamAccess || isShowingSearchResults" :collection-move-loading="collectionMoveLoading" + :duplicate-collection-loading="duplicateCollectionLoading" :is-last-item="node.data.isLastItem" :is-selected=" isSelected({ @@ -176,6 +184,12 @@ folder: node.data.data.data, }) " + @duplicate-collection=" + node.data.type === 'folders' && + emit('duplicate-collection', { + pathOrID: node.data.data.data.id, + }) + " @edit-properties=" node.data.type === 'folders' && emit('edit-properties', { @@ -236,7 +250,7 @@ :request-i-d="node.data.data.data.id" :parent-i-d="node.data.data.parentIndex" :collections-type="collectionsType.type" - :duplicate-loading="duplicateLoading" + :duplicate-request-loading="duplicateRequestLoading" :is-active="isActiveRequest(node.data.data.data.id)" :has-no-team-access="hasNoTeamAccess || isShowingSearchResults" :request-move-loading="requestMoveLoading" @@ -445,7 +459,12 @@ const props = defineProps({ default: false, required: false, }, - duplicateLoading: { + duplicateRequestLoading: { + type: Boolean, + default: false, + required: false, + }, + duplicateCollectionLoading: { type: Boolean, default: false, required: false, @@ -497,6 +516,13 @@ const emit = defineEmits<{ folder: TeamCollection } ): void + ( + event: "duplicate-collection", + payload: { + pathOrID: string + collectionSyncID?: string + } + ): void ( event: "edit-properties", payload: { diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue index 02f62c507..5fa4081ab 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue @@ -73,7 +73,13 @@ @keyup.r="requestAction.$el.click()" @keyup.n="folderAction.$el.click()" @keyup.e="edit.$el.click()" + @keyup.d=" + showDuplicateCollectionAction + ? duplicateAction.$el.click() + : null + " @keyup.delete="deleteAction.$el.click()" + @keyup.p="propertiesAction.$el.click()" @keyup.escape="hide()" > + -import { computed, ref } from "vue" -import IconCheckCircle from "~icons/lucide/check-circle" -import IconFolder from "~icons/lucide/folder" -import IconFolderOpen from "~icons/lucide/folder-open" -import IconFilePlus from "~icons/lucide/file-plus" -import IconFolderPlus from "~icons/lucide/folder-plus" -import IconMoreVertical from "~icons/lucide/more-vertical" -import IconEdit from "~icons/lucide/edit" -import IconTrash2 from "~icons/lucide/trash-2" -import IconSettings2 from "~icons/lucide/settings-2" -import { useToast } from "@composables/toast" import { useI18n } from "@composables/i18n" import { useColorMode } from "@composables/theming" -import { removeGraphqlCollection } from "~/newstore/collections" -import { Picked } from "~/helpers/types/HoppPicked" -import { useService } from "dioc/vue" -import { GQLTabService } from "~/services/tab/graphql" +import { useToast } from "@composables/toast" import { HoppCollection } from "@hoppscotch/data" +import { useService } from "dioc/vue" +import { computed, ref } from "vue" +import { useReadonlyStream } from "~/composables/stream" +import { Picked } from "~/helpers/types/HoppPicked" +import { removeGraphqlCollection } from "~/newstore/collections" +import { platform } from "~/platform" +import { GQLTabService } from "~/services/tab/graphql" +import IconCheckCircle from "~icons/lucide/check-circle" +import IconCopy from "~icons/lucide/copy" +import IconEdit from "~icons/lucide/edit" +import IconFilePlus from "~icons/lucide/file-plus" +import IconFolder from "~icons/lucide/folder" +import IconFolderOpen from "~icons/lucide/folder-open" +import IconFolderPlus from "~icons/lucide/folder-plus" +import IconMoreVertical from "~icons/lucide/more-vertical" +import IconSettings2 from "~icons/lucide/settings-2" +import IconTrash2 from "~icons/lucide/trash-2" const props = defineProps<{ picked: Picked | null @@ -271,6 +297,13 @@ const emit = defineEmits<{ (e: "add-request", i: any): void (e: "add-folder", i: any): void (e: "edit-folder", i: any): void + ( + e: "duplicate-collection", + payload: { + path: string + collectionSyncID?: string + } + ): void ( e: "edit-properties", payload: { @@ -296,13 +329,20 @@ const options = ref(null) const requestAction = ref(null) const folderAction = ref(null) const edit = ref(null) +const duplicateAction = ref(null) const deleteAction = ref(null) +const propertiesAction = ref(null) const showChildren = ref(false) const dragging = ref(false) const confirmRemove = ref(false) +const currentUser = useReadonlyStream( + platform.auth.getCurrentUserStream(), + platform.auth.getCurrentUser() +) + const isSelected = computed( () => props.picked?.pickedType === "gql-my-collection" && @@ -315,6 +355,17 @@ const collectionIcon = computed(() => { return IconFolder }) +const showDuplicateCollectionAction = computed(() => { + // Show if the user is not logged in + if (!currentUser.value) { + return true + } + + // Duplicate collection action is disabled on SH until the issue with syncing is resolved + return !platform.platformFeatureFlags + .duplicateCollectionDisabledInPersonalWorkspace +}) + const pick = () => { emit("select", { pickedType: "gql-my-collection", diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue b/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue index 5852daddc..a30d8d0d0 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue @@ -70,7 +70,13 @@ @keyup.r="requestAction.$el.click()" @keyup.n="folderAction.$el.click()" @keyup.e="edit.$el.click()" + @keyup.d=" + showDuplicateCollectionAction + ? duplicateAction.$el.click() + : null + " @keyup.delete="deleteAction.$el.click()" + @keyup.p="propertiesAction.$el.click()" @keyup.escape="hide()" > + -import IconEdit from "~icons/lucide/edit" -import IconTrash2 from "~icons/lucide/trash-2" -import IconFolderPlus from "~icons/lucide/folder-plus" -import IconFilePlus from "~icons/lucide/file-plus" -import IconMoreVertical from "~icons/lucide/more-vertical" -import IconCheckCircle from "~icons/lucide/check-circle" -import IconFolder from "~icons/lucide/folder" -import IconFolderOpen from "~icons/lucide/folder-open" -import IconSettings2 from "~icons/lucide/settings-2" -import { useToast } from "@composables/toast" import { useI18n } from "@composables/i18n" import { useColorMode } from "@composables/theming" -import { removeGraphqlFolder } from "~/newstore/collections" -import { computed, ref } from "vue" -import { useService } from "dioc/vue" -import { GQLTabService } from "~/services/tab/graphql" -import { Picked } from "~/helpers/types/HoppPicked" +import { useToast } from "@composables/toast" import { HoppCollection } from "@hoppscotch/data" +import { useService } from "dioc/vue" +import { computed, ref } from "vue" +import { useReadonlyStream } from "~/composables/stream" +import { Picked } from "~/helpers/types/HoppPicked" +import { removeGraphqlFolder } from "~/newstore/collections" +import { platform } from "~/platform" +import { GQLTabService } from "~/services/tab/graphql" +import IconCheckCircle from "~icons/lucide/check-circle" +import IconCopy from "~icons/lucide/copy" +import IconEdit from "~icons/lucide/edit" +import IconFilePlus from "~icons/lucide/file-plus" +import IconFolder from "~icons/lucide/folder" +import IconFolderOpen from "~icons/lucide/folder-open" +import IconFolderPlus from "~icons/lucide/folder-plus" +import IconMoreVertical from "~icons/lucide/more-vertical" +import IconSettings2 from "~icons/lucide/settings-2" +import IconTrash2 from "~icons/lucide/trash-2" const toast = useToast() const t = useI18n() @@ -255,6 +281,7 @@ const emit = defineEmits([ "edit-request", "add-folder", "edit-folder", + "duplicate-collection", "duplicate-request", "edit-properties", "select-request", @@ -267,12 +294,19 @@ const options = ref(null) const requestAction = ref(null) const folderAction = ref(null) const edit = ref(null) +const duplicateAction = ref(null) const deleteAction = ref(null) +const propertiesAction = ref(null) const showChildren = ref(false) const dragging = ref(false) const confirmRemove = ref(false) +const currentUser = useReadonlyStream( + platform.auth.getCurrentUserStream(), + platform.auth.getCurrentUser() +) + const isSelected = computed( () => props.picked?.pickedType === "gql-my-folder" && @@ -285,6 +319,17 @@ const collectionIcon = computed(() => { return IconFolder }) +const showDuplicateCollectionAction = computed(() => { + // Show if the user is not logged in + if (!currentUser.value) { + return true + } + + // Duplicate collection action is disabled on SH until the issue with syncing is resolved + return !platform.platformFeatureFlags + .duplicateCollectionDisabledInPersonalWorkspace +}) + const pick = () => { emit("select", { pickedType: "gql-my-folder", diff --git a/packages/hoppscotch-common/src/components/collections/graphql/index.vue b/packages/hoppscotch-common/src/components/collections/graphql/index.vue index e2f9c6b5b..b57a3fec4 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/index.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/index.vue @@ -54,6 +54,7 @@ @add-request="addRequest($event)" @add-folder="addFolder($event)" @edit-folder="editFolder($event)" + @duplicate-collection="duplicateCollection($event)" @edit-request="editRequest($event)" @duplicate-request="duplicateRequest($event)" @select-collection="$emit('use-collection', collection)" @@ -167,6 +168,7 @@ import { editGraphqlCollection, editGraphqlFolder, moveGraphqlRequest, + duplicateGraphQLCollection, } from "~/newstore/collections" import IconPlus from "~icons/lucide/plus" import IconHelpCircle from "~icons/lucide/help-circle" @@ -380,6 +382,14 @@ const editCollection = ( displayModalEdit(true) } +const duplicateCollection = ({ + path, + collectionSyncID, +}: { + path: string + collectionSyncID?: string +}) => duplicateGraphQLCollection(path, collectionSyncID) + const onAddRequest = ({ name, path, diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index 1a3db57d8..054acb1fc 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -37,6 +37,7 @@ @add-request="addRequest" @edit-collection="editCollection" @edit-folder="editFolder" + @duplicate-collection="duplicateCollection" @edit-properties="editProperties" @export-data="exportData" @remove-collection="removeCollection" @@ -67,7 +68,8 @@ " :filter-text="filterTexts" :export-loading="exportLoading" - :duplicate-loading="duplicateLoading" + :duplicate-request-loading="duplicateRequestLoading" + :duplicate-collection-loading="duplicateCollectionLoading" :save-request="saveRequest" :picked="picked" :collection-move-loading="collectionMoveLoading" @@ -76,6 +78,7 @@ @add-folder="addFolder" @edit-collection="editCollection" @edit-folder="editFolder" + @duplicate-collection="duplicateCollection" @edit-properties="editProperties" @export-data="exportData" @remove-collection="removeCollection" @@ -208,6 +211,7 @@ import { createChildCollection, createNewRootCollection, deleteCollection, + duplicateTeamCollection, moveRESTTeamCollection, updateOrderRESTTeamCollection, updateTeamCollection, @@ -240,6 +244,7 @@ import { addRESTCollection, addRESTFolder, cascadeParentCollectionForHeaderAuth, + duplicateRESTCollection, editRESTCollection, editRESTFolder, editRESTRequest, @@ -645,7 +650,8 @@ const isSelected = ({ const modalLoadingState = ref(false) const exportLoading = ref(false) -const duplicateLoading = ref(false) +const duplicateRequestLoading = ref(false) +const duplicateCollectionLoading = ref(false) const showModalAdd = ref(false) const showModalAddRequest = ref(false) @@ -1044,6 +1050,34 @@ const updateEditingFolder = (newName: string) => { } } +const duplicateCollection = async ({ + pathOrID, + collectionSyncID, +}: { + pathOrID: string + collectionSyncID?: string +}) => { + if (collectionsType.value.type === "my-collections") { + duplicateRESTCollection(pathOrID, collectionSyncID) + } else if (hasTeamWriteAccess.value) { + duplicateCollectionLoading.value = true + + await pipe( + duplicateTeamCollection(pathOrID), + TE.match( + (err: GQLError) => { + toast.error(`${getErrorMessage(err)}`) + duplicateCollectionLoading.value = false + }, + () => { + toast.success(t("collection.duplicated")) + duplicateCollectionLoading.value = false + } + ) + )() + } +} + const editRequest = (payload: { folderPath: string | undefined requestIndex: string @@ -1149,7 +1183,7 @@ const duplicateRequest = (payload: { saveRESTRequestAs(folderPath, newRequest) toast.success(t("request.duplicated")) } else if (hasTeamWriteAccess.value) { - duplicateLoading.value = true + duplicateRequestLoading.value = true if (!collectionsType.value.selectedTeam) return @@ -1164,10 +1198,10 @@ const duplicateRequest = (payload: { TE.match( (err: GQLError) => { toast.error(`${getErrorMessage(err)}`) - duplicateLoading.value = false + duplicateRequestLoading.value = false }, () => { - duplicateLoading.value = false + duplicateRequestLoading.value = false toast.success(t("request.duplicated")) displayModalAddRequest(false) } diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DuplicateTeamCollection.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DuplicateTeamCollection.graphql new file mode 100644 index 000000000..ef4a60a08 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DuplicateTeamCollection.graphql @@ -0,0 +1,3 @@ +mutation DuplicateTeamCollection($collectionID: String!) { + duplicateTeamCollection(collectionID: $collectionID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/helpers.ts b/packages/hoppscotch-common/src/helpers/backend/helpers.ts index 587fb4dd0..0e89ff5e9 100644 --- a/packages/hoppscotch-common/src/helpers/backend/helpers.ts +++ b/packages/hoppscotch-common/src/helpers/backend/helpers.ts @@ -1,12 +1,18 @@ -import * as A from "fp-ts/Array" -import * as E from "fp-ts/Either" -import * as TE from "fp-ts/TaskEither" -import { pipe, flow } from "fp-ts/function" import { HoppCollection, + HoppRESTAuth, + HoppRESTHeaders, + HoppRESTRequest, makeCollection, translateToNewRequest, } from "@hoppscotch/data" +import * as A from "fp-ts/Array" +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/TaskEither" +import { flow, pipe } from "fp-ts/function" +import { z } from "zod" + +import { getI18n } from "~/modules/i18n" import { TeamCollection } from "../teams/TeamCollection" import { TeamRequest } from "../teams/TeamRequest" import { GQLError, runGQLQuery } from "./GQLClient" @@ -17,6 +23,15 @@ import { GetCollectionTitleAndDataDocument, } from "./graphql" +type TeamCollectionJSON = { + name: string + folders: TeamCollectionJSON[] + requests: HoppRESTRequest[] + data: string +} + +type CollectionDataProps = { auth: HoppRESTAuth; headers: HoppRESTHeaders } + export const BACKEND_PAGE_SIZE = 10 const getCollectionChildrenIDs = async (collID: string) => { @@ -78,6 +93,68 @@ const getCollectionRequests = async (collID: string) => { return E.right(reqList) } +// Pick the value from the parsed result if it is successful, otherwise, return the default value +const parseWithDefaultValue = ( + parseResult: z.SafeParseReturnType, + defaultValue: T +): T => (parseResult.success ? parseResult.data : defaultValue) + +// Parse the incoming value for the `data` (authorization/headers) field and obtain the value in the expected format +const parseCollectionData = ( + data: string | Record | null +): CollectionDataProps => { + const defaultDataProps: CollectionDataProps = { + auth: { authType: "inherit", authActive: true }, + headers: [], + } + + if (!data) { + return defaultDataProps + } + + let parsedData: CollectionDataProps | Record | null + + if (typeof data === "string") { + try { + parsedData = JSON.parse(data) + } catch { + return defaultDataProps + } + } else { + parsedData = data + } + + const auth = parseWithDefaultValue( + HoppRESTAuth.safeParse(parsedData?.auth), + defaultDataProps.auth + ) + + const headers = parseWithDefaultValue( + HoppRESTHeaders.safeParse(parsedData?.headers), + defaultDataProps.headers + ) + + return { + auth, + headers, + } +} + +// Transforms the collection JSON string obtained with workspace level export to `HoppRESTCollection` +const teamCollectionJSONToHoppRESTColl = ( + coll: TeamCollectionJSON +): HoppCollection => { + const { auth, headers } = parseCollectionData(coll.data) + + return makeCollection({ + name: coll.name, + folders: coll.folders.map(teamCollectionJSONToHoppRESTColl), + requests: coll.requests, + auth, + headers, + }) +} + export const getCompleteCollectionTree = ( collID: string ): TE.TaskEither, TeamCollection> => @@ -146,10 +223,26 @@ export const teamCollToHoppRESTColl = ( * @param teamID - ID of the team * @returns Either of the JSON string of the collection or the error */ -export const getTeamCollectionJSON = async (teamID: string) => - await runGQLQuery({ +export const getTeamCollectionJSON = async (teamID: string) => { + const data = await runGQLQuery({ query: ExportAsJsonDocument, variables: { teamID, }, }) + + if (E.isLeft(data)) { + return E.left(data.left.error.toString()) + } + + const collections = JSON.parse(data.right.exportCollectionsToJSON) + + if (!collections.length) { + const t = getI18n() + + return E.left(t("error.no_collections_to_export")) + } + + const hoppCollections = collections.map(teamCollectionJSONToHoppRESTColl) + return E.right(JSON.stringify(hoppCollections)) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts index cd8307989..47dd6d1c2 100644 --- a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts @@ -9,6 +9,9 @@ import { DeleteCollectionDocument, DeleteCollectionMutation, DeleteCollectionMutationVariables, + DuplicateTeamCollectionDocument, + DuplicateTeamCollectionMutation, + DuplicateTeamCollectionMutationVariables, ImportFromJsonDocument, ImportFromJsonMutation, ImportFromJsonMutationVariables, @@ -140,3 +143,12 @@ export const updateTeamCollection = ( data, newTitle, }) + +export const duplicateTeamCollection = (collectionID: string) => + runMutation< + DuplicateTeamCollectionMutation, + DuplicateTeamCollectionMutationVariables, + "" + >(DuplicateTeamCollectionDocument, { + collectionID, + }) diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts index 5cda13eaa..5b41e9d3a 100644 --- a/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts +++ b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts @@ -324,10 +324,10 @@ export default class NewTeamCollectionAdapter { if (!parentCollection) return + // Prevent adding child collections to a collection that has not been expanded yet incoming from GQL subscription, during import, etc + // Hence, add entries to the pre-existing list without setting 'children' if it is `null' if (parentCollection.children !== null) { parentCollection.children.push(collection) - } else { - parentCollection.children = [collection] } } diff --git a/packages/hoppscotch-common/src/newstore/collections.ts b/packages/hoppscotch-common/src/newstore/collections.ts index 3c021809a..b55e64a9c 100644 --- a/packages/hoppscotch-common/src/newstore/collections.ts +++ b/packages/hoppscotch-common/src/newstore/collections.ts @@ -1,20 +1,21 @@ -import { pluck } from "rxjs/operators" import { - HoppGQLRequest, - HoppRESTRequest, HoppCollection, - makeCollection, HoppGQLAuth, + HoppGQLRequest, + HoppRESTAuth, + HoppRESTHeaders, + HoppRESTRequest, + makeCollection, } from "@hoppscotch/data" -import DispatchingStore, { defineDispatchers } from "./DispatchingStore" import { cloneDeep } from "lodash-es" +import { pluck } from "rxjs/operators" import { resolveSaveContextOnRequestReorder } from "~/helpers/collection/request" -import { getService } from "~/modules/dioc" -import { RESTTabService } from "~/services/tab/rest" -import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" -import { HoppRESTAuth } from "@hoppscotch/data" -import { HoppRESTHeaders } from "@hoppscotch/data" import { HoppGQLHeader } from "~/helpers/graphql" +import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" +import { getService } from "~/modules/dioc" +import { getI18n } from "~/modules/i18n" +import { RESTTabService } from "~/services/tab/rest" +import DispatchingStore, { defineDispatchers } from "./DispatchingStore" const defaultRESTCollectionState = { state: [ @@ -494,6 +495,50 @@ const restCollectionDispatchers = defineDispatchers({ } }, + duplicateCollection( + { state }: RESTCollectionStoreType, + // `collectionSyncID` is used to sync the duplicated collection in `collections.sync.ts` + // eslint-disable-next-line @typescript-eslint/no-unused-vars + { path, collectionSyncID }: { path: string; collectionSyncID?: string } + ) { + const t = getI18n() + + const newState = state + + const indexPaths = path.split("/").map((x) => parseInt(x)) + + const isRootCollection = indexPaths.length === 1 + + const collection = navigateToFolderWithIndexPath(state, [...indexPaths]) + + if (collection) { + const name = `${collection.name} - ${t("action.duplicate")}` + + const duplicatedCollection = { + ...cloneDeep(collection), + name, + ...(collection.id + ? { id: `${collection.id}-duplicate-collection` } + : {}), + } + + if (isRootCollection) { + newState.push(duplicatedCollection) + } else { + const parentCollectionIndexPath = indexPaths.slice(0, -1) + + const parentCollection = navigateToFolderWithIndexPath(state, [ + ...parentCollectionIndexPath, + ]) + parentCollection?.folders.push(duplicatedCollection) + } + } + + return { + state: newState, + } + }, + editRequest( { state }: RESTCollectionStoreType, { @@ -896,6 +941,50 @@ const gqlCollectionDispatchers = defineDispatchers({ } }, + duplicateCollection( + { state }: GraphqlCollectionStoreType, + // `collectionSyncID` is used to sync the duplicated collection in `gqlCollections.sync.ts` + // eslint-disable-next-line @typescript-eslint/no-unused-vars + { path, collectionSyncID }: { path: string; collectionSyncID?: string } + ) { + const t = getI18n() + + const newState = state + + const indexPaths = path.split("/").map((x) => parseInt(x)) + + const isRootCollection = indexPaths.length === 1 + + const collection = navigateToFolderWithIndexPath(state, [...indexPaths]) + + if (collection) { + const name = `${collection.name} - ${t("action.duplicate")}` + + const duplicatedCollection = { + ...cloneDeep(collection), + name, + ...(collection.id + ? { id: `${collection.id}-duplicate-collection` } + : {}), + } + + if (isRootCollection) { + newState.push(duplicatedCollection) + } else { + const parentCollectionIndexPath = indexPaths.slice(0, -1) + + const parentCollection = navigateToFolderWithIndexPath(state, [ + ...parentCollectionIndexPath, + ]) + parentCollection?.folders.push(duplicatedCollection) + } + } + + return { + state: newState, + } + }, + editRequest( { state }: GraphqlCollectionStoreType, { @@ -1162,6 +1251,19 @@ export function moveRESTFolder(path: string, destinationPath: string | null) { }) } +export function duplicateRESTCollection( + path: string, + collectionSyncID?: string +) { + restCollectionStore.dispatch({ + dispatcher: "duplicateCollection", + payload: { + path, + collectionSyncID, + }, + }) +} + export function removeDuplicateRESTCollectionOrFolder( id: string, collectionPath: string, @@ -1362,6 +1464,19 @@ export function removeGraphqlFolder(path: string, folderID?: string) { }) } +export function duplicateGraphQLCollection( + path: string, + collectionSyncID?: string +) { + graphqlCollectionStore.dispatch({ + dispatcher: "duplicateCollection", + payload: { + path, + collectionSyncID, + }, + }) +} + export function removeDuplicateGraphqlCollectionOrFolder( id: string, collectionPath: string, diff --git a/packages/hoppscotch-common/src/platform/index.ts b/packages/hoppscotch-common/src/platform/index.ts index 129ab3f61..0e379680a 100644 --- a/packages/hoppscotch-common/src/platform/index.ts +++ b/packages/hoppscotch-common/src/platform/index.ts @@ -52,6 +52,12 @@ export type PlatformDef = { * Whether to show the A/B testing workspace switcher click login flow or not */ workspaceSwitcherLogin?: Ref + + /** + * There's an active issue wrt syncing in personal workspace under SH while duplicating a collection + * This is a temporary flag to disable the same + */ + duplicateCollectionDisabledInPersonalWorkspace?: boolean } infra?: InfraPlatformDef } diff --git a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts index 36dc51620..6991bd39d 100644 --- a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts @@ -276,7 +276,8 @@ const HoppGQLSaveContextSchema = z.nullable( .object({ originLocation: z.literal("user-collection"), folderPath: z.string(), - requestIndex: z.number(), + // TODO: Investigate why this field is not populated at times + requestIndex: z.optional(z.number()), }) .strict(), z diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/DuplicateUserCollection.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/DuplicateUserCollection.graphql new file mode 100644 index 000000000..78d68bcdb --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/DuplicateUserCollection.graphql @@ -0,0 +1,3 @@ +mutation DuplicateUserCollection($collectionID: String!, $reqType: ReqType!) { + duplicateUserCollection(collectionID: $collectionID, reqType: $reqType) +} diff --git a/packages/hoppscotch-selfhost-web/src/main.ts b/packages/hoppscotch-selfhost-web/src/main.ts index 5119e8a34..5aaf62e21 100644 --- a/packages/hoppscotch-selfhost-web/src/main.ts +++ b/packages/hoppscotch-selfhost-web/src/main.ts @@ -40,6 +40,7 @@ createHoppApp("#app", { platformFeatureFlags: { exportAsGIST: false, hasTelemetry: false, + duplicateCollectionDisabledInPersonalWorkspace: true, }, infra: InfraPlatform, }) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.api.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.api.ts index 10a7cb249..70be93653 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.api.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.api.ts @@ -68,6 +68,9 @@ import { UpdateUserCollectionMutation, UpdateUserCollectionMutationVariables, UpdateUserCollectionDocument, + DuplicateUserCollectionDocument, + DuplicateUserCollectionMutation, + DuplicateUserCollectionMutationVariables, } from "../../api/generated/graphql" export const createRESTRootUserCollection = (title: string, data?: string) => @@ -193,6 +196,19 @@ export const moveUserCollection = ( destCollectionID: destinationCollectionID, })() +export const duplicateUserCollection = ( + collectionID: string, + reqType: ReqType +) => + runMutation< + DuplicateUserCollectionMutation, + DuplicateUserCollectionMutationVariables, + "" + >(DuplicateUserCollectionDocument, { + collectionID, + reqType, + })() + export const editUserRequest = ( requestID: string, title: string, diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts index 88b057062..ccaeaf904 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts @@ -284,6 +284,20 @@ function setupUserCollectionCreatedSubscription() { return } + // While duplicating a collection, the new entry added to the store has an ID with a suffix to be updated after the backend ID is received from the GQL subscription + // This is to prevent the new entry from being added to the store again when the GQL subscription + // The boolean return value indicates if the GQL subscription was fired because of a duplicate collection action and whether the collection should be added to the store + const shouldCreateCollection = issueBackendIDToDuplicatedCollection( + collectionStore, + collectionType, + userCollectionBackendID, + parentCollectionID + ) + + if (!shouldCreateCollection) { + return + } + const parentCollectionPath = parentCollectionID && getCollectionPathFromCollectionID( @@ -828,3 +842,105 @@ function getRequestIndex( return requestIndex } + +function issueBackendIDToDuplicatedCollection( + collectionStore: ReturnType< + typeof getStoreByCollectionType + >["collectionStore"], + collectionType: ReqType, + userCollectionBackendID: string, + parentCollectionID?: string +): boolean { + // Collection added to store via duplicating is set an ID with a suffix to be updated after the backend ID is received from the GQL subscription + const collectionCreatedFromStoreIDSuffix = "-duplicate-collection" + + // Duplicating a child collection + if (parentCollectionID) { + // Get the index path for the parent collection + const parentCollectionPath = getCollectionPathFromCollectionID( + parentCollectionID, + collectionStore.value.state + ) + + if (!parentCollectionPath) { + // Indicates the collection received from the GQL subscription should be created in the store + return true + } + + const parentCollection = navigateToFolderWithIndexPath( + collectionStore.value.state, + parentCollectionPath.split("/").map((index) => parseInt(index)) + ) + + if (!parentCollection) { + // Indicates the collection received from the GQL subscription should be created in the store + return true + } + + // Grab the child collection inserted via store update with the ID suffix + const collectionInsertedViaStoreUpdateIdx = + parentCollection.folders.findIndex(({ id }) => + id?.endsWith(collectionCreatedFromStoreIDSuffix) + ) + + // No entry indicates the GQL subscription was fired not because of a duplicate collection action + if (collectionInsertedViaStoreUpdateIdx === -1) { + // Indicates the collection received from the GQL subscription should be created in the store + return true + } + const collectionInsertedViaStoreUpdate = + parentCollection.folders[collectionInsertedViaStoreUpdateIdx] + + const childCollectionPath = `${parentCollectionPath}/${collectionInsertedViaStoreUpdateIdx}` + + // Update the ID for the child collection already existing in store with the backend ID + runDispatchWithOutSyncing(() => { + if (collectionType == ReqType.Rest) { + editRESTFolder(childCollectionPath, { + ...collectionInsertedViaStoreUpdate, + id: userCollectionBackendID, + }) + } else { + editGraphqlFolder(childCollectionPath, { + ...collectionInsertedViaStoreUpdate, + id: userCollectionBackendID, + }) + } + }) + } else { + // Duplicating a root collection + + // Grab the collection inserted via store update with the ID suffix + const collectionInsertedViaStoreUpdateIdx = + collectionStore.value.state.findIndex(({ id }) => + id?.endsWith(collectionCreatedFromStoreIDSuffix) + ) + + // No entry indicates the GQL subscription was fired not because of a duplicate collection action + if (collectionInsertedViaStoreUpdateIdx === -1) { + // Indicates the collection received from the GQL subscription should be created in the store + return true + } + + const collectionInsertedViaStoreUpdate = + collectionStore.value.state[collectionInsertedViaStoreUpdateIdx] + + // Update the ID for the collection already existing in store with the backend ID + runDispatchWithOutSyncing(() => { + if (collectionType == ReqType.Rest) { + editRESTCollection(collectionInsertedViaStoreUpdateIdx, { + ...collectionInsertedViaStoreUpdate, + id: userCollectionBackendID, + }) + } else { + editGraphqlCollection(collectionInsertedViaStoreUpdateIdx, { + ...collectionInsertedViaStoreUpdate, + id: userCollectionBackendID, + }) + } + }) + } + + // Prevent adding the collection received from GQL subscription to the store + return false +} diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.sync.ts index bbdec917b..d16819af6 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.sync.ts @@ -11,9 +11,7 @@ import { import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" -import { getSyncInitFunction } from "../../lib/sync" - -import { StoreSyncDefinitionOf } from "../../lib/sync" +import { getSyncInitFunction, StoreSyncDefinitionOf } from "../../lib/sync" import { createMapper } from "../../lib/sync/mapper" import { createRESTChildUserCollection, @@ -21,6 +19,7 @@ import { createRESTUserRequest, deleteUserCollection, deleteUserRequest, + duplicateUserCollection, editUserRequest, moveUserCollection, moveUserRequest, @@ -29,6 +28,7 @@ import { } from "./collections.api" import * as E from "fp-ts/Either" +import { ReqType } from "../../api/generated/graphql" // restCollectionsMapper uses the collectionPath as the local identifier export const restCollectionsMapper = createMapper() @@ -280,6 +280,11 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< } } }, + async duplicateCollection({ collectionSyncID }) { + if (collectionSyncID) { + await duplicateUserCollection(collectionSyncID, ReqType.Rest) + } + }, editRequest({ path, requestIndex, requestNew }) { const request = navigateToFolderWithIndexPath( restCollectionStore.value.state, diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/gqlCollections.sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/gqlCollections.sync.ts index 3c6e5afab..0835ca33a 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/gqlCollections.sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/gqlCollections.sync.ts @@ -20,11 +20,13 @@ import { createGQLUserRequest, deleteUserCollection, deleteUserRequest, + duplicateUserCollection, editGQLUserRequest, updateUserCollection, } from "./collections.api" import * as E from "fp-ts/Either" +import { ReqType } from "../../api/generated/graphql" import { moveOrReorderRequests } from "./collections.sync" // gqlCollectionsMapper uses the collectionPath as the local identifier @@ -261,6 +263,11 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< await deleteUserCollection(folderID) } }, + async duplicateCollection({ collectionSyncID }) { + if (collectionSyncID) { + await duplicateUserCollection(collectionSyncID, ReqType.Gql) + } + }, editRequest({ path, requestIndex, requestNew }) { const request = navigateToFolderWithIndexPath( graphqlCollectionStore.value.state,