feat: rest revamp (#2918)

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Anwarul Islam
2023-03-31 01:15:42 +06:00
committed by GitHub
parent dbb45e7253
commit defece95fc
63 changed files with 2262 additions and 1924 deletions

View File

@@ -1,6 +1,6 @@
import { FormDataKeyValue, HoppRESTRequest } from "@hoppscotch/data"
import { getDefaultRESTRequest } from "./rest/default"
import { isJSONContentType } from "./utils/contenttypes"
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
/**
* Handles translations for all the hopp.io REST Shareable URL params

View File

@@ -0,0 +1,293 @@
import {
FormDataKeyValue,
HoppRESTAuth,
HoppRESTHeader,
HoppRESTParam,
HoppRESTReqBody,
HoppRESTRequest,
RESTReqSchemaVersion,
ValidContentTypes,
} from "@hoppscotch/data"
import { BehaviorSubject, combineLatest, map } from "rxjs"
import { applyBodyTransition } from "~/helpers/rules/BodyTransition"
import { HoppRESTResponse } from "./types/HoppRESTResponse"
export class RESTRequest {
public v$ = new BehaviorSubject<typeof RESTReqSchemaVersion>(
RESTReqSchemaVersion
)
public name$ = new BehaviorSubject("Untitled")
public endpoint$ = new BehaviorSubject("https://echo.hoppscotch.io/")
public params$ = new BehaviorSubject<HoppRESTParam[]>([])
public headers$ = new BehaviorSubject<HoppRESTHeader[]>([])
public method$ = new BehaviorSubject("GET")
public auth$ = new BehaviorSubject<HoppRESTAuth>({
authType: "none",
authActive: true,
})
public preRequestScript$ = new BehaviorSubject("")
public testScript$ = new BehaviorSubject("")
public body$ = new BehaviorSubject<HoppRESTReqBody>({
contentType: null,
body: null,
})
public response$ = new BehaviorSubject<HoppRESTResponse | null>(null)
get request$() {
// any of above changes construct requests
return combineLatest([
this.v$,
this.name$,
this.endpoint$,
this.params$,
this.headers$,
this.method$,
this.auth$,
this.preRequestScript$,
this.testScript$,
this.body$,
]).pipe(
map(
([
v,
name,
endpoint,
params,
headers,
method,
auth,
preRequestScript,
testScript,
body,
]) => ({
v,
name,
endpoint,
params,
headers,
method,
auth,
preRequestScript,
testScript,
body,
})
)
)
}
get contentType$() {
return this.body$.pipe(map((body) => body.contentType))
}
get bodyContent$() {
return this.body$.pipe(map((body) => body.body))
}
get headersCount$() {
return this.headers$.pipe(
map(
(params) =>
params.filter((x) => x.active && (x.key !== "" || x.value !== ""))
.length
)
)
}
get paramsCount$() {
return this.params$.pipe(
map(
(params) =>
params.filter((x) => x.active && (x.key !== "" || x.value !== ""))
.length
)
)
}
setName(name: string) {
this.name$.next(name)
}
setEndpoint(newURL: string) {
this.endpoint$.next(newURL)
}
setMethod(newMethod: string) {
this.method$.next(newMethod)
}
setParams(entries: HoppRESTParam[]) {
this.params$.next(entries)
}
addParam(newParam: HoppRESTParam) {
const newParams = this.params$.value.concat(newParam)
this.params$.next(newParams)
}
updateParam(index: number, updatedParam: HoppRESTParam) {
const newParams = this.params$.value.map((param, i) =>
i === index ? updatedParam : param
)
this.params$.next(newParams)
}
deleteParam(index: number) {
const newParams = this.params$.value.filter((_, i) => i !== index)
this.params$.next(newParams)
}
deleteAllParams() {
this.params$.next([])
}
setHeaders(entries: HoppRESTHeader[]) {
this.headers$.next(entries)
}
addHeader(newHeader: HoppRESTHeader) {
const newHeaders = this.headers$.value.concat(newHeader)
this.headers$.next(newHeaders)
}
updateHeader(index: number, updatedHeader: HoppRESTHeader) {
const newHeaders = this.headers$.value.map((header, i) =>
i === index ? updatedHeader : header
)
this.headers$.next(newHeaders)
}
deleteHeader(index: number) {
const newHeaders = this.headers$.value.filter((_, i) => i !== index)
this.headers$.next(newHeaders)
}
deleteAllHeaders() {
this.headers$.next([])
}
setContentType(newContentType: ValidContentTypes | null) {
// TODO: persist body evenafter switching content typees
this.body$.next(applyBodyTransition(this.body$.value, newContentType))
}
setBody(newBody: string | FormDataKeyValue[] | null) {
const body = { ...this.body$.value }
body.body = newBody
this.body$.next({ ...body })
}
addFormDataEntry(entry: FormDataKeyValue) {
if (this.body$.value.contentType !== "multipart/form-data") return {}
const body: HoppRESTReqBody = {
contentType: "multipart/form-data",
body: [...this.body$.value.body, entry],
}
this.body$.next(body)
}
deleteFormDataEntry(index: number) {
// Only perform update if the current content-type is formdata
if (this.body$.value.contentType !== "multipart/form-data") return {}
const body: HoppRESTReqBody = {
contentType: "multipart/form-data",
body: [...this.body$.value.body.filter((_, i) => i !== index)],
}
this.body$.next(body)
}
updateFormDataEntry(index: number, entry: FormDataKeyValue) {
// Only perform update if the current content-type is formdata
if (this.body$.value.contentType !== "multipart/form-data") return {}
const body: HoppRESTReqBody = {
contentType: "multipart/form-data",
body: [
...this.body$.value.body.map((oldEntry, i) =>
i === index ? entry : oldEntry
),
],
}
this.body$.next(body)
}
deleteAllFormDataEntries() {
// Only perform update if the current content-type is formdata
if (this.body$.value.contentType !== "multipart/form-data") return {}
const body: HoppRESTReqBody = {
contentType: "multipart/form-data",
body: [],
}
this.body$.next(body)
}
setRequestBody(newBody: HoppRESTReqBody) {
this.body$.next(newBody)
}
setAuth(newAuth: HoppRESTAuth) {
this.auth$.next(newAuth)
}
setPreRequestScript(newScript: string) {
this.preRequestScript$.next(newScript)
}
setTestScript(newScript: string) {
this.testScript$.next(newScript)
}
updateResponse(response: HoppRESTResponse | null) {
this.response$.next(response)
}
setRequest(request: HoppRESTRequest) {
this.v$.next(RESTReqSchemaVersion)
this.name$.next(request.name)
this.endpoint$.next(request.endpoint)
this.params$.next(request.params)
this.headers$.next(request.headers)
this.method$.next(request.method)
this.auth$.next(request.auth)
this.preRequestScript$.next(request.preRequestScript)
this.testScript$.next(request.testScript)
this.body$.next(request.body)
}
getRequest() {
return {
v: this.v$.value,
name: this.name$.value,
endpoint: this.endpoint$.value,
params: this.params$.value,
headers: this.headers$.value,
method: this.method$.value,
auth: this.auth$.value,
preRequestScript: this.preRequestScript$.value,
testScript: this.testScript$.value,
body: this.body$.value,
}
}
resetRequest() {
this.v$.next(RESTReqSchemaVersion)
this.name$.next("")
this.endpoint$.next("")
this.params$.next([])
this.headers$.next([])
this.method$.next("GET")
this.auth$.next({
authType: "none",
authActive: false,
})
this.preRequestScript$.next("")
this.testScript$.next("")
this.body$.next({
contentType: null,
body: null,
})
}
}

View File

@@ -1,6 +1,6 @@
import { Observable } from "rxjs"
import { Observable, Subject } from "rxjs"
import { filter } from "rxjs/operators"
import { chain, right, TaskEither } from "fp-ts/lib/TaskEither"
import * as TE from "fp-ts/lib/TaskEither"
import { flow, pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
@@ -22,7 +22,6 @@ import { createRESTNetworkRequestStream } from "./network"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { isJSONContentType } from "./utils/contenttypes"
import { updateTeamEnvironment } from "./backend/mutations/TeamEnvironment"
import { getRESTRequest, setRESTTestResults } from "~/newstore/RESTSession"
import {
environmentsStore,
getCurrentEnvironment,
@@ -31,6 +30,8 @@ import {
setGlobalEnvVariables,
updateEnvironment,
} from "~/newstore/environments"
import { HoppRESTTab } from "./rest/tab"
import { Ref } from "vue"
const getTestableBody = (
res: HoppRESTResponse & { type: "success" | "fail" }
@@ -64,20 +65,26 @@ const combineEnvVariables = (env: {
selected: Environment["variables"]
}) => [...env.selected, ...env.global]
export const runRESTRequest$ = (): TaskEither<
string | Error,
Observable<HoppRESTResponse>
> =>
export const executedResponses$ = new Subject<
HoppRESTResponse & { type: "success" | "fail " }
>()
export const runRESTRequest$ = (
tab: Ref<HoppRESTTab>
): TE.TaskEither<string | Error, Observable<HoppRESTResponse>> =>
pipe(
getFinalEnvsFromPreRequest(
getRESTRequest().preRequestScript,
tab.value.document.request.preRequestScript,
getCombinedEnvVariables()
),
chain((envs) => {
const effectiveRequest = getEffectiveRESTRequest(getRESTRequest(), {
name: "Env",
variables: combineEnvVariables(envs),
})
TE.chain((envs) => {
const effectiveRequest = getEffectiveRESTRequest(
tab.value.document.request,
{
name: "Env",
variables: combineEnvVariables(envs),
}
)
const stream = createRESTNetworkRequestStream(effectiveRequest)
@@ -86,6 +93,11 @@ export const runRESTRequest$ = (): TaskEither<
.pipe(filter((res) => res.type === "success" || res.type === "fail"))
.subscribe(async (res) => {
if (res.type === "success" || res.type === "fail") {
executedResponses$.next(
// @ts-expect-error Typescript can't figure out this inference for some reason
res
)
const runResult = await runTestScript(res.req.testScript, envs, {
status: res.statusCode,
body: getTestableBody(res),
@@ -93,7 +105,9 @@ export const runRESTRequest$ = (): TaskEither<
})()
if (isRight(runResult)) {
setRESTTestResults(translateToSandboxTestResults(runResult.right))
tab.value.testResults = translateToSandboxTestResults(
runResult.right
)
setGlobalEnvVariables(runResult.right.envs.global)
@@ -128,7 +142,7 @@ export const runRESTRequest$ = (): TaskEither<
)()
}
} else {
setRESTTestResults({
tab.value.testResults = {
description: "",
expectResults: [],
tests: [],
@@ -145,14 +159,14 @@ export const runRESTRequest$ = (): TaskEither<
},
},
scriptError: true,
})
}
}
subscription.unsubscribe()
}
})
return right(stream)
return TE.right(stream)
})
)

View File

@@ -0,0 +1,21 @@
/**
* Get the indexes that are affected by the reorder
* @param from index of the item before reorder
* @param to index of the item after reorder
* @returns Map of from to to
*/
export function getAffectedIndexes(from: number, to: number) {
const indexes = new Map<number, number>()
indexes.set(from, to)
if (from < to) {
for (let i = from + 1; i <= to; i++) {
indexes.set(i, i - 1)
}
} else {
for (let i = from - 1; i >= to; i--) {
indexes.set(i, i + 1)
}
}
return indexes
}

View File

@@ -0,0 +1,141 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { getTabsRefTo } from "../rest/tab"
import { getAffectedIndexes } from "./affectedIndex"
/**
* Resolve save context on reorder
* @param payload
* @param payload.lastIndex
* @param payload.newIndex
* @param folderPath
* @param payload.length
* @returns
*/
export function resolveSaveContextOnCollectionReorder(
payload: {
lastIndex: number
newIndex: number
folderPath: string
length?: number // better way to do this? now it could be undefined
},
type: "remove" | "drop" = "remove"
) {
const { lastIndex, folderPath, length } = payload
let { newIndex } = payload
if (newIndex > lastIndex) newIndex-- // there is a issue when going down? better way to resolve this?
if (lastIndex === newIndex) return
const affectedIndexes = getAffectedIndexes(
lastIndex,
newIndex === -1 ? length! : newIndex
)
if (newIndex === -1) {
// if (newIndex === -1) remove it from the map because it will be deleted
affectedIndexes.delete(lastIndex)
// when collection deleted opended requests from that collection be affected
if (type === "remove") {
resetSaveContextForAffectedRequests(
folderPath ? `${folderPath}/${lastIndex}` : lastIndex.toString()
)
}
}
// add folder path as prefix to the affected indexes
const affectedPaths = new Map<string, string>()
for (const [key, value] of affectedIndexes) {
if (folderPath) {
affectedPaths.set(`${folderPath}/${key}`, `${folderPath}/${value}`)
} else {
affectedPaths.set(key.toString(), value.toString())
}
}
const tabs = getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
affectedPaths.has(tab.document.saveContext.folderPath)
)
})
for (const tab of tabs) {
if (tab.value.document.saveContext?.originLocation === "user-collection") {
const newPath = affectedPaths.get(
tab.value.document.saveContext?.folderPath
)!
tab.value.document.saveContext.folderPath = newPath
}
}
}
/**
* Resolve save context for affected requests on drop folder from one to another
* @param oldFolderPath
* @param newFolderPath
* @returns
*/
export function updateSaveContextForAffectedRequests(
oldFolderPath: string,
newFolderPath: string
) {
const tabs = getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(oldFolderPath)
)
})
for (const tab of tabs) {
if (tab.value.document.saveContext?.originLocation === "user-collection") {
tab.value.document.saveContext = {
...tab.value.document.saveContext,
folderPath: tab.value.document.saveContext.folderPath.replace(
oldFolderPath,
newFolderPath
),
}
}
}
}
function resetSaveContextForAffectedRequests(folderPath: string) {
const tabs = getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(folderPath)
)
})
for (const tab of tabs) {
tab.value.document.saveContext = null
tab.value.document.isDirty = true
}
}
export function getFoldersByPath(
collections: HoppCollection<HoppRESTRequest>[],
path: string
): HoppCollection<HoppRESTRequest>[] {
if (!path) return collections
// path will be like this "0/0/1" these are the indexes of the folders
const pathArray = path.split("/").map((index) => parseInt(index))
console.log(pathArray, collections[pathArray[0]])
let currentCollection = collections[pathArray[0]]
if (pathArray.length === 1) {
return currentCollection.folders
} else {
for (let i = 1; i < pathArray.length; i++) {
const folder = currentCollection.folders[pathArray[i]]
if (folder) currentCollection = folder
}
}
return currentCollection.folders
}

View File

@@ -0,0 +1,72 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { getTabsRefTo } from "../rest/tab"
import { getAffectedIndexes } from "./affectedIndex"
/**
* Resolve save context on reorder
* @param payload
* @param payload.lastIndex
* @param payload.newIndex
* @param payload.folderPath
* @param payload.length
* @returns
*/
export function resolveSaveContextOnRequestReorder(payload: {
lastIndex: number
folderPath: string
newIndex: number
length?: number // better way to do this? now it could be undefined
}) {
const { lastIndex, folderPath, length } = payload
let { newIndex } = payload
if (newIndex > lastIndex) newIndex-- // there is a issue when going down? better way to resolve this?
if (lastIndex === newIndex) return
const affectedIndexes = getAffectedIndexes(
lastIndex,
newIndex === -1 ? length! : newIndex
)
// if (newIndex === -1) remove it from the map because it will be deleted
if (newIndex === -1) affectedIndexes.delete(lastIndex)
const tabs = getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath === folderPath &&
affectedIndexes.has(tab.document.saveContext.requestIndex)
)
})
for (const tab of tabs) {
if (tab.value.document.saveContext?.originLocation === "user-collection") {
const newIndex = affectedIndexes.get(
tab.value.document.saveContext?.requestIndex
)!
tab.value.document.saveContext.requestIndex = newIndex
}
}
}
export function getRequestsByPath(
collections: HoppCollection<HoppRESTRequest>[],
path: string
): HoppRESTRequest[] {
// path will be like this "0/0/1" these are the indexes of the folders
const pathArray = path.split("/").map((index) => parseInt(index))
let currentCollection = collections[pathArray[0]]
if (pathArray.length === 1) {
return currentCollection.requests
} else {
for (let i = 1; i < pathArray.length; i++) {
const folder = currentCollection.folders[pathArray[i]]
if (folder) currentCollection = folder
}
}
return currentCollection.requests
}

View File

@@ -20,7 +20,7 @@ import { getMethod } from "./sub_helpers/method"
import { concatParams, getURLObject } from "./sub_helpers/url"
import { preProcessCurlCommand } from "./sub_helpers/preproc"
import { getBody, getFArgumentMultipartData } from "./sub_helpers/body"
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
import { getDefaultRESTRequest } from "../rest/default"
import {
objHasProperty,
objHasArrayProperty,

View File

@@ -3,7 +3,7 @@ import parser from "yargs-parser"
import * as O from "fp-ts/Option"
import * as S from "fp-ts/string"
import { pipe } from "fp-ts/function"
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { objHasProperty } from "~/helpers/functional/object"
const defaultRESTReq = getDefaultRESTRequest()

View File

@@ -2,7 +2,7 @@ import parser from "yargs-parser"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as R from "fp-ts/Refinement"
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import {
objHasProperty,
objHasArrayProperty,

View File

@@ -2,7 +2,7 @@ import parser from "yargs-parser"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { stringArrayJoin } from "~/helpers/functional/array"
const defaultRESTReq = getDefaultRESTRequest()

View File

@@ -1,87 +0,0 @@
import {
audit,
combineLatest,
distinctUntilChanged,
EMPTY,
from,
map,
Subscription,
} from "rxjs"
import { doc, getDoc, getFirestore, setDoc } from "firebase/firestore"
import { cloneDeep } from "lodash-es"
import { HoppRESTRequest, translateToNewRequest } from "@hoppscotch/data"
import { platform } from "~/platform"
import { HoppUser } from "~/platform/auth"
import { restRequest$ } from "~/newstore/RESTSession"
/**
* Writes a request to a user's firestore sync
*
* @param user The user to write to
* @param request The request to write to the request sync
*/
function writeCurrentRequest(user: HoppUser, request: HoppRESTRequest) {
const req = cloneDeep(request)
// Remove FormData entries because those can't be stored on Firestore
if (req.body.contentType === "multipart/form-data") {
req.body.body = req.body.body.map((formData) => {
if (!formData.isFile) return formData
return {
active: formData.active,
isFile: false,
key: formData.key,
value: "",
}
})
}
return setDoc(doc(getFirestore(), "users", user.uid, "requests", "rest"), req)
}
/**
* Loads the synced request from the firestore sync
*
* @returns Fetched request object if exists else null
*/
export async function loadRequestFromSync(): Promise<HoppRESTRequest | null> {
const currentUser = platform.auth.getCurrentUser()
if (!currentUser)
throw new Error("Cannot load request from sync without login")
const fbDoc = await getDoc(
doc(getFirestore(), "users", currentUser.uid, "requests", "rest")
)
const data = fbDoc.data()
if (!data) return null
else return translateToNewRequest(data)
}
/**
* Performs sync of the REST Request session with Firestore.
*
* @returns A subscription to the sync observable stream.
* Unsubscribe to stop syncing.
*/
export function startRequestSync(): Subscription {
const currentUser$ = platform.auth.getCurrentUserStream()
const sub = combineLatest([
currentUser$,
restRequest$.pipe(distinctUntilChanged()),
])
.pipe(
map(([user, request]) =>
user ? from(writeCurrentRequest(user, request)) : EMPTY
),
audit((x) => x)
)
.subscribe(() => {
// NOTE: This subscription should be kept
})
return sub
}

View File

@@ -0,0 +1,20 @@
import { HoppRESTRequest, RESTReqSchemaVersion } from "@hoppscotch/data"
export const getDefaultRESTRequest = (): HoppRESTRequest => ({
v: RESTReqSchemaVersion,
endpoint: "https://echo.hoppscotch.io",
name: "Untitled",
params: [],
headers: [],
method: "GET",
auth: {
authType: "none",
authActive: true,
},
preRequestScript: "",
testScript: "",
body: {
contentType: null,
body: null,
},
})

View File

@@ -0,0 +1,58 @@
import { HoppRESTRequest } from "@hoppscotch/data"
export type HoppRESTSaveContext =
| {
/**
* The origin source of the request
*/
originLocation: "user-collection"
/**
* Path to the request folder
*/
folderPath: string
/**
* Index to the request
*/
requestIndex: number
}
| {
/**
* The origin source of the request
*/
originLocation: "team-collection"
/**
* ID of the request in the team
*/
requestID: string
/**
* ID of the team
*/
teamID?: string
/**
* ID of the collection loaded
*/
collectionID?: string
}
| null
/**
* Defines a live 'document' (something that is open and being edited) in the app
*/
export type HoppRESTDocument = {
/**
* The request as it is in the document
*/
request: HoppRESTRequest
/**
* Whether the request has any unsaved changes
* (atleast as far as we can say)
*/
isDirty: boolean
/**
* Info about where this request should be saved.
* This contains where the request is originated from basically.
*/
saveContext?: HoppRESTSaveContext
}

View File

@@ -0,0 +1,25 @@
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as RR from "fp-ts/ReadonlyRecord"
import { HoppRESTRequest } from "@hoppscotch/data"
export const REQUEST_METHOD_LABEL_COLORS = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
} as const
/**
* Returns the label color tailwind class for a request
* @param request The HoppRESTRequest object to get the value for
* @returns The class value for the given HTTP VERB, if not, a generic verb class
*/
export function getMethodLabelColorClassOf(request: HoppRESTRequest) {
return pipe(
REQUEST_METHOD_LABEL_COLORS,
RR.lookup(request.method.toLowerCase()),
O.getOrElseW(() => REQUEST_METHOD_LABEL_COLORS.default)
)
}

View File

@@ -0,0 +1,199 @@
import { v4 as uuidV4 } from "uuid"
import { isEqual } from "lodash-es"
import { reactive, watch, computed, ref, shallowReadonly } from "vue"
import { HoppRESTDocument, HoppRESTSaveContext } from "./document"
import { refWithControl } from "@vueuse/core"
import { HoppRESTResponse } from "../types/HoppRESTResponse"
import { getDefaultRESTRequest } from "./default"
import { HoppTestResult } from "../types/HoppTestResult"
export type HoppRESTTab = {
id: string
document: HoppRESTDocument
response?: HoppRESTResponse | null
testResults?: HoppTestResult | null
}
export type PersistableRESTTabState = {
lastActiveTabID: string
orderedDocs: Array<{
tabID: string
doc: HoppRESTDocument
}>
}
export const currentTabID = refWithControl("test", {
onBeforeChange(newTabID) {
if (!newTabID || !tabMap.has(newTabID)) {
console.warn(
`Tried to set current tab id to an invalid value. (value: ${newTabID})`
)
// Don't allow change
return false
}
},
})
const tabMap = reactive(
new Map<string, HoppRESTTab>([
[
"test",
{
id: "test",
document: {
request: getDefaultRESTRequest(),
isDirty: false,
},
},
],
])
)
const tabOrdering = ref<string[]>(["test"])
watch(
tabOrdering,
(newOrdering) => {
if (!currentTabID.value || !newOrdering.includes(currentTabID.value)) {
currentTabID.value = newOrdering[newOrdering.length - 1] // newOrdering should always be non-empty
}
},
{ deep: true }
)
export const persistableTabState = computed<PersistableRESTTabState>(() => ({
lastActiveTabID: currentTabID.value,
orderedDocs: tabOrdering.value.map((tabID) => {
const tab = tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: tab.document,
}
}),
}))
export const currentActiveTab = computed(() => tabMap.get(currentTabID.value)!) // Guaranteed to not be undefined
// TODO: Mark this unknown and do validations
export function loadTabsFromPersistedState(data: PersistableRESTTabState) {
if (data) {
tabMap.clear()
tabOrdering.value = []
for (const doc of data.orderedDocs) {
tabMap.set(doc.tabID, {
id: doc.tabID,
document: doc.doc,
})
tabOrdering.value.push(doc.tabID)
}
currentTabID.value = data.lastActiveTabID
}
}
/**
* Returns all the active Tab IDs in order
*/
export function getActiveTabs() {
return shallowReadonly(
computed(() => tabOrdering.value.map((x) => tabMap.get(x)!))
)
}
export function getTabRef(tabID: string) {
return computed({
get() {
const result = tabMap.get(tabID)
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
return result
},
set(value) {
return tabMap.set(tabID, value)
},
})
}
function generateNewTabID() {
while (true) {
const id = uuidV4()
if (!tabMap.has(id)) return id
}
}
export function updateTab(tabUpdate: HoppRESTTab) {
if (!tabMap.has(tabUpdate.id)) {
console.warn(
`Cannot update tab as tab with that tab id does not exist (id: ${tabUpdate.id})`
)
}
tabMap.set(tabUpdate.id, tabUpdate)
}
export function createNewTab(document: HoppRESTDocument, switchToIt = true) {
const id = generateNewTabID()
const tab: HoppRESTTab = { id, document }
tabMap.set(id, tab)
tabOrdering.value.push(id)
if (switchToIt) {
currentTabID.value = id
}
return tab
}
export function updateTabOrdering(fromIndex: number, toIndex: number) {
tabOrdering.value.splice(
toIndex,
0,
tabOrdering.value.splice(fromIndex, 1)[0]
)
}
export function closeTab(tabID: string) {
if (!tabMap.has(tabID)) {
console.warn(`Tried to close a tab which does not exist (tab id: ${tabID})`)
return
}
if (tabOrdering.value.length === 1) {
console.warn(
`Tried to close the only tab open, which is not allowed. (tab id: ${tabID})`
)
return
}
tabOrdering.value.splice(tabOrdering.value.indexOf(tabID), 1)
tabMap.delete(tabID)
}
export function getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
for (const tab of tabMap.values()) {
// For `team-collection` request id can be considered unique
if (ctx && ctx.originLocation === "team-collection") {
if (
tab.document.saveContext?.originLocation === "team-collection" &&
tab.document.saveContext.requestID === ctx.requestID
) {
return getTabRef(tab.id)
}
} else if (isEqual(ctx, tab.document.saveContext)) return getTabRef(tab.id)
}
return null
}
export function getTabsRefTo(func: (tab: HoppRESTTab) => boolean) {
return Array.from(tabMap.values())
.filter(func)
.map((tab) => getTabRef(tab.id))
}

View File

@@ -4,7 +4,7 @@ import { HoppRESTRequest } from "@hoppscotch/data"
* We use the save context to figure out
* how a loaded request is to be saved.
* These will be set when the request is loaded
* into the request session (RESTSession)
* into the request session
*/
export type HoppRequestSaveContext =
| {