feat: team search in workspace search and spotlight (#3896)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
@@ -281,7 +281,7 @@
|
||||
"updated": "Environment updated",
|
||||
"value": "Value",
|
||||
"variable": "Variable",
|
||||
"variables":"Variables",
|
||||
"variables": "Variables",
|
||||
"variable_list": "Variable List"
|
||||
},
|
||||
"error": {
|
||||
@@ -961,7 +961,8 @@
|
||||
"success_invites": "Success invites",
|
||||
"title": "Workspaces",
|
||||
"we_sent_invite_link": "We sent an invite link to all invitees!",
|
||||
"we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the workspace."
|
||||
"we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the workspace.",
|
||||
"search_title": "Team Requests"
|
||||
},
|
||||
"team_environment": {
|
||||
"deleted": "Environment Deleted",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<span class="flex flex-1 items-center space-x-2">
|
||||
<template v-for="(title, index) in collectionTitles" :key="index">
|
||||
<span class="block" :class="{ truncate: index !== 0 }">
|
||||
{{ title }}
|
||||
</span>
|
||||
<icon-lucide-chevron-right class="flex flex-shrink-0" />
|
||||
</template>
|
||||
<span
|
||||
v-if="request"
|
||||
class="flex flex-shrink-0 truncate rounded-md border border-dividerDark px-1 text-tiny font-semibold"
|
||||
:style="{ color: getMethodLabelColor(request.method) }"
|
||||
>
|
||||
{{ request.method.toUpperCase() }}
|
||||
</span>
|
||||
<span v-if="request" class="block">
|
||||
{{ request.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
|
||||
|
||||
defineProps<{
|
||||
collectionTitles: string[]
|
||||
request: {
|
||||
name: string
|
||||
method: string
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
@@ -111,6 +111,7 @@ import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/
|
||||
import { ResponseSpotlightSearcherService } from "~/services/spotlight/searchers/response.searcher"
|
||||
import { SettingsSpotlightSearcherService } from "~/services/spotlight/searchers/settings.searcher"
|
||||
import { TabSpotlightSearcherService } from "~/services/spotlight/searchers/tab.searcher"
|
||||
import { TeamsSpotlightSearcherService } from "~/services/spotlight/searchers/teamRequest.searcher"
|
||||
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
|
||||
import {
|
||||
SwitchWorkspaceSpotlightSearcherService,
|
||||
@@ -144,6 +145,7 @@ useService(SwitchEnvSpotlightSearcherService)
|
||||
useService(WorkspaceSpotlightSearcherService)
|
||||
useService(SwitchWorkspaceSpotlightSearcherService)
|
||||
useService(InterceptorSpotlightSearcherService)
|
||||
useService(TeamsSpotlightSearcherService)
|
||||
|
||||
platform.spotlight?.additionalSearchers?.forEach((searcher) =>
|
||||
useService(searcher)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
v-if="hasNoTeamAccess"
|
||||
v-if="hasNoTeamAccess || isShowingSearchResults"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
class="!rounded-none"
|
||||
@@ -36,8 +36,9 @@
|
||||
v-if="!saveRequest"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:disabled="
|
||||
collectionsType.type === 'team-collections' &&
|
||||
collectionsType.selectedTeam === undefined
|
||||
(collectionsType.type === 'team-collections' &&
|
||||
collectionsType.selectedTeam === undefined) ||
|
||||
isShowingSearchResults
|
||||
"
|
||||
:icon="IconImport"
|
||||
:title="t('modal.import_export')"
|
||||
@@ -58,7 +59,7 @@
|
||||
:collections-type="collectionsType.type"
|
||||
:is-open="isOpen"
|
||||
:export-loading="exportLoading"
|
||||
:has-no-team-access="hasNoTeamAccess"
|
||||
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
|
||||
:collection-move-loading="collectionMoveLoading"
|
||||
:is-last-item="node.data.isLastItem"
|
||||
:is-selected="
|
||||
@@ -128,6 +129,14 @@
|
||||
})
|
||||
}
|
||||
"
|
||||
@click="
|
||||
() => {
|
||||
handleCollectionClick({
|
||||
collectionID: node.id,
|
||||
isOpen,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<CollectionsCollection
|
||||
v-if="node.data.type === 'folders'"
|
||||
@@ -137,7 +146,7 @@
|
||||
:collections-type="collectionsType.type"
|
||||
:is-open="isOpen"
|
||||
:export-loading="exportLoading"
|
||||
:has-no-team-access="hasNoTeamAccess"
|
||||
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
|
||||
:collection-move-loading="collectionMoveLoading"
|
||||
:is-last-item="node.data.isLastItem"
|
||||
:is-selected="
|
||||
@@ -209,6 +218,15 @@
|
||||
})
|
||||
}
|
||||
"
|
||||
@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'"
|
||||
@@ -218,7 +236,7 @@
|
||||
:collections-type="collectionsType.type"
|
||||
:duplicate-loading="duplicateLoading"
|
||||
:is-active="isActiveRequest(node.data.data.data.id)"
|
||||
:has-no-team-access="hasNoTeamAccess"
|
||||
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
|
||||
:request-move-loading="requestMoveLoading"
|
||||
:is-last-item="node.data.isLastItem"
|
||||
:is-selected="
|
||||
@@ -283,7 +301,15 @@
|
||||
</template>
|
||||
<template #emptyNode="{ node }">
|
||||
<HoppSmartPlaceholder
|
||||
v-if="node === null"
|
||||
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')"
|
||||
@@ -394,6 +420,11 @@ const props = defineProps({
|
||||
default: () => ({ type: "my-collections", selectedTeam: undefined }),
|
||||
required: true,
|
||||
},
|
||||
filterText: {
|
||||
type: String as PropType<string>,
|
||||
default: "",
|
||||
required: true,
|
||||
},
|
||||
teamCollectionList: {
|
||||
type: Array as PropType<TeamCollection[]>,
|
||||
default: () => [],
|
||||
@@ -436,6 +467,8 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const isShowingSearchResults = computed(() => props.filterText.length > 0)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: "add-request",
|
||||
@@ -543,6 +576,14 @@ const emit = defineEmits<{
|
||||
}
|
||||
}
|
||||
): 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
|
||||
@@ -555,6 +596,18 @@ const getPath = (path: string) => {
|
||||
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(
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
autocomplete="off"
|
||||
class="flex w-full bg-transparent px-4 py-2 h-8"
|
||||
:placeholder="t('action.search')"
|
||||
:disabled="collectionsType.type === 'team-collections'"
|
||||
/>
|
||||
</div>
|
||||
<CollectionsMyCollections
|
||||
@@ -58,8 +57,15 @@
|
||||
<CollectionsTeamCollections
|
||||
v-else
|
||||
:collections-type="collectionsType"
|
||||
:team-collection-list="teamCollectionList"
|
||||
:team-loading-collections="teamLoadingCollections"
|
||||
:team-collection-list="
|
||||
filterTexts.length > 0 ? teamsSearchResults : teamCollectionList
|
||||
"
|
||||
:team-loading-collections="
|
||||
filterTexts.length > 0
|
||||
? collectionsBeingLoadedFromSearch
|
||||
: teamLoadingCollections
|
||||
"
|
||||
:filter-text="filterTexts"
|
||||
:export-loading="exportLoading"
|
||||
:duplicate-loading="duplicateLoading"
|
||||
:save-request="saveRequest"
|
||||
@@ -87,6 +93,7 @@
|
||||
@expand-team-collection="expandTeamCollection"
|
||||
@display-modal-add="displayModalAdd(true)"
|
||||
@display-modal-import-export="displayModalImportExport(true)"
|
||||
@collection-click="handleCollectionClick"
|
||||
/>
|
||||
<div
|
||||
class="py-15 hidden flex-1 flex-col items-center justify-center bg-primaryDark px-4 text-secondaryLight"
|
||||
@@ -199,7 +206,7 @@ import {
|
||||
HoppRESTRequest,
|
||||
makeCollection,
|
||||
} from "@hoppscotch/data"
|
||||
import { cloneDeep, isEqual } from "lodash-es"
|
||||
import { cloneDeep, debounce, isEqual } from "lodash-es"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
createNewRootCollection,
|
||||
@@ -240,6 +247,7 @@ import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { useService } from "dioc/vue"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||
import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -336,6 +344,50 @@ const teamLoadingCollections = useReadonlyStream(
|
||||
[]
|
||||
)
|
||||
|
||||
const {
|
||||
cascadeParentCollectionForHeaderAuthForSearchResults,
|
||||
searchTeams,
|
||||
teamsSearchResults,
|
||||
teamsSearchResultsLoading,
|
||||
expandCollection,
|
||||
expandingCollections,
|
||||
} = useService(TeamSearchService)
|
||||
|
||||
watch(teamsSearchResults, (newSearchResults) => {
|
||||
if (newSearchResults.length === 1 && filterTexts.value.length > 0) {
|
||||
expandCollection(newSearchResults[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
const debouncedSearch = debounce(searchTeams, 400)
|
||||
|
||||
const collectionsBeingLoadedFromSearch = computed(() => {
|
||||
const collections = []
|
||||
|
||||
if (teamsSearchResultsLoading.value) {
|
||||
collections.push("root")
|
||||
}
|
||||
|
||||
collections.push(...expandingCollections.value)
|
||||
|
||||
return collections
|
||||
})
|
||||
|
||||
watch(
|
||||
filterTexts,
|
||||
(newFilterText) => {
|
||||
if (collectionsType.value.type === "team-collections") {
|
||||
const selectedTeamID = collectionsType.value.selectedTeam?.id
|
||||
|
||||
selectedTeamID &&
|
||||
debouncedSearch(newFilterText, selectedTeamID)?.catch((_) => {})
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => myTeams.value,
|
||||
(newTeams) => {
|
||||
@@ -364,7 +416,28 @@ const switchToMyCollections = () => {
|
||||
teamCollectionAdapter.changeTeamID(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* right now, for search results, we rely on collection click + isOpen to expand the collection
|
||||
*/
|
||||
const handleCollectionClick = (payload: {
|
||||
collectionID: string
|
||||
isOpen: boolean
|
||||
}) => {
|
||||
if (
|
||||
filterTexts.value.length > 0 &&
|
||||
teamsSearchResults.value.length &&
|
||||
payload.isOpen
|
||||
) {
|
||||
expandCollection(payload.collectionID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const expandTeamCollection = (collectionID: string) => {
|
||||
if (filterTexts.value.length > 0 && teamsSearchResults.value) {
|
||||
return
|
||||
}
|
||||
|
||||
teamCollectionAdapter.expandCollection(collectionID)
|
||||
}
|
||||
|
||||
@@ -1330,13 +1403,25 @@ const selectRequest = (selectedRequest: {
|
||||
let possibleTab = null
|
||||
|
||||
if (collectionsType.value.type === "team-collections") {
|
||||
const { auth, headers } =
|
||||
teamCollectionAdapter.cascadeParentCollectionForHeaderAuth(folderPath)
|
||||
let inheritedProperties: HoppInheritedProperty | undefined = undefined
|
||||
|
||||
possibleTab = tabs.getTabRefWithSaveContext({
|
||||
if (filterTexts.value.length > 0) {
|
||||
const collectionID = folderPath.split("/").at(-1)
|
||||
|
||||
if (!collectionID) return
|
||||
|
||||
inheritedProperties =
|
||||
cascadeParentCollectionForHeaderAuthForSearchResults(collectionID)
|
||||
} else {
|
||||
inheritedProperties =
|
||||
teamCollectionAdapter.cascadeParentCollectionForHeaderAuth(folderPath)
|
||||
}
|
||||
|
||||
const possibleTab = tabs.getTabRefWithSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: requestIndex,
|
||||
})
|
||||
|
||||
if (possibleTab) {
|
||||
tabs.setActiveTab(possibleTab.value.id)
|
||||
} else {
|
||||
@@ -1348,10 +1433,7 @@ const selectRequest = (selectedRequest: {
|
||||
requestID: requestIndex,
|
||||
collectionID: folderPath,
|
||||
},
|
||||
inheritedProperties: {
|
||||
auth,
|
||||
headers,
|
||||
},
|
||||
inheritedProperties: inheritedProperties,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
import { ref } from "vue"
|
||||
import { runGQLQuery } from "../backend/GQLClient"
|
||||
import {
|
||||
GetCollectionChildrenDocument,
|
||||
GetCollectionRequestsDocument,
|
||||
GetSingleCollectionDocument,
|
||||
GetSingleRequestDocument,
|
||||
} from "../backend/graphql"
|
||||
import { TeamCollection } from "./TeamCollection"
|
||||
import { HoppRESTAuth, HoppRESTHeader } from "@hoppscotch/data"
|
||||
|
||||
import * as E from "fp-ts/Either"
|
||||
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
|
||||
import { TeamRequest } from "./TeamRequest"
|
||||
import { Service } from "dioc"
|
||||
import axios from "axios"
|
||||
import { Ref } from "vue"
|
||||
|
||||
type CollectionSearchMeta = {
|
||||
isSearchResult?: boolean
|
||||
insertedWhileExpanding?: boolean
|
||||
}
|
||||
|
||||
type CollectionSearchNode =
|
||||
| {
|
||||
type: "request"
|
||||
title: string
|
||||
method: string
|
||||
id: string
|
||||
// parent collections
|
||||
path: CollectionSearchNode[]
|
||||
}
|
||||
| {
|
||||
type: "collection"
|
||||
title: string
|
||||
id: string
|
||||
// parent collections
|
||||
path: CollectionSearchNode[]
|
||||
}
|
||||
|
||||
type _SearchCollection = TeamCollection & {
|
||||
parentID: string | null
|
||||
meta?: CollectionSearchMeta
|
||||
}
|
||||
|
||||
type _SearchRequest = {
|
||||
id: string
|
||||
collectionID: string
|
||||
title: string
|
||||
request: {
|
||||
name: string
|
||||
method: string
|
||||
}
|
||||
meta?: CollectionSearchMeta
|
||||
}
|
||||
|
||||
function convertToTeamCollection(
|
||||
node: CollectionSearchNode & {
|
||||
meta?: CollectionSearchMeta
|
||||
},
|
||||
existingCollections: Record<string, _SearchCollection>,
|
||||
existingRequests: Record<string, _SearchRequest>
|
||||
) {
|
||||
if (node.type === "request") {
|
||||
existingRequests[node.id] = {
|
||||
id: node.id,
|
||||
collectionID: node.path[0].id,
|
||||
title: node.title,
|
||||
request: {
|
||||
name: node.title,
|
||||
method: node.method,
|
||||
},
|
||||
meta: {
|
||||
isSearchResult: node.meta?.isSearchResult || false,
|
||||
},
|
||||
}
|
||||
|
||||
if (node.path[0]) {
|
||||
// add parent collections to the collections array recursively
|
||||
convertToTeamCollection(
|
||||
node.path[0],
|
||||
existingCollections,
|
||||
existingRequests
|
||||
)
|
||||
}
|
||||
} else {
|
||||
existingCollections[node.id] = {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
children: [],
|
||||
requests: [],
|
||||
data: null,
|
||||
parentID: node.path[0]?.id,
|
||||
meta: {
|
||||
isSearchResult: node.meta?.isSearchResult || false,
|
||||
},
|
||||
}
|
||||
|
||||
if (node.path[0]) {
|
||||
// add parent collections to the collections array recursively
|
||||
convertToTeamCollection(
|
||||
node.path[0],
|
||||
existingCollections,
|
||||
existingRequests
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
existingCollections,
|
||||
existingRequests,
|
||||
}
|
||||
}
|
||||
|
||||
function convertToTeamTree(
|
||||
collections: (TeamCollection & { parentID: string | null })[],
|
||||
requests: TeamRequest[]
|
||||
) {
|
||||
const collectionTree: TeamCollection[] = []
|
||||
|
||||
collections.forEach((collection) => {
|
||||
const parentCollection = collection.parentID
|
||||
? collections.find((c) => c.id === collection.parentID)
|
||||
: null
|
||||
|
||||
const isAlreadyInserted = parentCollection?.children?.find(
|
||||
(c) => c.id === collection.id
|
||||
)
|
||||
|
||||
if (isAlreadyInserted) return
|
||||
|
||||
if (parentCollection) {
|
||||
parentCollection.children = parentCollection.children || []
|
||||
parentCollection.children.push(collection)
|
||||
} else {
|
||||
collectionTree.push(collection)
|
||||
}
|
||||
})
|
||||
|
||||
requests.forEach((request) => {
|
||||
const parentCollection = collections.find(
|
||||
(c) => c.id === request.collectionID
|
||||
)
|
||||
|
||||
const isAlreadyInserted = parentCollection?.requests?.find(
|
||||
(r) => r.id === request.id
|
||||
)
|
||||
|
||||
if (isAlreadyInserted) return
|
||||
|
||||
if (parentCollection) {
|
||||
parentCollection.requests = parentCollection.requests || []
|
||||
parentCollection.requests.push({
|
||||
id: request.id,
|
||||
collectionID: request.collectionID,
|
||||
title: request.title,
|
||||
request: request.request,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return collectionTree
|
||||
}
|
||||
|
||||
export class TeamSearchService extends Service {
|
||||
public static readonly ID = "TeamSearchService"
|
||||
|
||||
public endpoint = import.meta.env.VITE_BACKEND_API_URL
|
||||
|
||||
public teamsSearchResultsLoading = ref(false)
|
||||
public teamsSearchResults = ref<TeamCollection[]>([])
|
||||
public teamsSearchResultsFormattedForSpotlight = ref<
|
||||
{
|
||||
collectionTitles: string[]
|
||||
request: {
|
||||
id: string
|
||||
name: string
|
||||
method: string
|
||||
}
|
||||
}[]
|
||||
>([])
|
||||
|
||||
searchResultsCollections: Record<string, _SearchCollection> = {}
|
||||
searchResultsRequests: Record<string, _SearchRequest> = {}
|
||||
|
||||
expandingCollections: Ref<string[]> = ref([])
|
||||
expandedCollections: Ref<string[]> = ref([])
|
||||
|
||||
// FUTURE-TODO: ideally this should return the search results / formatted results instead of directly manipulating the result set
|
||||
// eg: do the spotlight formatting in the spotlight searcher and not here
|
||||
searchTeams = async (query: string, teamID: string) => {
|
||||
if (!query.length) {
|
||||
return
|
||||
}
|
||||
|
||||
this.teamsSearchResultsLoading.value = true
|
||||
|
||||
this.searchResultsCollections = {}
|
||||
this.searchResultsRequests = {}
|
||||
this.expandedCollections.value = []
|
||||
|
||||
try {
|
||||
const searchResponse = await axios.get(
|
||||
`${
|
||||
this.endpoint
|
||||
}/team-collection/search/${teamID}?searchQuery=${encodeURIComponent(
|
||||
query
|
||||
)}}`,
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
)
|
||||
|
||||
if (searchResponse.status !== 200) {
|
||||
return
|
||||
}
|
||||
|
||||
const searchResults = searchResponse.data.data as CollectionSearchNode[]
|
||||
|
||||
searchResults
|
||||
.map((node) => {
|
||||
const { existingCollections, existingRequests } =
|
||||
convertToTeamCollection(
|
||||
{
|
||||
...node,
|
||||
meta: {
|
||||
isSearchResult: true,
|
||||
},
|
||||
},
|
||||
{},
|
||||
{}
|
||||
)
|
||||
|
||||
return {
|
||||
collections: existingCollections,
|
||||
requests: existingRequests,
|
||||
}
|
||||
})
|
||||
.forEach(({ collections, requests }) => {
|
||||
this.searchResultsCollections = {
|
||||
...this.searchResultsCollections,
|
||||
...collections,
|
||||
}
|
||||
this.searchResultsRequests = {
|
||||
...this.searchResultsRequests,
|
||||
...requests,
|
||||
}
|
||||
})
|
||||
|
||||
const collectionFetchingPromises = Object.values(
|
||||
this.searchResultsCollections
|
||||
).map((col) => {
|
||||
return getSingleCollection(col.id)
|
||||
})
|
||||
|
||||
const requestFetchingPromises = Object.values(
|
||||
this.searchResultsRequests
|
||||
).map((req) => {
|
||||
return getSingleRequest(req.id)
|
||||
})
|
||||
|
||||
const collectionResponses = await Promise.all(collectionFetchingPromises)
|
||||
const requestResponses = await Promise.all(requestFetchingPromises)
|
||||
|
||||
requestResponses.map((res) => {
|
||||
if (E.isLeft(res)) {
|
||||
return
|
||||
}
|
||||
|
||||
const request = res.right.request
|
||||
|
||||
if (!request) return
|
||||
|
||||
this.searchResultsRequests[request.id] = {
|
||||
id: request.id,
|
||||
title: request.title,
|
||||
request: JSON.parse(request.request) as TeamRequest["request"],
|
||||
collectionID: request.collectionID,
|
||||
}
|
||||
})
|
||||
|
||||
collectionResponses.map((res) => {
|
||||
if (E.isLeft(res)) {
|
||||
return
|
||||
}
|
||||
|
||||
const collection = res.right.collection
|
||||
|
||||
if (!collection) return
|
||||
|
||||
this.searchResultsCollections[collection.id].data =
|
||||
collection.data ?? null
|
||||
})
|
||||
|
||||
const collectionTree = convertToTeamTree(
|
||||
Object.values(this.searchResultsCollections),
|
||||
// asserting because we've already added the missing properties after fetching the full details
|
||||
Object.values(this.searchResultsRequests) as TeamRequest[]
|
||||
)
|
||||
|
||||
this.teamsSearchResults.value = collectionTree
|
||||
|
||||
this.teamsSearchResultsFormattedForSpotlight.value = Object.values(
|
||||
this.searchResultsRequests
|
||||
).map((request) => {
|
||||
return formatTeamsSearchResultsForSpotlight(
|
||||
{
|
||||
collectionID: request.collectionID,
|
||||
name: request.title,
|
||||
method: request.request.method,
|
||||
id: request.id,
|
||||
},
|
||||
Object.values(this.searchResultsCollections)
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
this.teamsSearchResultsLoading.value = false
|
||||
}
|
||||
|
||||
cascadeParentCollectionForHeaderAuthForSearchResults = (
|
||||
collectionID: string
|
||||
): HoppInheritedProperty => {
|
||||
const defaultInheritedAuth: HoppInheritedProperty["auth"] = {
|
||||
parentID: "",
|
||||
parentName: "",
|
||||
inheritedAuth: {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
},
|
||||
}
|
||||
|
||||
const defaultInheritedHeaders: HoppInheritedProperty["headers"] = []
|
||||
|
||||
const collection = Object.values(this.searchResultsCollections).find(
|
||||
(col) => col.id === collectionID
|
||||
)
|
||||
|
||||
if (!collection)
|
||||
return { auth: defaultInheritedAuth, headers: defaultInheritedHeaders }
|
||||
|
||||
const inheritedAuthData = this.findInheritableParentAuth(collectionID)
|
||||
const inheritedHeadersData = this.findInheritableParentHeaders(collectionID)
|
||||
|
||||
return {
|
||||
auth: E.isRight(inheritedAuthData)
|
||||
? inheritedAuthData.right
|
||||
: defaultInheritedAuth,
|
||||
headers: E.isRight(inheritedHeadersData)
|
||||
? Object.values(inheritedHeadersData.right)
|
||||
: defaultInheritedHeaders,
|
||||
}
|
||||
}
|
||||
|
||||
findInheritableParentAuth = (
|
||||
collectionID: string
|
||||
): E.Either<
|
||||
string,
|
||||
{
|
||||
parentID: string
|
||||
parentName: string
|
||||
inheritedAuth: HoppRESTAuth
|
||||
}
|
||||
> => {
|
||||
const collection = Object.values(this.searchResultsCollections).find(
|
||||
(col) => col.id === collectionID
|
||||
)
|
||||
|
||||
if (!collection) {
|
||||
return E.left("PARENT_NOT_FOUND" as const)
|
||||
}
|
||||
|
||||
// has inherited data
|
||||
if (collection.data) {
|
||||
const parentInheritedData = JSON.parse(collection.data) as {
|
||||
auth: HoppRESTAuth
|
||||
headers: HoppRESTHeader[]
|
||||
}
|
||||
|
||||
const inheritedAuth = parentInheritedData.auth
|
||||
|
||||
if (inheritedAuth.authType !== "inherit") {
|
||||
return E.right({
|
||||
parentID: collectionID,
|
||||
parentName: collection.title,
|
||||
inheritedAuth: inheritedAuth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!collection.parentID) {
|
||||
return E.left("PARENT_INHERITED_DATA_NOT_FOUND")
|
||||
}
|
||||
|
||||
return this.findInheritableParentAuth(collection.parentID)
|
||||
}
|
||||
|
||||
findInheritableParentHeaders = (
|
||||
collectionID: string,
|
||||
existingHeaders: Record<
|
||||
string,
|
||||
HoppInheritedProperty["headers"][number]
|
||||
> = {}
|
||||
): E.Either<
|
||||
string,
|
||||
Record<string, HoppInheritedProperty["headers"][number]>
|
||||
> => {
|
||||
const collection = Object.values(this.searchResultsCollections).find(
|
||||
(col) => col.id === collectionID
|
||||
)
|
||||
|
||||
if (!collection) {
|
||||
return E.left("PARENT_NOT_FOUND" as const)
|
||||
}
|
||||
|
||||
// see if it has headers to inherit, if yes, add it to the existing headers
|
||||
if (collection.data) {
|
||||
const parentInheritedData = JSON.parse(collection.data) as {
|
||||
auth: HoppRESTAuth
|
||||
headers: HoppRESTHeader[]
|
||||
}
|
||||
|
||||
const inheritedHeaders = parentInheritedData.headers
|
||||
|
||||
if (inheritedHeaders) {
|
||||
inheritedHeaders.forEach((header) => {
|
||||
if (!existingHeaders[header.key]) {
|
||||
existingHeaders[header.key] = {
|
||||
parentID: collection.id,
|
||||
parentName: collection.title,
|
||||
inheritedHeader: header,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (collection.parentID) {
|
||||
return this.findInheritableParentHeaders(
|
||||
collection.parentID,
|
||||
existingHeaders
|
||||
)
|
||||
}
|
||||
|
||||
return E.right(existingHeaders)
|
||||
}
|
||||
|
||||
expandCollection = async (collectionID: string) => {
|
||||
if (this.expandingCollections.value.includes(collectionID)) return
|
||||
|
||||
const collectionToExpand = Object.values(
|
||||
this.searchResultsCollections
|
||||
).find((col) => col.id === collectionID)
|
||||
|
||||
const isAlreadyExpanded =
|
||||
this.expandedCollections.value.includes(collectionID)
|
||||
|
||||
// only allow search result collections to be expanded
|
||||
if (
|
||||
isAlreadyExpanded ||
|
||||
!collectionToExpand ||
|
||||
!(
|
||||
collectionToExpand.meta?.isSearchResult ||
|
||||
collectionToExpand.meta?.insertedWhileExpanding
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
this.expandingCollections.value.push(collectionID)
|
||||
|
||||
const childCollectionsPromise = getCollectionChildCollections(collectionID)
|
||||
const childRequestsPromise = getCollectionChildRequests(collectionID)
|
||||
|
||||
const [childCollections, childRequests] = await Promise.all([
|
||||
childCollectionsPromise,
|
||||
childRequestsPromise,
|
||||
])
|
||||
|
||||
if (E.isLeft(childCollections)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (E.isLeft(childRequests)) {
|
||||
return
|
||||
}
|
||||
|
||||
childCollections.right.collection?.children
|
||||
.map((child) => ({
|
||||
id: child.id,
|
||||
title: child.title,
|
||||
data: child.data ?? null,
|
||||
children: [],
|
||||
requests: [],
|
||||
}))
|
||||
.forEach((child) => {
|
||||
this.searchResultsCollections[child.id] = {
|
||||
...child,
|
||||
parentID: collectionID,
|
||||
meta: {
|
||||
isSearchResult: false,
|
||||
insertedWhileExpanding: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
childRequests.right.requestsInCollection
|
||||
.map((request) => ({
|
||||
id: request.id,
|
||||
collectionID: collectionID,
|
||||
title: request.title,
|
||||
request: JSON.parse(request.request) as TeamRequest["request"],
|
||||
}))
|
||||
.forEach((request) => {
|
||||
this.searchResultsRequests[request.id] = {
|
||||
...request,
|
||||
meta: {
|
||||
isSearchResult: false,
|
||||
insertedWhileExpanding: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
this.teamsSearchResults.value = convertToTeamTree(
|
||||
Object.values(this.searchResultsCollections),
|
||||
// asserting because we've already added the missing properties after fetching the full details
|
||||
Object.values(this.searchResultsRequests) as TeamRequest[]
|
||||
)
|
||||
|
||||
// remove the collection after expanding
|
||||
this.expandingCollections.value = this.expandingCollections.value.filter(
|
||||
(colID) => colID !== collectionID
|
||||
)
|
||||
|
||||
this.expandedCollections.value.push(collectionID)
|
||||
}
|
||||
}
|
||||
|
||||
const getSingleCollection = (collectionID: string) =>
|
||||
runGQLQuery({
|
||||
query: GetSingleCollectionDocument,
|
||||
variables: {
|
||||
collectionID,
|
||||
},
|
||||
})
|
||||
|
||||
const getSingleRequest = (requestID: string) =>
|
||||
runGQLQuery({
|
||||
query: GetSingleRequestDocument,
|
||||
variables: {
|
||||
requestID,
|
||||
},
|
||||
})
|
||||
|
||||
const getCollectionChildCollections = (collectionID: string) =>
|
||||
runGQLQuery({
|
||||
query: GetCollectionChildrenDocument,
|
||||
variables: {
|
||||
collectionID,
|
||||
},
|
||||
})
|
||||
|
||||
const getCollectionChildRequests = (collectionID: string) =>
|
||||
runGQLQuery({
|
||||
query: GetCollectionRequestsDocument,
|
||||
variables: {
|
||||
collectionID,
|
||||
},
|
||||
})
|
||||
|
||||
const formatTeamsSearchResultsForSpotlight = (
|
||||
request: {
|
||||
collectionID: string
|
||||
name: string
|
||||
method: string
|
||||
id: string
|
||||
},
|
||||
parentCollections: (TeamCollection & { parentID: string | null })[]
|
||||
) => {
|
||||
let collectionTitles: string[] = []
|
||||
|
||||
let parentCollectionID: string | null = request.collectionID
|
||||
|
||||
while (true) {
|
||||
if (!parentCollectionID) {
|
||||
break
|
||||
}
|
||||
|
||||
const parentCollection = parentCollections.find(
|
||||
(col) => col.id === parentCollectionID
|
||||
)
|
||||
|
||||
if (!parentCollection) {
|
||||
break
|
||||
}
|
||||
|
||||
collectionTitles = [parentCollection.title, ...collectionTitles]
|
||||
parentCollectionID = parentCollection.parentID
|
||||
}
|
||||
|
||||
return {
|
||||
collectionTitles,
|
||||
request: {
|
||||
name: request.name,
|
||||
method: request.method,
|
||||
id: request.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Service } from "dioc"
|
||||
import {
|
||||
SpotlightSearcher,
|
||||
SpotlightSearcherResult,
|
||||
SpotlightSearcherSessionState,
|
||||
SpotlightService,
|
||||
} from ".."
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
import { Ref, computed, effectScope, markRaw, watch } from "vue"
|
||||
import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service"
|
||||
import { cloneDeep, debounce } from "lodash-es"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import RESTTeamRequestEntry from "~/components/app/spotlight/entry/RESTTeamRequestEntry.vue"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||
|
||||
export class TeamsSpotlightSearcherService
|
||||
extends Service
|
||||
implements SpotlightSearcher
|
||||
{
|
||||
public static readonly ID = "TEAMS_SPOTLIGHT_SEARCHER_SERVICE"
|
||||
|
||||
private t = getI18n()
|
||||
|
||||
public searcherID = "teams"
|
||||
public searcherSectionTitle = this.t("team.search_title")
|
||||
|
||||
private readonly spotlight = this.bind(SpotlightService)
|
||||
|
||||
private readonly teamsSearch = this.bind(TeamSearchService)
|
||||
|
||||
private readonly workspaceService = this.bind(WorkspaceService)
|
||||
|
||||
private readonly tabs = this.bind(RESTTabService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
createSearchSession(
|
||||
query: Readonly<Ref<string>>
|
||||
): [Ref<SpotlightSearcherSessionState>, () => void] {
|
||||
const isTeamWorkspace = computed(
|
||||
() => this.workspaceService.currentWorkspace.value.type === "team"
|
||||
)
|
||||
|
||||
const scopeHandle = effectScope()
|
||||
|
||||
scopeHandle.run(() => {
|
||||
const debouncedSearch = debounce(this.teamsSearch.searchTeams, 400)
|
||||
|
||||
watch(
|
||||
query,
|
||||
(query) => {
|
||||
if (this.workspaceService.currentWorkspace.value.type === "team") {
|
||||
const teamID = this.workspaceService.currentWorkspace.value.teamID
|
||||
debouncedSearch(query, teamID)?.catch((_) => {})
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const onSessionEnd = () => {
|
||||
scopeHandle.stop()
|
||||
}
|
||||
|
||||
const resultObj = computed<SpotlightSearcherSessionState>(() => {
|
||||
return isTeamWorkspace.value
|
||||
? {
|
||||
loading: this.teamsSearch.teamsSearchResultsLoading.value,
|
||||
results:
|
||||
this.teamsSearch.teamsSearchResultsFormattedForSpotlight.value.map(
|
||||
(result) => ({
|
||||
id: result.request.id,
|
||||
icon: markRaw(IconFolder),
|
||||
score: 1, // make a better scoring system for this
|
||||
text: {
|
||||
type: "custom",
|
||||
component: markRaw(RESTTeamRequestEntry),
|
||||
componentProps: {
|
||||
collectionTitles: result.collectionTitles,
|
||||
request: result.request,
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
}
|
||||
: {
|
||||
loading: false,
|
||||
results: [],
|
||||
}
|
||||
})
|
||||
|
||||
return [resultObj, onSessionEnd]
|
||||
}
|
||||
|
||||
onResultSelect(result: SpotlightSearcherResult): void {
|
||||
let inheritedProperties: HoppInheritedProperty | undefined = undefined
|
||||
|
||||
const selectedRequest = this.teamsSearch.searchResultsRequests[result.id]
|
||||
|
||||
if (!selectedRequest) return
|
||||
|
||||
const collectionID = result.id
|
||||
|
||||
if (!collectionID) return
|
||||
|
||||
inheritedProperties =
|
||||
this.teamsSearch.cascadeParentCollectionForHeaderAuthForSearchResults(
|
||||
collectionID
|
||||
)
|
||||
|
||||
const possibleTab = this.tabs.getTabRefWithSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: result.id,
|
||||
})
|
||||
|
||||
if (possibleTab) {
|
||||
this.tabs.setActiveTab(possibleTab.value.id)
|
||||
} else {
|
||||
this.tabs.createNewTab({
|
||||
request: cloneDeep(selectedRequest.request as HoppRESTRequest),
|
||||
isDirty: false,
|
||||
saveContext: {
|
||||
originLocation: "team-collection",
|
||||
requestID: selectedRequest.id,
|
||||
collectionID: selectedRequest.collectionID,
|
||||
},
|
||||
inheritedProperties: inheritedProperties,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user