feat: duplicate REST/GraphQL collections (#4211)

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
James George
2024-07-29 06:07:34 -07:00
committed by GitHub
parent c24d5c5302
commit c9f92282bf
26 changed files with 734 additions and 105 deletions

View File

@@ -21,7 +21,11 @@ import {
TEAM_MEMBER_NOT_FOUND, TEAM_MEMBER_NOT_FOUND,
} from '../errors'; } from '../errors';
import { PubSubService } from '../pubsub/pubsub.service'; 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 E from 'fp-ts/Either';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { import {
@@ -134,11 +138,13 @@ export class TeamCollectionService {
}, },
}); });
const data = transformCollectionData(collection.right.data);
const result: CollectionFolder = { const result: CollectionFolder = {
name: collection.right.title, name: collection.right.title,
folders: childrenCollectionObjects, folders: childrenCollectionObjects,
requests: requests.map((x) => x.request), requests: requests.map((x) => x.request),
data: JSON.stringify(collection.right.data), data,
}; };
return E.right(result); return E.right(result);
@@ -309,11 +315,13 @@ export class TeamCollectionService {
* @returns TeamCollection model * @returns TeamCollection model
*/ */
private cast(teamCollection: DBTeamCollection): TeamCollection { private cast(teamCollection: DBTeamCollection): TeamCollection {
const data = transformCollectionData(teamCollection.data);
return <TeamCollection>{ return <TeamCollection>{
id: teamCollection.id, id: teamCollection.id,
title: teamCollection.title, title: teamCollection.title,
parentID: teamCollection.parentID, parentID: teamCollection.parentID,
data: !teamCollection.data ? null : JSON.stringify(teamCollection.data), data,
}; };
} }

View File

@@ -25,7 +25,11 @@ import {
UserCollectionExportJSONData, UserCollectionExportJSONData,
} from './user-collections.model'; } from './user-collections.model';
import { ReqType } from 'src/types/RequestTypes'; 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'; import { CollectionFolder } from 'src/types/CollectionFolder';
@Injectable() @Injectable()
@@ -43,13 +47,15 @@ export class UserCollectionService {
* @returns UserCollection model * @returns UserCollection model
*/ */
private cast(collection: UserCollection) { private cast(collection: UserCollection) {
const data = transformCollectionData(collection.data);
return <UserCollectionModel>{ return <UserCollectionModel>{
id: collection.id, id: collection.id,
title: collection.title, title: collection.title,
type: collection.type, type: collection.type,
parentID: collection.parentID, parentID: collection.parentID,
userID: collection.userUid, 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 = { const result: CollectionFolder = {
id: collection.right.id, id: collection.right.id,
name: collection.right.title, name: collection.right.title,
@@ -882,7 +890,7 @@ export class UserCollectionService {
...(x.request as Record<string, unknown>), // type casting x.request of type Prisma.JSONValue to an object to enable spread ...(x.request as Record<string, unknown>), // 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); return E.right(result);

View File

@@ -1,21 +1,21 @@
import { ExecutionContext, HttpException } from '@nestjs/common'; import { ExecutionContext, HttpException } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql'; 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 { pipe } from 'fp-ts/lib/function';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task'; import * as T from 'fp-ts/Task';
import * as E from 'fp-ts/Either'; import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array'; import { AuthProvider } from './auth/helper';
import { TeamMemberRole } from './team/team.model';
import { User } from './user/user.model';
import { import {
ENV_EMPTY_AUTH_PROVIDERS, ENV_EMPTY_AUTH_PROVIDERS,
ENV_NOT_FOUND_KEY_AUTH_PROVIDERS, ENV_NOT_FOUND_KEY_AUTH_PROVIDERS,
ENV_NOT_SUPPORT_AUTH_PROVIDERS, ENV_NOT_SUPPORT_AUTH_PROVIDERS,
JSON_INVALID, JSON_INVALID,
} from './errors'; } from './errors';
import { AuthProvider } from './auth/helper'; import { TeamMemberRole } from './team/team.model';
import { RESTError } from './types/RESTError'; import { RESTError } from './types/RESTError';
/** /**
@@ -297,3 +297,22 @@ export function calculateExpirationDate(expiresOn: null | number) {
if (expiresOn === null) return null; if (expiresOn === null) return null;
return new Date(Date.now() + expiresOn * 24 * 60 * 60 * 1000); 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);
}

View File

@@ -194,7 +194,8 @@
"save_to_collection": "Save to Collection", "save_to_collection": "Save to Collection",
"select": "Select a Collection", "select": "Select a Collection",
"select_location": "Select location", "select_location": "Select location",
"details": "Details" "details": "Details",
"duplicated": "Collection duplicated"
}, },
"confirm": { "confirm": {
"close_unsaved_tab": "Are you sure you want to close this tab?", "close_unsaved_tab": "Are you sure you want to close this tab?",

View File

@@ -102,6 +102,11 @@
@keyup.r="requestAction?.$el.click()" @keyup.r="requestAction?.$el.click()"
@keyup.n="folderAction?.$el.click()" @keyup.n="folderAction?.$el.click()"
@keyup.e="edit?.$el.click()" @keyup.e="edit?.$el.click()"
@keyup.d="
showDuplicateCollectionAction
? duplicateAction?.$el.click()
: null
"
@keyup.delete="deleteAction?.$el.click()" @keyup.delete="deleteAction?.$el.click()"
@keyup.x="exportAction?.$el.click()" @keyup.x="exportAction?.$el.click()"
@keyup.p="propertiesAction?.$el.click()" @keyup.p="propertiesAction?.$el.click()"
@@ -144,6 +149,20 @@
} }
" "
/> />
<HoppSmartItem
v-if="showDuplicateCollectionAction"
ref="duplicateAction"
:icon="IconCopy"
:label="t('action.duplicate')"
:loading="duplicateCollectionLoading"
:shortcut="['D']"
@click="
() => {
emit('duplicate-collection'),
collectionsType === 'my-collections' ? hide() : null
}
"
/>
<HoppSmartItem <HoppSmartItem
ref="exportAction" ref="exportAction"
:icon="IconDownload" :icon="IconDownload"
@@ -229,7 +248,9 @@ import {
changeCurrentReorderStatus, changeCurrentReorderStatus,
currentReorderingStatus$, currentReorderingStatus$,
} from "~/newstore/reordering" } from "~/newstore/reordering"
import { platform } from "~/platform"
import IconCheckCircle from "~icons/lucide/check-circle" import IconCheckCircle from "~icons/lucide/check-circle"
import IconCopy from "~icons/lucide/copy"
import IconDownload from "~icons/lucide/download" import IconDownload from "~icons/lucide/download"
import IconEdit from "~icons/lucide/edit" import IconEdit from "~icons/lucide/edit"
import IconFilePlus from "~icons/lucide/file-plus" import IconFilePlus from "~icons/lucide/file-plus"
@@ -263,6 +284,7 @@ const props = withDefaults(
hasNoTeamAccess?: boolean hasNoTeamAccess?: boolean
collectionMoveLoading?: string[] collectionMoveLoading?: string[]
isLastItem?: boolean isLastItem?: boolean
duplicateCollectionLoading?: boolean
}>(), }>(),
{ {
id: "", id: "",
@@ -274,6 +296,7 @@ const props = withDefaults(
exportLoading: false, exportLoading: false,
hasNoTeamAccess: false, hasNoTeamAccess: false,
isLastItem: false, isLastItem: false,
duplicateLoading: false,
} }
) )
@@ -283,6 +306,7 @@ const emit = defineEmits<{
(event: "add-folder"): void (event: "add-folder"): void
(event: "edit-collection"): void (event: "edit-collection"): void
(event: "edit-properties"): void (event: "edit-properties"): void
(event: "duplicate-collection"): void
(event: "export-data"): void (event: "export-data"): void
(event: "remove-collection"): void (event: "remove-collection"): void
(event: "drop-event", payload: DataTransfer): void (event: "drop-event", payload: DataTransfer): void
@@ -297,6 +321,7 @@ const tippyActions = ref<HTMLDivElement | null>(null)
const requestAction = ref<HTMLButtonElement | null>(null) const requestAction = ref<HTMLButtonElement | null>(null)
const folderAction = ref<HTMLButtonElement | null>(null) const folderAction = ref<HTMLButtonElement | null>(null)
const edit = ref<HTMLButtonElement | null>(null) const edit = ref<HTMLButtonElement | null>(null)
const duplicateAction = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null) const deleteAction = ref<HTMLButtonElement | null>(null)
const exportAction = ref<HTMLButtonElement | null>(null) const exportAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null) const options = ref<TippyComponent | null>(null)
@@ -314,6 +339,11 @@ const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
parentID: "", parentID: "",
}) })
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
// Used to determine if the collection is being dragged to a different destination // Used to determine if the collection is being dragged to a different destination
// This is used to make the highlight effect work // This is used to make the highlight effect work
watch( watch(
@@ -340,10 +370,25 @@ const collectionName = computed(() => {
return (props.data as TeamCollection).title 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( watch(
() => props.exportLoading, () => [props.exportLoading, props.duplicateCollectionLoading],
(val) => { ([newExportLoadingVal, newDuplicateCollectionLoadingVal]) => {
if (!val) { if (!newExportLoadingVal && !newDuplicateCollectionLoadingVal) {
options.value!.tippy?.hide() options.value!.tippy?.hide()
} }
} }

View File

@@ -418,7 +418,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = {
metadata: { metadata: {
id: "hopp_team_collections", id: "hopp_team_collections",
name: "export.as_json", name: "export.as_json",
title: "export.as_json_description", title: "export.as_json",
icon: IconUser, icon: IconUser,
disabled: false, disabled: false,
applicableTo: ["team-workspace"], applicableTo: ["team-workspace"],
@@ -435,18 +435,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = {
) )
if (E.isRight(res)) { if (E.isRight(res)) {
const { exportCollectionsToJSON } = res.right initializeDownloadCollection(res.right, "team-collections")
if (!JSON.parse(exportCollectionsToJSON).length) {
isHoppTeamCollectionExporterInProgress.value = false
return toast.error(t("error.no_collections_to_export"))
}
initializeDownloadCollection(
exportCollectionsToJSON,
"team-collections"
)
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION", type: "HOPP_EXPORT_COLLECTION",
@@ -454,7 +443,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = {
platform: "rest", platform: "rest",
}) })
} else { } else {
toast.error(res.left.error.toString()) toast.error(res.left)
} }
} }
@@ -492,11 +481,6 @@ const HoppGistCollectionsExporter: ImporterOrExporter = {
} }
if (E.isRight(collectionJSON)) { 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) const res = await gistExporter(collectionJSON.right, accessToken)
if (E.isLeft(res)) { if (E.isLeft(res)) {
@@ -513,6 +497,8 @@ const HoppGistCollectionsExporter: ImporterOrExporter = {
}) })
platform.io.openExternalLink(res.right) platform.io.openExternalLink(res.right)
} else {
toast.error(collectionJSON.left)
} }
isHoppGistCollectionExporterInProgress.value = false isHoppGistCollectionExporterInProgress.value = false
@@ -589,9 +575,7 @@ const getCollectionJSON = async () => {
props.collectionsType.selectedTeam?.teamID props.collectionsType.selectedTeam?.teamID
) )
return E.isRight(res) return E.isRight(res) ? E.right(res.right) : E.left(res.left)
? E.right(res.right.exportCollectionsToJSON)
: E.left(res.left)
} }
if (props.collectionsType.type === "my-collections") { if (props.collectionsType.type === "my-collections") {

View File

@@ -71,6 +71,13 @@
collection: node.data.data.data, 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=" @edit-properties="
node.data.type === 'collections' && node.data.type === 'collections' &&
emit('edit-properties', { emit('edit-properties', {
@@ -146,6 +153,13 @@
folder: node.data.data.data, 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=" @edit-properties="
node.data.type === 'folders' && node.data.type === 'folders' &&
emit('edit-properties', { emit('edit-properties', {
@@ -447,6 +461,13 @@ const emit = defineEmits<{
folder: HoppCollection folder: HoppCollection
} }
): void ): void
(
event: "duplicate-collection",
payload: {
pathOrID: string
collectionSyncID?: string
}
): void
( (
event: "edit-properties", event: "edit-properties",
payload: { payload: {

View File

@@ -20,7 +20,7 @@
@dragover="handleDragOver($event)" @dragover="handleDragOver($event)"
@dragleave="resetDragState" @dragleave="resetDragState"
@dragend="resetDragState" @dragend="resetDragState"
@contextmenu.prevent="options?.tippy.show()" @contextmenu.prevent="options?.tippy?.show()"
> >
<div <div
class="pointer-events-auto flex min-w-0 flex-1 cursor-pointer items-center justify-center" class="pointer-events-auto flex min-w-0 flex-1 cursor-pointer items-center justify-center"
@@ -112,12 +112,11 @@
ref="duplicate" ref="duplicate"
:icon="IconCopy" :icon="IconCopy"
:label="t('action.duplicate')" :label="t('action.duplicate')"
:loading="duplicateLoading" :loading="duplicateRequestLoading"
:shortcut="['D']" :shortcut="['D']"
@click=" @click="
() => { () => {
emit('duplicate-request'), emit('duplicate-request')
collectionsType === 'my-collections' ? hide() : null
} }
" "
/> />
@@ -211,7 +210,7 @@ const props = defineProps({
default: "my-collections", default: "my-collections",
required: true, required: true,
}, },
duplicateLoading: { duplicateRequestLoading: {
type: Boolean, type: Boolean,
default: false, default: false,
required: false, required: false,
@@ -259,7 +258,7 @@ const emit = defineEmits<{
(event: "update-last-request-order", payload: DataTransfer): void (event: "update-last-request-order", payload: DataTransfer): void
}>() }>()
const tippyActions = ref<TippyComponent | null>(null) const tippyActions = ref<HTMLButtonElement | null>(null)
const edit = ref<HTMLButtonElement | null>(null) const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null) const deleteAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null) const options = ref<TippyComponent | null>(null)
@@ -277,10 +276,10 @@ const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
}) })
watch( watch(
() => props.duplicateLoading, () => props.duplicateRequestLoading,
(val) => { (val) => {
if (!val) { if (!val) {
options.value!.tippy.hide() options.value!.tippy?.hide()
} }
} }
) )

View File

@@ -61,6 +61,7 @@
:export-loading="exportLoading" :export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults" :has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:collection-move-loading="collectionMoveLoading" :collection-move-loading="collectionMoveLoading"
:duplicate-collection-loading="duplicateCollectionLoading"
:is-last-item="node.data.isLastItem" :is-last-item="node.data.isLastItem"
:is-selected=" :is-selected="
isSelected({ isSelected({
@@ -89,6 +90,12 @@
collection: node.data.data.data, collection: node.data.data.data,
}) })
" "
@duplicate-collection="
node.data.type === 'collections' &&
emit('duplicate-collection', {
pathOrID: node.data.data.data.id,
})
"
@edit-properties=" @edit-properties="
node.data.type === 'collections' && node.data.type === 'collections' &&
emit('edit-properties', { emit('edit-properties', {
@@ -149,6 +156,7 @@
:export-loading="exportLoading" :export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults" :has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:collection-move-loading="collectionMoveLoading" :collection-move-loading="collectionMoveLoading"
:duplicate-collection-loading="duplicateCollectionLoading"
:is-last-item="node.data.isLastItem" :is-last-item="node.data.isLastItem"
:is-selected=" :is-selected="
isSelected({ isSelected({
@@ -176,6 +184,12 @@
folder: node.data.data.data, folder: node.data.data.data,
}) })
" "
@duplicate-collection="
node.data.type === 'folders' &&
emit('duplicate-collection', {
pathOrID: node.data.data.data.id,
})
"
@edit-properties=" @edit-properties="
node.data.type === 'folders' && node.data.type === 'folders' &&
emit('edit-properties', { emit('edit-properties', {
@@ -236,7 +250,7 @@
:request-i-d="node.data.data.data.id" :request-i-d="node.data.data.data.id"
:parent-i-d="node.data.data.parentIndex" :parent-i-d="node.data.data.parentIndex"
:collections-type="collectionsType.type" :collections-type="collectionsType.type"
:duplicate-loading="duplicateLoading" :duplicate-request-loading="duplicateRequestLoading"
:is-active="isActiveRequest(node.data.data.data.id)" :is-active="isActiveRequest(node.data.data.data.id)"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults" :has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:request-move-loading="requestMoveLoading" :request-move-loading="requestMoveLoading"
@@ -445,7 +459,12 @@ const props = defineProps({
default: false, default: false,
required: false, required: false,
}, },
duplicateLoading: { duplicateRequestLoading: {
type: Boolean,
default: false,
required: false,
},
duplicateCollectionLoading: {
type: Boolean, type: Boolean,
default: false, default: false,
required: false, required: false,
@@ -497,6 +516,13 @@ const emit = defineEmits<{
folder: TeamCollection folder: TeamCollection
} }
): void ): void
(
event: "duplicate-collection",
payload: {
pathOrID: string
collectionSyncID?: string
}
): void
( (
event: "edit-properties", event: "edit-properties",
payload: { payload: {

View File

@@ -73,7 +73,13 @@
@keyup.r="requestAction.$el.click()" @keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()" @keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()" @keyup.e="edit.$el.click()"
@keyup.d="
showDuplicateCollectionAction
? duplicateAction.$el.click()
: null
"
@keyup.delete="deleteAction.$el.click()" @keyup.delete="deleteAction.$el.click()"
@keyup.p="propertiesAction.$el.click()"
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<HoppSmartItem <HoppSmartItem
@@ -116,6 +122,22 @@
} }
" "
/> />
<HoppSmartItem
v-if="showDuplicateCollectionAction"
ref="duplicateAction"
:icon="IconCopy"
:label="t('action.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-collection', {
path: `${collectionIndex}`,
collectionSyncID: collection.id,
}),
hide()
}
"
/>
<HoppSmartItem <HoppSmartItem
ref="deleteAction" ref="deleteAction"
:icon="IconTrash2" :icon="IconTrash2"
@@ -168,6 +190,7 @@
@add-request="$emit('add-request', $event)" @add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)" @add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)" @edit-folder="$emit('edit-folder', $event)"
@duplicate-collection="$emit('duplicate-collection', $event)"
@edit-request="$emit('edit-request', $event)" @edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)" @duplicate-request="$emit('duplicate-request', $event)"
@edit-properties=" @edit-properties="
@@ -229,24 +252,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
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 { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { removeGraphqlCollection } from "~/newstore/collections" import { useToast } from "@composables/toast"
import { Picked } from "~/helpers/types/HoppPicked"
import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
import { HoppCollection } from "@hoppscotch/data" 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<{ const props = defineProps<{
picked: Picked | null picked: Picked | null
@@ -271,6 +297,13 @@ const emit = defineEmits<{
(e: "add-request", i: any): void (e: "add-request", i: any): void
(e: "add-folder", i: any): void (e: "add-folder", i: any): void
(e: "edit-folder", i: any): void (e: "edit-folder", i: any): void
(
e: "duplicate-collection",
payload: {
path: string
collectionSyncID?: string
}
): void
( (
e: "edit-properties", e: "edit-properties",
payload: { payload: {
@@ -296,13 +329,20 @@ const options = ref<any | null>(null)
const requestAction = ref<any | null>(null) const requestAction = ref<any | null>(null)
const folderAction = ref<any | null>(null) const folderAction = ref<any | null>(null)
const edit = ref<any | null>(null) const edit = ref<any | null>(null)
const duplicateAction = ref<any | null>(null)
const deleteAction = ref<any | null>(null) const deleteAction = ref<any | null>(null)
const propertiesAction = ref<any | null>(null)
const showChildren = ref(false) const showChildren = ref(false)
const dragging = ref(false) const dragging = ref(false)
const confirmRemove = ref(false) const confirmRemove = ref(false)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const isSelected = computed( const isSelected = computed(
() => () =>
props.picked?.pickedType === "gql-my-collection" && props.picked?.pickedType === "gql-my-collection" &&
@@ -315,6 +355,17 @@ const collectionIcon = computed(() => {
return IconFolder 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 = () => { const pick = () => {
emit("select", { emit("select", {
pickedType: "gql-my-collection", pickedType: "gql-my-collection",

View File

@@ -70,7 +70,13 @@
@keyup.r="requestAction.$el.click()" @keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()" @keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()" @keyup.e="edit.$el.click()"
@keyup.d="
showDuplicateCollectionAction
? duplicateAction.$el.click()
: null
"
@keyup.delete="deleteAction.$el.click()" @keyup.delete="deleteAction.$el.click()"
@keyup.p="propertiesAction.$el.click()"
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<HoppSmartItem <HoppSmartItem
@@ -109,6 +115,22 @@
} }
" "
/> />
<HoppSmartItem
v-if="showDuplicateCollectionAction"
ref="duplicateAction"
:icon="IconCopy"
:label="t('action.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-collection', {
path: folderPath,
collectionSyncID: folder.id,
}),
hide()
}
"
/>
<HoppSmartItem <HoppSmartItem
ref="deleteAction" ref="deleteAction"
:icon="IconTrash2" :icon="IconTrash2"
@@ -162,6 +184,7 @@
@add-request="emit('add-request', $event)" @add-request="emit('add-request', $event)"
@add-folder="emit('add-folder', $event)" @add-folder="emit('add-folder', $event)"
@edit-folder="emit('edit-folder', $event)" @edit-folder="emit('edit-folder', $event)"
@duplicate-collection="emit('duplicate-collection', $event)"
@edit-request="emit('edit-request', $event)" @edit-request="emit('edit-request', $event)"
@duplicate-request="emit('duplicate-request', $event)" @duplicate-request="emit('duplicate-request', $event)"
@edit-properties=" @edit-properties="
@@ -213,24 +236,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
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 { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { removeGraphqlFolder } from "~/newstore/collections" import { useToast } from "@composables/toast"
import { computed, ref } from "vue"
import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
import { Picked } from "~/helpers/types/HoppPicked"
import { HoppCollection } from "@hoppscotch/data" 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 toast = useToast()
const t = useI18n() const t = useI18n()
@@ -255,6 +281,7 @@ const emit = defineEmits([
"edit-request", "edit-request",
"add-folder", "add-folder",
"edit-folder", "edit-folder",
"duplicate-collection",
"duplicate-request", "duplicate-request",
"edit-properties", "edit-properties",
"select-request", "select-request",
@@ -267,12 +294,19 @@ const options = ref<any | null>(null)
const requestAction = ref<any | null>(null) const requestAction = ref<any | null>(null)
const folderAction = ref<any | null>(null) const folderAction = ref<any | null>(null)
const edit = ref<any | null>(null) const edit = ref<any | null>(null)
const duplicateAction = ref<any | null>(null)
const deleteAction = ref<any | null>(null) const deleteAction = ref<any | null>(null)
const propertiesAction = ref<any | null>(null)
const showChildren = ref(false) const showChildren = ref(false)
const dragging = ref(false) const dragging = ref(false)
const confirmRemove = ref(false) const confirmRemove = ref(false)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const isSelected = computed( const isSelected = computed(
() => () =>
props.picked?.pickedType === "gql-my-folder" && props.picked?.pickedType === "gql-my-folder" &&
@@ -285,6 +319,17 @@ const collectionIcon = computed(() => {
return IconFolder 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 = () => { const pick = () => {
emit("select", { emit("select", {
pickedType: "gql-my-folder", pickedType: "gql-my-folder",

View File

@@ -54,6 +54,7 @@
@add-request="addRequest($event)" @add-request="addRequest($event)"
@add-folder="addFolder($event)" @add-folder="addFolder($event)"
@edit-folder="editFolder($event)" @edit-folder="editFolder($event)"
@duplicate-collection="duplicateCollection($event)"
@edit-request="editRequest($event)" @edit-request="editRequest($event)"
@duplicate-request="duplicateRequest($event)" @duplicate-request="duplicateRequest($event)"
@select-collection="$emit('use-collection', collection)" @select-collection="$emit('use-collection', collection)"
@@ -167,6 +168,7 @@ import {
editGraphqlCollection, editGraphqlCollection,
editGraphqlFolder, editGraphqlFolder,
moveGraphqlRequest, moveGraphqlRequest,
duplicateGraphQLCollection,
} from "~/newstore/collections" } from "~/newstore/collections"
import IconPlus from "~icons/lucide/plus" import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle" import IconHelpCircle from "~icons/lucide/help-circle"
@@ -380,6 +382,14 @@ const editCollection = (
displayModalEdit(true) displayModalEdit(true)
} }
const duplicateCollection = ({
path,
collectionSyncID,
}: {
path: string
collectionSyncID?: string
}) => duplicateGraphQLCollection(path, collectionSyncID)
const onAddRequest = ({ const onAddRequest = ({
name, name,
path, path,

View File

@@ -37,6 +37,7 @@
@add-request="addRequest" @add-request="addRequest"
@edit-collection="editCollection" @edit-collection="editCollection"
@edit-folder="editFolder" @edit-folder="editFolder"
@duplicate-collection="duplicateCollection"
@edit-properties="editProperties" @edit-properties="editProperties"
@export-data="exportData" @export-data="exportData"
@remove-collection="removeCollection" @remove-collection="removeCollection"
@@ -67,7 +68,8 @@
" "
:filter-text="filterTexts" :filter-text="filterTexts"
:export-loading="exportLoading" :export-loading="exportLoading"
:duplicate-loading="duplicateLoading" :duplicate-request-loading="duplicateRequestLoading"
:duplicate-collection-loading="duplicateCollectionLoading"
:save-request="saveRequest" :save-request="saveRequest"
:picked="picked" :picked="picked"
:collection-move-loading="collectionMoveLoading" :collection-move-loading="collectionMoveLoading"
@@ -76,6 +78,7 @@
@add-folder="addFolder" @add-folder="addFolder"
@edit-collection="editCollection" @edit-collection="editCollection"
@edit-folder="editFolder" @edit-folder="editFolder"
@duplicate-collection="duplicateCollection"
@edit-properties="editProperties" @edit-properties="editProperties"
@export-data="exportData" @export-data="exportData"
@remove-collection="removeCollection" @remove-collection="removeCollection"
@@ -208,6 +211,7 @@ import {
createChildCollection, createChildCollection,
createNewRootCollection, createNewRootCollection,
deleteCollection, deleteCollection,
duplicateTeamCollection,
moveRESTTeamCollection, moveRESTTeamCollection,
updateOrderRESTTeamCollection, updateOrderRESTTeamCollection,
updateTeamCollection, updateTeamCollection,
@@ -240,6 +244,7 @@ import {
addRESTCollection, addRESTCollection,
addRESTFolder, addRESTFolder,
cascadeParentCollectionForHeaderAuth, cascadeParentCollectionForHeaderAuth,
duplicateRESTCollection,
editRESTCollection, editRESTCollection,
editRESTFolder, editRESTFolder,
editRESTRequest, editRESTRequest,
@@ -645,7 +650,8 @@ const isSelected = ({
const modalLoadingState = ref(false) const modalLoadingState = ref(false)
const exportLoading = ref(false) const exportLoading = ref(false)
const duplicateLoading = ref(false) const duplicateRequestLoading = ref(false)
const duplicateCollectionLoading = ref(false)
const showModalAdd = ref(false) const showModalAdd = ref(false)
const showModalAddRequest = 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<string>) => {
toast.error(`${getErrorMessage(err)}`)
duplicateCollectionLoading.value = false
},
() => {
toast.success(t("collection.duplicated"))
duplicateCollectionLoading.value = false
}
)
)()
}
}
const editRequest = (payload: { const editRequest = (payload: {
folderPath: string | undefined folderPath: string | undefined
requestIndex: string requestIndex: string
@@ -1149,7 +1183,7 @@ const duplicateRequest = (payload: {
saveRESTRequestAs(folderPath, newRequest) saveRESTRequestAs(folderPath, newRequest)
toast.success(t("request.duplicated")) toast.success(t("request.duplicated"))
} else if (hasTeamWriteAccess.value) { } else if (hasTeamWriteAccess.value) {
duplicateLoading.value = true duplicateRequestLoading.value = true
if (!collectionsType.value.selectedTeam) return if (!collectionsType.value.selectedTeam) return
@@ -1164,10 +1198,10 @@ const duplicateRequest = (payload: {
TE.match( TE.match(
(err: GQLError<string>) => { (err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`) toast.error(`${getErrorMessage(err)}`)
duplicateLoading.value = false duplicateRequestLoading.value = false
}, },
() => { () => {
duplicateLoading.value = false duplicateRequestLoading.value = false
toast.success(t("request.duplicated")) toast.success(t("request.duplicated"))
displayModalAddRequest(false) displayModalAddRequest(false)
} }

View File

@@ -0,0 +1,3 @@
mutation DuplicateTeamCollection($collectionID: String!) {
duplicateTeamCollection(collectionID: $collectionID)
}

View File

@@ -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 { import {
HoppCollection, HoppCollection,
HoppRESTAuth,
HoppRESTHeaders,
HoppRESTRequest,
makeCollection, makeCollection,
translateToNewRequest, translateToNewRequest,
} from "@hoppscotch/data" } 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 { TeamCollection } from "../teams/TeamCollection"
import { TeamRequest } from "../teams/TeamRequest" import { TeamRequest } from "../teams/TeamRequest"
import { GQLError, runGQLQuery } from "./GQLClient" import { GQLError, runGQLQuery } from "./GQLClient"
@@ -17,6 +23,15 @@ import {
GetCollectionTitleAndDataDocument, GetCollectionTitleAndDataDocument,
} from "./graphql" } from "./graphql"
type TeamCollectionJSON = {
name: string
folders: TeamCollectionJSON[]
requests: HoppRESTRequest[]
data: string
}
type CollectionDataProps = { auth: HoppRESTAuth; headers: HoppRESTHeaders }
export const BACKEND_PAGE_SIZE = 10 export const BACKEND_PAGE_SIZE = 10
const getCollectionChildrenIDs = async (collID: string) => { const getCollectionChildrenIDs = async (collID: string) => {
@@ -78,6 +93,68 @@ const getCollectionRequests = async (collID: string) => {
return E.right(reqList) return E.right(reqList)
} }
// Pick the value from the parsed result if it is successful, otherwise, return the default value
const parseWithDefaultValue = <T>(
parseResult: z.SafeParseReturnType<T, T>,
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<string, unknown> | null
): CollectionDataProps => {
const defaultDataProps: CollectionDataProps = {
auth: { authType: "inherit", authActive: true },
headers: [],
}
if (!data) {
return defaultDataProps
}
let parsedData: CollectionDataProps | Record<string, unknown> | null
if (typeof data === "string") {
try {
parsedData = JSON.parse(data)
} catch {
return defaultDataProps
}
} else {
parsedData = data
}
const auth = parseWithDefaultValue<CollectionDataProps["auth"]>(
HoppRESTAuth.safeParse(parsedData?.auth),
defaultDataProps.auth
)
const headers = parseWithDefaultValue<CollectionDataProps["headers"]>(
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 = ( export const getCompleteCollectionTree = (
collID: string collID: string
): TE.TaskEither<GQLError<string>, TeamCollection> => ): TE.TaskEither<GQLError<string>, TeamCollection> =>
@@ -146,10 +223,26 @@ export const teamCollToHoppRESTColl = (
* @param teamID - ID of the team * @param teamID - ID of the team
* @returns Either of the JSON string of the collection or the error * @returns Either of the JSON string of the collection or the error
*/ */
export const getTeamCollectionJSON = async (teamID: string) => export const getTeamCollectionJSON = async (teamID: string) => {
await runGQLQuery({ const data = await runGQLQuery({
query: ExportAsJsonDocument, query: ExportAsJsonDocument,
variables: { variables: {
teamID, 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))
}

View File

@@ -9,6 +9,9 @@ import {
DeleteCollectionDocument, DeleteCollectionDocument,
DeleteCollectionMutation, DeleteCollectionMutation,
DeleteCollectionMutationVariables, DeleteCollectionMutationVariables,
DuplicateTeamCollectionDocument,
DuplicateTeamCollectionMutation,
DuplicateTeamCollectionMutationVariables,
ImportFromJsonDocument, ImportFromJsonDocument,
ImportFromJsonMutation, ImportFromJsonMutation,
ImportFromJsonMutationVariables, ImportFromJsonMutationVariables,
@@ -140,3 +143,12 @@ export const updateTeamCollection = (
data, data,
newTitle, newTitle,
}) })
export const duplicateTeamCollection = (collectionID: string) =>
runMutation<
DuplicateTeamCollectionMutation,
DuplicateTeamCollectionMutationVariables,
""
>(DuplicateTeamCollectionDocument, {
collectionID,
})

View File

@@ -324,10 +324,10 @@ export default class NewTeamCollectionAdapter {
if (!parentCollection) return 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) { if (parentCollection.children !== null) {
parentCollection.children.push(collection) parentCollection.children.push(collection)
} else {
parentCollection.children = [collection]
} }
} }

View File

@@ -1,20 +1,21 @@
import { pluck } from "rxjs/operators"
import { import {
HoppGQLRequest,
HoppRESTRequest,
HoppCollection, HoppCollection,
makeCollection,
HoppGQLAuth, HoppGQLAuth,
HoppGQLRequest,
HoppRESTAuth,
HoppRESTHeaders,
HoppRESTRequest,
makeCollection,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import { pluck } from "rxjs/operators"
import { resolveSaveContextOnRequestReorder } from "~/helpers/collection/request" 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 { 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 = { const defaultRESTCollectionState = {
state: [ 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( editRequest(
{ state }: RESTCollectionStoreType, { 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( editRequest(
{ state }: GraphqlCollectionStoreType, { 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( export function removeDuplicateRESTCollectionOrFolder(
id: string, id: string,
collectionPath: 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( export function removeDuplicateGraphqlCollectionOrFolder(
id: string, id: string,
collectionPath: string, collectionPath: string,

View File

@@ -52,6 +52,12 @@ export type PlatformDef = {
* Whether to show the A/B testing workspace switcher click login flow or not * Whether to show the A/B testing workspace switcher click login flow or not
*/ */
workspaceSwitcherLogin?: Ref<boolean> workspaceSwitcherLogin?: Ref<boolean>
/**
* 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 infra?: InfraPlatformDef
} }

View File

@@ -276,7 +276,8 @@ const HoppGQLSaveContextSchema = z.nullable(
.object({ .object({
originLocation: z.literal("user-collection"), originLocation: z.literal("user-collection"),
folderPath: z.string(), folderPath: z.string(),
requestIndex: z.number(), // TODO: Investigate why this field is not populated at times
requestIndex: z.optional(z.number()),
}) })
.strict(), .strict(),
z z

View File

@@ -0,0 +1,3 @@
mutation DuplicateUserCollection($collectionID: String!, $reqType: ReqType!) {
duplicateUserCollection(collectionID: $collectionID, reqType: $reqType)
}

View File

@@ -40,6 +40,7 @@ createHoppApp("#app", {
platformFeatureFlags: { platformFeatureFlags: {
exportAsGIST: false, exportAsGIST: false,
hasTelemetry: false, hasTelemetry: false,
duplicateCollectionDisabledInPersonalWorkspace: true,
}, },
infra: InfraPlatform, infra: InfraPlatform,
}) })

View File

@@ -68,6 +68,9 @@ import {
UpdateUserCollectionMutation, UpdateUserCollectionMutation,
UpdateUserCollectionMutationVariables, UpdateUserCollectionMutationVariables,
UpdateUserCollectionDocument, UpdateUserCollectionDocument,
DuplicateUserCollectionDocument,
DuplicateUserCollectionMutation,
DuplicateUserCollectionMutationVariables,
} from "../../api/generated/graphql" } from "../../api/generated/graphql"
export const createRESTRootUserCollection = (title: string, data?: string) => export const createRESTRootUserCollection = (title: string, data?: string) =>
@@ -193,6 +196,19 @@ export const moveUserCollection = (
destCollectionID: destinationCollectionID, destCollectionID: destinationCollectionID,
})() })()
export const duplicateUserCollection = (
collectionID: string,
reqType: ReqType
) =>
runMutation<
DuplicateUserCollectionMutation,
DuplicateUserCollectionMutationVariables,
""
>(DuplicateUserCollectionDocument, {
collectionID,
reqType,
})()
export const editUserRequest = ( export const editUserRequest = (
requestID: string, requestID: string,
title: string, title: string,

View File

@@ -284,6 +284,20 @@ function setupUserCollectionCreatedSubscription() {
return 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 = const parentCollectionPath =
parentCollectionID && parentCollectionID &&
getCollectionPathFromCollectionID( getCollectionPathFromCollectionID(
@@ -828,3 +842,105 @@ function getRequestIndex(
return requestIndex 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
}

View File

@@ -11,9 +11,7 @@ import {
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { getSyncInitFunction } from "../../lib/sync" import { getSyncInitFunction, StoreSyncDefinitionOf } from "../../lib/sync"
import { StoreSyncDefinitionOf } from "../../lib/sync"
import { createMapper } from "../../lib/sync/mapper" import { createMapper } from "../../lib/sync/mapper"
import { import {
createRESTChildUserCollection, createRESTChildUserCollection,
@@ -21,6 +19,7 @@ import {
createRESTUserRequest, createRESTUserRequest,
deleteUserCollection, deleteUserCollection,
deleteUserRequest, deleteUserRequest,
duplicateUserCollection,
editUserRequest, editUserRequest,
moveUserCollection, moveUserCollection,
moveUserRequest, moveUserRequest,
@@ -29,6 +28,7 @@ import {
} from "./collections.api" } from "./collections.api"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { ReqType } from "../../api/generated/graphql"
// restCollectionsMapper uses the collectionPath as the local identifier // restCollectionsMapper uses the collectionPath as the local identifier
export const restCollectionsMapper = createMapper<string, string>() export const restCollectionsMapper = createMapper<string, string>()
@@ -280,6 +280,11 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
} }
} }
}, },
async duplicateCollection({ collectionSyncID }) {
if (collectionSyncID) {
await duplicateUserCollection(collectionSyncID, ReqType.Rest)
}
},
editRequest({ path, requestIndex, requestNew }) { editRequest({ path, requestIndex, requestNew }) {
const request = navigateToFolderWithIndexPath( const request = navigateToFolderWithIndexPath(
restCollectionStore.value.state, restCollectionStore.value.state,

View File

@@ -20,11 +20,13 @@ import {
createGQLUserRequest, createGQLUserRequest,
deleteUserCollection, deleteUserCollection,
deleteUserRequest, deleteUserRequest,
duplicateUserCollection,
editGQLUserRequest, editGQLUserRequest,
updateUserCollection, updateUserCollection,
} from "./collections.api" } from "./collections.api"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { ReqType } from "../../api/generated/graphql"
import { moveOrReorderRequests } from "./collections.sync" import { moveOrReorderRequests } from "./collections.sync"
// gqlCollectionsMapper uses the collectionPath as the local identifier // gqlCollectionsMapper uses the collectionPath as the local identifier
@@ -261,6 +263,11 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
await deleteUserCollection(folderID) await deleteUserCollection(folderID)
} }
}, },
async duplicateCollection({ collectionSyncID }) {
if (collectionSyncID) {
await duplicateUserCollection(collectionSyncID, ReqType.Gql)
}
},
editRequest({ path, requestIndex, requestNew }) { editRequest({ path, requestIndex, requestNew }) {
const request = navigateToFolderWithIndexPath( const request = navigateToFolderWithIndexPath(
graphqlCollectionStore.value.state, graphqlCollectionStore.value.state,