Files
hoppscotch/packages/hoppscotch-common/src/components/collections/TeamCollections.vue
2024-06-27 19:17:56 +05:30

894 lines
26 KiB
Vue

<template>
<div class="flex flex-1 flex-col">
<div
class="sticky z-10 flex flex-1 justify-between border-b border-dividerLight bg-primary"
:style="
saveRequest
? 'top: calc(var(--upper-primary-sticky-fold) - var(--line-height-body))'
: 'top: var(--upper-primary-sticky-fold)'
"
>
<HoppButtonSecondary
v-if="hasNoTeamAccess || isShowingSearchResults"
v-tippy="{ theme: 'tooltip' }"
disabled
class="!rounded-none"
:icon="IconPlus"
:title="t('team.no_access')"
:label="t('action.new')"
/>
<HoppButtonSecondary
v-else
:icon="IconPlus"
:label="t('action.new')"
class="!rounded-none"
@click="emit('display-modal-add')"
/>
<span class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/collections"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:disabled="
(collectionsType.type === 'team-collections' &&
collectionsType.selectedTeam === undefined) ||
isShowingSearchResults
"
:icon="IconImport"
:title="t('modal.import_export')"
@click="emit('display-modal-import-export')"
/>
</span>
</div>
<div class="flex flex-col overflow-hidden">
<HoppSmartTree :adapter="teamAdapter">
<template
#content="{ node, toggleChildren, isOpen, highlightChildren }"
>
<CollectionsCollection
v-if="node.data.type === 'collections'"
:id="node.data.data.data.id"
:parent-i-d="node.data.data.parentIndex"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:collection-move-loading="collectionMoveLoading"
:is-last-item="node.data.isLastItem"
:is-selected="
isSelected({
collectionID: node.id,
})
"
folder-type="collection"
@add-request="
node.data.type === 'collections' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'collections' &&
emit('edit-collection', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@edit-properties="
node.data.type === 'collections' &&
emit('edit-properties', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'collections' &&
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-collection', node.id)"
@drop-event="dropEvent($event, node.id)"
@drag-event="dragEvent($event, node.id)"
@update-collection-order="
updateCollectionOrder($event, {
destinationCollectionIndex: node.data.data.data.id,
destinationCollectionParentIndex: node.data.data.parentIndex,
})
"
@update-last-collection-order="
updateCollectionOrder($event, {
destinationCollectionIndex: null,
destinationCollectionParentIndex: node.data.data.parentIndex,
})
"
@dragging="
(isDraging) =>
highlightChildren(isDraging ? node.data.data.data.id : null)
"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'teams-collection',
collectionID: node.id,
})
}
"
@run-collection="emit('run-collection', $event)"
@click="
() => {
handleCollectionClick({
collectionID: node.id,
isOpen,
})
}
"
/>
<CollectionsCollection
v-if="node.data.type === 'folders'"
:id="node.data.data.data.id"
:parent-i-d="node.data.data.parentIndex"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:collection-move-loading="collectionMoveLoading"
:is-last-item="node.data.isLastItem"
:is-selected="
isSelected({
folderID: node.data.data.data.id,
})
"
folder-type="folder"
@add-request="
node.data.type === 'folders' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'folders' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'folders' &&
emit('edit-folder', {
folder: node.data.data.data,
})
"
@edit-properties="
node.data.type === 'folders' &&
emit('edit-properties', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'folders' &&
emit('export-data', node.data.data.data)
"
@remove-collection="
node.data.type === 'folders' &&
emit('remove-folder', node.data.data.data.id)
"
@drop-event="dropEvent($event, node.data.data.data.id)"
@drag-event="dragEvent($event, node.data.data.data.id)"
@update-collection-order="
updateCollectionOrder($event, {
destinationCollectionIndex: node.data.data.data.id,
destinationCollectionParentIndex: node.data.data.parentIndex,
})
"
@update-last-collection-order="
updateCollectionOrder($event, {
destinationCollectionIndex: null,
destinationCollectionParentIndex: node.data.data.parentIndex,
})
"
@dragging="
(isDraging) =>
highlightChildren(isDraging ? node.data.data.data.id : null)
"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'teams-folder',
folderID: node.data.data.data.id,
})
}
"
@run-collection="emit('run-collection', $event)"
@click="
() => {
handleCollectionClick({
// for the folders, we get a path, so we need to get the last part of the path which is the folder id
collectionID: node.id.split('/').pop() as string,
isOpen,
})
}
"
/>
<CollectionsRequest
v-if="node.data.type === 'requests'"
:request="node.data.data.data.request"
:request-i-d="node.data.data.data.id"
:parent-i-d="node.data.data.parentIndex"
:collections-type="collectionsType.type"
:duplicate-loading="duplicateLoading"
:is-active="isActiveRequest(node.data.data.data.id)"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:request-move-loading="requestMoveLoading"
:is-last-item="node.data.isLastItem"
:is-selected="
isSelected({
requestID: node.data.data.data.id,
})
"
@edit-request="
node.data.type === 'requests' &&
emit('edit-request', {
requestIndex: node.data.data.data.id,
request: node.data.data.data.request,
})
"
@duplicate-request="
node.data.type === 'requests' &&
emit('duplicate-request', {
folderPath: node.data.data.parentIndex,
request: node.data.data.data.request,
})
"
@remove-request="
node.data.type === 'requests' &&
emit('remove-request', {
folderPath: null,
requestIndex: node.data.data.data.id,
})
"
@select-request="
node.data.type === 'requests' &&
selectRequest({
request: node.data.data.data.request,
requestIndex: node.data.data.data.id,
folderPath: getPath(node.id),
})
"
@share-request="
node.data.type === 'requests' &&
emit('share-request', {
request: node.data.data.data.request,
})
"
@drag-request="
dragRequest($event, {
folderPath: node.data.data.parentIndex,
requestIndex: node.data.data.data.id,
})
"
@update-request-order="
updateRequestOrder($event, {
folderPath: node.data.data.parentIndex,
requestIndex: node.data.data.data.id,
})
"
@update-last-request-order="
updateRequestOrder($event, {
folderPath: node.data.data.parentIndex,
requestIndex: null,
})
"
/>
</template>
<template #emptyNode="{ node }">
<HoppSmartPlaceholder
v-if="filterText.length !== 0 && teamCollectionList.length === 0"
:text="`${t('state.nothing_found')}${filterText}`"
>
<template #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node === null"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
@drop.stop
>
<template #body>
<div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight">
{{ t("collection.import_or_create") }}
</span>
<div class="flex flex-col items-stretch gap-4">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
:disabled="hasNoTeamAccess"
:title="hasNoTeamAccess ? t('team.no_access') : ''"
@click="
hasNoTeamAccess
? null
: emit('display-modal-import-export')
"
/>
<HoppButtonSecondary
:icon="IconPlus"
:label="t('add.new')"
filled
outline
:disabled="hasNoTeamAccess"
:title="hasNoTeamAccess ? t('team.no_access') : ''"
@click="hasNoTeamAccess ? null : emit('display-modal-add')"
/>
</div>
</div>
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
@drop.stop
>
<template #body>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`"
:text="t('empty.folder')"
@drop.stop
/>
</template>
</HoppSmartTree>
</div>
</div>
</template>
<script setup lang="ts">
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconImport from "~icons/lucide/folder-down"
import { computed, PropType, Ref, toRef } from "vue"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { TeamRequest } from "~/helpers/teams/TeamRequest"
import { ChildrenResult, SmartTreeAdapter } from "@hoppscotch/ui/helpers"
import { cloneDeep } from "lodash-es"
import { HoppRESTRequest } from "@hoppscotch/data"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import { Picked } from "~/helpers/types/HoppPicked.js"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
import { TeamWorkspace } from "~/services/workspace.service"
const t = useI18n()
const colorMode = useColorMode()
const tabs = useService(RESTTabService)
type CollectionType =
| {
type: "team-collections"
selectedTeam: TeamWorkspace
}
| { type: "my-collections"; selectedTeam: undefined }
const props = defineProps({
collectionsType: {
type: Object as PropType<CollectionType>,
default: () => ({ type: "my-collections", selectedTeam: undefined }),
required: true,
},
filterText: {
type: String as PropType<string>,
default: "",
required: true,
},
teamCollectionList: {
type: Array as PropType<TeamCollection[]>,
default: () => [],
required: true,
},
teamLoadingCollections: {
type: Array as PropType<string[]>,
default: () => [],
required: true,
},
saveRequest: {
type: Boolean,
default: false,
required: false,
},
exportLoading: {
type: Boolean,
default: false,
required: false,
},
duplicateLoading: {
type: Boolean,
default: false,
required: false,
},
picked: {
type: Object as PropType<Picked | null>,
default: null,
required: false,
},
collectionMoveLoading: {
type: Array as PropType<string[]>,
default: () => [],
required: false,
},
requestMoveLoading: {
type: Array as PropType<string[]>,
default: () => [],
required: false,
},
})
const isShowingSearchResults = computed(() => props.filterText.length > 0)
const emit = defineEmits<{
(
event: "add-request",
payload: {
path: string
folder: TeamCollection
}
): void
(
event: "add-folder",
payload: {
path: string
folder: TeamCollection
}
): void
(
event: "edit-collection",
payload: {
collectionIndex: string
collection: TeamCollection
}
): void
(
event: "edit-folder",
payload: {
folder: TeamCollection
}
): void
(
event: "edit-properties",
payload: {
collectionIndex: string
collection: TeamCollection
}
): void
(
event: "edit-request",
payload: {
requestIndex: string
request: HoppRESTRequest
}
): void
(
event: "duplicate-request",
payload: {
folderPath: string
request: HoppRESTRequest
}
): void
(event: "export-data", payload: TeamCollection): void
(event: "remove-collection", payload: string): void
(event: "remove-folder", payload: string): void
(
event: "remove-request",
payload: {
folderPath: string | null
requestIndex: string
}
): void
(
event: "select-request",
payload: {
request: HoppRESTRequest
requestIndex: string
isActive: boolean
folderPath: string
}
): void
(
event: "share-request",
payload: {
request: HoppRESTRequest
}
): void
(
event: "drop-request",
payload: {
folderPath: string
requestIndex: string
destinationCollectionIndex: string
}
): void
(
event: "drop-collection",
payload: {
collectionIndexDragged: string
destinationCollectionIndex: string
}
): void
(
event: "update-request-order",
payload: {
dragedRequestIndex: string
destinationRequestIndex: string | null
destinationCollectionIndex: string
}
): void
(
event: "update-collection-order",
payload: {
dragedCollectionIndex: string
destinationCollection: {
destinationCollectionIndex: string | null
destinationCollectionParentIndex: string | null
}
}
): void
(
event: "collection-click",
payload: {
// if the collection is open or not in the tree
isOpen: boolean
collectionID: string
}
): void
(event: "select", payload: Picked | null): void
(event: "expand-team-collection", payload: string): void
(event: "display-modal-add"): void
(event: "display-modal-import-export"): void
(event: "run-collection", collectionID: string): void
}>()
const getPath = (path: string) => {
const pathArray = path.split("/")
pathArray.pop()
return pathArray.join("/")
}
const handleCollectionClick = (payload: {
collectionID: string
isOpen: boolean
}) => {
const { collectionID, isOpen } = payload
emit("collection-click", {
collectionID,
isOpen,
})
}
const teamCollectionsList = toRef(props, "teamCollectionList")
const hasNoTeamAccess = computed(
() =>
props.collectionsType.type === "team-collections" &&
(props.collectionsType.selectedTeam === undefined ||
props.collectionsType.selectedTeam.role === "VIEWER")
)
const isSelected = ({
collectionID,
folderID,
requestID,
}: {
collectionID?: string | undefined
folderID?: string | undefined
requestID?: string | undefined
}) => {
if (collectionID !== undefined) {
return (
props.picked &&
props.picked.pickedType === "teams-collection" &&
props.picked.collectionID === collectionID
)
} else if (requestID !== undefined) {
return (
props.picked &&
props.picked.pickedType === "teams-request" &&
props.picked.requestID === requestID
)
}
return (
props.picked &&
props.picked.pickedType === "teams-folder" &&
props.picked.folderID === folderID
)
}
const active = computed(() => tabs.currentActiveTab.value.document.saveContext)
const isActiveRequest = (requestID: string) => {
return pipe(
active.value,
O.fromNullable,
O.filter(
(active) =>
active.originLocation === "team-collection" &&
active.requestID === requestID
),
O.isSome
)
}
const selectRequest = (data: {
request: HoppRESTRequest
requestIndex: string
folderPath: string | null
}) => {
const { request, requestIndex } = data
if (props.saveRequest) {
emit("select", {
pickedType: "teams-request",
requestID: requestIndex,
})
} else {
emit("select-request", {
request: request,
requestIndex: requestIndex,
isActive: isActiveRequest(requestIndex),
folderPath: data.folderPath,
})
}
}
const dragRequest = (
dataTransfer: DataTransfer,
{
folderPath,
requestIndex,
}: { folderPath: string | null; requestIndex: string }
) => {
if (!folderPath) return
dataTransfer.setData("folderPath", folderPath)
dataTransfer.setData("requestIndex", requestIndex)
}
const dragEvent = (dataTransfer: DataTransfer, collectionIndex: string) => {
dataTransfer.setData("collectionIndex", collectionIndex)
}
const dropEvent = (
dataTransfer: DataTransfer,
destinationCollectionIndex: string
) => {
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
const collectionIndexDragged = dataTransfer.getData("collectionIndex")
if (folderPath && requestIndex) {
emit("drop-request", {
folderPath,
requestIndex,
destinationCollectionIndex,
})
} else {
emit("drop-collection", {
collectionIndexDragged,
destinationCollectionIndex,
})
}
}
const updateRequestOrder = (
dataTransfer: DataTransfer,
{
folderPath,
requestIndex,
}: { folderPath: string | null; requestIndex: string | null }
) => {
if (!folderPath) return
const dragedRequestIndex = dataTransfer.getData("requestIndex")
const destinationRequestIndex = requestIndex
const destinationCollectionIndex = folderPath
emit("update-request-order", {
dragedRequestIndex,
destinationRequestIndex,
destinationCollectionIndex,
})
}
const updateCollectionOrder = (
dataTransfer: DataTransfer,
destinationCollection: {
destinationCollectionIndex: string | null
destinationCollectionParentIndex: string | null
}
) => {
const dragedCollectionIndex = dataTransfer.getData("collectionIndex")
emit("update-collection-order", {
dragedCollectionIndex,
destinationCollection,
})
}
type TeamCollections = {
isLastItem: boolean
type: "collections"
data: {
parentIndex: null
data: TeamCollection
}
}
type TeamFolder = {
isLastItem: boolean
type: "folders"
data: {
parentIndex: string
data: TeamCollection
}
}
type TeamRequests = {
isLastItem: boolean
type: "requests"
data: {
parentIndex: string
data: TeamRequest
}
}
type TeamCollectionNode = TeamCollections | TeamFolder | TeamRequests
class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
constructor(public data: Ref<TeamCollection[]>) {}
findCollInTree(
tree: TeamCollection[],
targetID: string
): TeamCollection | null {
for (const coll of tree) {
// If the direct child matched, then return that
if (coll.id === targetID) return coll
// Else run it in the children
if (coll.children) {
const result = this.findCollInTree(coll.children, targetID)
if (result) return result
}
}
// If nothing matched, return null
return null
}
getChildren(id: string | null): Ref<ChildrenResult<TeamCollectionNode>> {
return computed(() => {
if (id === null) {
if (props.teamLoadingCollections.includes("root")) {
return {
status: "loading",
}
}
const data = this.data.value.map((item, index) => ({
id: item.id,
data: {
isLastItem: index === this.data.value.length - 1,
type: "collections",
data: {
parentIndex: null,
data: item,
},
},
}))
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamCollections>
}
const parsedID = id.split("/")[id.split("/").length - 1]
!props.teamLoadingCollections.includes(parsedID) &&
emit("expand-team-collection", parsedID)
if (props.teamLoadingCollections.includes(parsedID)) {
return {
status: "loading",
}
}
const items = this.findCollInTree(this.data.value, parsedID)
if (items) {
const data = [
...(items.children
? items.children.map((item, index) => ({
id: `${id}/${item.id}`,
data: {
isLastItem:
items.children && items.children.length > 1
? index === items.children.length - 1
: false,
type: "folders",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
...(items.requests
? items.requests.map((item, index) => ({
id: `${id}/${item.id}`,
data: {
isLastItem:
items.requests && items.requests.length > 1
? index === items.requests.length - 1
: false,
type: "requests",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
]
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamFolder | TeamRequests>
}
return {
status: "loaded",
data: [],
}
})
}
}
const teamAdapter: SmartTreeAdapter<TeamCollectionNode> =
new TeamCollectionsAdapter(teamCollectionsList)
</script>