From defece95fc5f2fa3f6c961bdabd399b1c4fbf749 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 31 Mar 2023 01:15:42 +0600 Subject: [PATCH] feat: rest revamp (#2918) Co-authored-by: Liyas Thomas Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com> Co-authored-by: Andrew Bastin --- packages/hoppscotch-common/locales/en.json | 2 + .../hoppscotch-common/src/components.d.ts | 5 + .../src/components/app/PaneLayout.vue | 2 + .../src/components/collections/AddRequest.vue | 4 +- .../src/components/collections/Collection.vue | 2 +- .../components/collections/MyCollections.vue | 36 +- .../src/components/collections/Request.vue | 18 +- .../components/collections/SaveRequest.vue | 90 ++- .../collections/TeamCollections.vue | 5 +- .../src/components/collections/index.vue | 431 ++++++----- .../src/components/history/index.vue | 141 +--- .../src/components/http/Authorization.vue | 159 +--- .../src/components/http/Body.vue | 75 +- .../src/components/http/BodyParameters.vue | 20 +- .../src/components/http/CodegenModal.vue | 7 +- .../src/components/http/Headers.vue | 40 +- .../src/components/http/ImportCurl.vue | 4 +- .../components/http/OAuth2Authorization.vue | 119 ++- .../src/components/http/Parameters.vue | 15 +- .../src/components/http/PreRequestScript.vue | 11 +- .../src/components/http/RawBody.vue | 34 +- .../src/components/http/Request.vue | 187 ++--- .../src/components/http/RequestOptions.vue | 85 ++- .../src/components/http/RequestTab.vue | 46 ++ .../src/components/http/Response.vue | 34 +- .../src/components/http/ResponseMeta.vue | 4 +- .../src/components/http/Sidebar.vue | 16 +- .../src/components/http/TestResult.vue | 21 +- .../src/components/http/Tests.vue | 9 +- .../src/components/http/URLEncodedParams.vue | 19 +- .../components/http/authorization/ApiKey.vue | 82 ++ .../components/http/authorization/Basic.vue | 32 + .../lenses/ResponseBodyRenderer.vue | 55 +- .../src/components/workspace/Selector.vue | 8 + .../src/helpers/RESTExtURLParams.ts | 2 +- .../src/helpers/RESTRequest.ts | 293 ++++++++ .../src/helpers/RequestRunner.ts | 48 +- .../src/helpers/collection/affectedIndex.ts | 21 + .../src/helpers/collection/collection.ts | 141 ++++ .../src/helpers/collection/request.ts | 72 ++ .../src/helpers/curl/curlparser.ts | 2 +- .../src/helpers/curl/sub_helpers/auth.ts | 2 +- .../src/helpers/curl/sub_helpers/method.ts | 2 +- .../src/helpers/curl/sub_helpers/url.ts | 2 +- .../src/helpers/fb/request.ts | 87 --- .../src/helpers/rest/default.ts | 20 + .../src/helpers/rest/document.ts | 58 ++ .../src/helpers/rest/labelColoring.ts | 25 + .../hoppscotch-common/src/helpers/rest/tab.ts | 199 +++++ .../helpers/types/HoppRequestSaveContext.ts | 2 +- .../src/newstore/RESTSession.ts | 710 ------------------ .../src/newstore/collections.ts | 45 +- .../hoppscotch-common/src/newstore/history.ts | 57 +- .../src/newstore/localpersistence.ts | 60 +- .../hoppscotch-common/src/pages/index.vue | 368 ++++++--- .../hoppscotch-common/src/pages/r/_id.vue | 10 +- .../hoppscotch-common/src/platform/index.ts | 2 + .../hoppscotch-common/src/platform/tab.ts | 10 + .../src/components/smart/Window.vue | 27 +- .../src/components/smart/Windows.vue | 32 +- .../src/stories/Window.story.vue | 21 + packages/hoppscotch-web/src/main.ts | 2 + packages/hoppscotch-web/src/tab.ts | 48 ++ 63 files changed, 2262 insertions(+), 1924 deletions(-) create mode 100644 packages/hoppscotch-common/src/components/http/RequestTab.vue create mode 100644 packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue create mode 100644 packages/hoppscotch-common/src/components/http/authorization/Basic.vue create mode 100644 packages/hoppscotch-common/src/helpers/RESTRequest.ts create mode 100644 packages/hoppscotch-common/src/helpers/collection/affectedIndex.ts create mode 100644 packages/hoppscotch-common/src/helpers/collection/collection.ts create mode 100644 packages/hoppscotch-common/src/helpers/collection/request.ts delete mode 100644 packages/hoppscotch-common/src/helpers/fb/request.ts create mode 100644 packages/hoppscotch-common/src/helpers/rest/default.ts create mode 100644 packages/hoppscotch-common/src/helpers/rest/document.ts create mode 100644 packages/hoppscotch-common/src/helpers/rest/labelColoring.ts create mode 100644 packages/hoppscotch-common/src/helpers/rest/tab.ts delete mode 100644 packages/hoppscotch-common/src/newstore/RESTSession.ts create mode 100644 packages/hoppscotch-common/src/platform/tab.ts create mode 100644 packages/hoppscotch-web/src/tab.ts diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 59f6f6157..d298e286a 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -138,6 +138,7 @@ }, "confirm": { "exit_team": "Are you sure you want to leave this team?", + "save_unsaved_tab": "Do you want to save changes made in this tab ?", "logout": "Are you sure you want to logout?", "remove_collection": "Are you sure you want to permanently delete this collection?", "remove_environment": "Are you sure you want to permanently delete this environment?", @@ -317,6 +318,7 @@ "modal": { "collections": "Collections", "confirm": "Confirm", + "close_unsaved_tab": "Close Unsaved Tab ?", "edit_request": "Edit Request", "import_export": "Import / Export" }, diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 40471f3c5..9fc832795 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -89,7 +89,11 @@ declare module '@vue/runtime-core' { HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'] + HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow'] + HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows'] HttpAuthorization: typeof import('./components/http/Authorization.vue')['default'] + HttpAuthorizationApiKey: typeof import('./components/http/authorization/ApiKey.vue')['default'] + HttpAuthorizationBasic: typeof import('./components/http/authorization/Basic.vue')['default'] HttpBody: typeof import('./components/http/Body.vue')['default'] HttpBodyParameters: typeof import('./components/http/BodyParameters.vue')['default'] HttpCodegenModal: typeof import('./components/http/CodegenModal.vue')['default'] @@ -102,6 +106,7 @@ declare module '@vue/runtime-core' { HttpReqChangeConfirmModal: typeof import('./components/http/ReqChangeConfirmModal.vue')['default'] HttpRequest: typeof import('./components/http/Request.vue')['default'] HttpRequestOptions: typeof import('./components/http/RequestOptions.vue')['default'] + HttpRequestTab: typeof import('./components/http/RequestTab.vue')['default'] HttpResponse: typeof import('./components/http/Response.vue')['default'] HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default'] HttpSidebar: typeof import('./components/http/Sidebar.vue')['default'] diff --git a/packages/hoppscotch-common/src/components/app/PaneLayout.vue b/packages/hoppscotch-common/src/components/app/PaneLayout.vue index 7763abe1c..486a58cd6 100644 --- a/packages/hoppscotch-common/src/components/app/PaneLayout.vue +++ b/packages/hoppscotch-common/src/components/app/PaneLayout.vue @@ -22,6 +22,7 @@ @@ -62,6 +63,7 @@ const SIDEBAR = useSetting("SIDEBAR") const slots = useSlots() const hasSidebar = computed(() => !!slots.sidebar) +const hasSecondary = computed(() => !!slots.secondary) const props = defineProps({ layoutId: { diff --git a/packages/hoppscotch-common/src/components/collections/AddRequest.vue b/packages/hoppscotch-common/src/components/collections/AddRequest.vue index 23bcd729c..5f186a09e 100644 --- a/packages/hoppscotch-common/src/components/collections/AddRequest.vue +++ b/packages/hoppscotch-common/src/components/collections/AddRequest.vue @@ -43,7 +43,7 @@ import { ref, watch } from "vue" import { useI18n } from "@composables/i18n" import { useToast } from "@composables/toast" -import { getRESTRequest } from "~/newstore/RESTSession" +import { currentActiveTab } from "~/helpers/rest/tab" const toast = useToast() const t = useI18n() @@ -70,7 +70,7 @@ watch( () => props.show, (show) => { if (show) { - name.value = getRESTRequest().name + name.value = currentActiveTab.value.document.request.name } } ) diff --git a/packages/hoppscotch-common/src/components/collections/Collection.vue b/packages/hoppscotch-common/src/components/collections/Collection.vue index f368409fd..9a8695a3e 100644 --- a/packages/hoppscotch-common/src/components/collections/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/Collection.vue @@ -203,7 +203,7 @@ const props = defineProps({ parentID: { type: String as PropType, default: null, - required: true, + required: false, }, data: { type: Object as PropType | TeamCollection>, diff --git a/packages/hoppscotch-common/src/components/collections/MyCollections.vue b/packages/hoppscotch-common/src/components/collections/MyCollections.vue index 759b7ad69..d09b93a45 100644 --- a/packages/hoppscotch-common/src/components/collections/MyCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/MyCollections.vue @@ -298,11 +298,10 @@ import { GetMyTeamsQuery } from "~/helpers/backend/graphql" import { ChildrenResult, SmartTreeAdapter } from "~/helpers/treeAdapter" import { useI18n } from "@composables/i18n" import { useColorMode } from "@composables/theming" -import { useReadonlyStream } from "~/composables/stream" -import { restSaveContext$ } from "~/newstore/RESTSession" import { pipe } from "fp-ts/function" import * as O from "fp-ts/Option" import { Picked } from "~/helpers/types/HoppPicked.js" +import { currentActiveTab } from "~/helpers/rest/tab" export type Collection = { type: "collections" @@ -508,23 +507,21 @@ const isSelected = computed(() => { } }) -const active = useReadonlyStream(restSaveContext$, null) +const active = computed(() => currentActiveTab.value.document.saveContext) -const isActiveRequest = computed(() => { - return (folderPath: string, requestIndex: number) => { - return pipe( - active.value, - O.fromNullable, - O.filter( - (active) => - active.originLocation === "user-collection" && - active.folderPath === folderPath && - active.requestIndex === requestIndex - ), - O.isSome - ) - } -}) +const isActiveRequest = (folderPath: string, requestIndex: number) => { + return pipe( + active.value, + O.fromNullable, + O.filter( + (active) => + active.originLocation === "user-collection" && + active.folderPath === folderPath && + active.requestIndex === requestIndex + ), + O.isSome + ) +} const selectRequest = (data: { request: HoppRESTRequest @@ -532,6 +529,7 @@ const selectRequest = (data: { requestIndex: string }) => { const { request, folderPath, requestIndex } = data + if (props.saveRequest) { emit("select", { pickedType: "my-request", @@ -543,7 +541,7 @@ const selectRequest = (data: { request, folderPath, requestIndex, - isActive: isActiveRequest.value(folderPath, parseInt(requestIndex)), + isActive: isActiveRequest(folderPath, parseInt(requestIndex)), }) } } diff --git a/packages/hoppscotch-common/src/components/collections/Request.vue b/packages/hoppscotch-common/src/components/collections/Request.vue index 6014710ce..3aab24047 100644 --- a/packages/hoppscotch-common/src/components/collections/Request.vue +++ b/packages/hoppscotch-common/src/components/collections/Request.vue @@ -152,14 +152,12 @@ import { ref, PropType, watch, computed } from "vue" import { HoppRESTRequest } from "@hoppscotch/data" import { useI18n } from "@composables/i18n" import { TippyComponent } from "vue-tippy" -import { pipe } from "fp-ts/function" -import * as RR from "fp-ts/ReadonlyRecord" -import * as O from "fp-ts/Option" import { changeCurrentReorderStatus, currentReorderingStatus$, } from "~/newstore/reordering" import { useReadonlyStream } from "~/composables/stream" +import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring" type CollectionType = "my-collections" | "team-collections" @@ -242,20 +240,8 @@ const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, { parentID: "", }) -const requestMethodLabels = { - get: "text-green-500", - post: "text-yellow-500", - put: "text-blue-500", - delete: "text-red-500", - default: "text-gray-500", -} as const - const requestLabelColor = computed(() => - pipe( - requestMethodLabels, - RR.lookup(props.request.method.toLowerCase()), - O.getOrElseW(() => requestMethodLabels.default) - ) + getMethodLabelColorClassOf(props.request) ) watch( diff --git a/packages/hoppscotch-common/src/components/collections/SaveRequest.vue b/packages/hoppscotch-common/src/components/collections/SaveRequest.vue index b342b44d6..b10e16d22 100644 --- a/packages/hoppscotch-common/src/components/collections/SaveRequest.vue +++ b/packages/hoppscotch-common/src/components/collections/SaveRequest.vue @@ -1,3 +1,4 @@ + @@ -40,5 +40,5 @@ const t = useI18n() type RequestOptionTabs = "history" | "collections" | "env" -const selectedNavigationTab = ref("history") +const selectedNavigationTab = ref("collections") diff --git a/packages/hoppscotch-common/src/components/http/TestResult.vue b/packages/hoppscotch-common/src/components/http/TestResult.vue index f8f9b3941..fb4cd7eb1 100644 --- a/packages/hoppscotch-common/src/components/http/TestResult.vue +++ b/packages/hoppscotch-common/src/components/http/TestResult.vue @@ -216,7 +216,6 @@ import { setGlobalEnvVariables, setSelectedEnvironmentIndex, } from "~/newstore/environments" -import { restTestResults$, setRESTTestResults } from "~/newstore/RESTSession" import { HoppTestResult } from "~/helpers/types/HoppTestResult" import IconTrash2 from "~icons/lucide/trash-2" @@ -226,6 +225,17 @@ import IconCheck from "~icons/lucide/check" import IconClose from "~icons/lucide/x" import { useColorMode } from "~/composables/theming" +import { useVModel } from "@vueuse/core" + +const props = defineProps<{ + modelValue: HoppTestResult | null | undefined +}>() + +const emit = defineEmits<{ + (e: "update:modelValue", val: HoppTestResult | null | undefined): void +}>() + +const testResults = useVModel(props, "modelValue", emit) const t = useI18n() const colorMode = useColorMode() @@ -236,11 +246,6 @@ const displayModalAdd = (shouldDisplay: boolean) => { showModalDetails.value = shouldDisplay } -const testResults = useReadonlyStream( - restTestResults$, - null -) as Ref - /** * Get the "addition" environment variables * @returns Array of objects with key-value pairs of arguments @@ -250,7 +255,9 @@ const getAdditionVars = () => ? testResults.value.envDiff.selected.additions : [] -const clearContent = () => setRESTTestResults(null) +const clearContent = () => { + testResults.value = null +} const haveEnvVariables = computed(() => { if (!testResults.value) return false diff --git a/packages/hoppscotch-common/src/components/http/Tests.vue b/packages/hoppscotch-common/src/components/http/Tests.vue index b56245b64..15a5b738d 100644 --- a/packages/hoppscotch-common/src/components/http/Tests.vue +++ b/packages/hoppscotch-common/src/components/http/Tests.vue @@ -66,17 +66,20 @@ import IconHelpCircle from "~icons/lucide/help-circle" import IconWrapText from "~icons/lucide/wrap-text" import IconTrash2 from "~icons/lucide/trash-2" import { reactive, ref } from "vue" -import { useTestScript } from "~/newstore/RESTSession" import testSnippets from "~/helpers/testSnippets" import { useCodemirror } from "@composables/codemirror" import linter from "~/helpers/editor/linting/testScript" import completer from "~/helpers/editor/completion/testScript" import { useI18n } from "@composables/i18n" +import { useVModel } from "@vueuse/core" const t = useI18n() -const testScript = useTestScript() - +const props = defineProps<{ + modelValue: string +}>() +const emit = defineEmits(["update:modelValue"]) +const testScript = useVModel(props, "modelValue", emit) const testScriptEditor = ref(null) const linewrapEnabled = ref(true) diff --git a/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue b/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue index 8957eb9c4..7ba5cdc77 100644 --- a/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue +++ b/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue @@ -181,6 +181,7 @@ import IconWrapText from "~icons/lucide/wrap-text" import { computed, reactive, ref, watch } from "vue" import { isEqual, cloneDeep } from "lodash-es" import { + HoppRESTReqBody, parseRawKeyValueEntries, parseRawKeyValueEntriesE, rawKeyValueEntriesToString, @@ -194,13 +195,27 @@ import * as E from "fp-ts/Either" import draggable from "vuedraggable-es" import { useCodemirror } from "@composables/codemirror" import linter from "~/helpers/editor/linting/rawKeyValue" -import { useRESTRequestBody } from "~/newstore/RESTSession" import { pluckRef } from "@composables/ref" import { useI18n } from "@composables/i18n" import { useToast } from "@composables/toast" import { useColorMode } from "@composables/theming" import { objRemoveKey } from "~/helpers/functional/object" import { throwError } from "~/helpers/functional/error" +import { useVModel } from "@vueuse/core" + +type Body = HoppRESTReqBody & { + contentType: "application/x-www-form-urlencoded" +} + +const props = defineProps<{ + modelValue: Body +}>() + +const emit = defineEmits<{ + (e: "update:modelValue", val: Body): void +}>() + +const body = useVModel(props, "modelValue", emit) const t = useI18n() const toast = useToast() @@ -231,7 +246,7 @@ useCodemirror( ) // The functional urlEncodedParams list (the urlEncodedParams actually in the system) -const urlEncodedParamsRaw = pluckRef(useRESTRequestBody(), "body") +const urlEncodedParamsRaw = pluckRef(body, "body") const urlEncodedParams = computed({ get() { diff --git a/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue b/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue new file mode 100644 index 000000000..d5aa48225 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue @@ -0,0 +1,82 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/authorization/Basic.vue b/packages/hoppscotch-common/src/components/http/authorization/Basic.vue new file mode 100644 index 000000000..b3db29a6b --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/Basic.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue b/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue index 31a0400a2..6ad7ef987 100644 --- a/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue +++ b/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue @@ -1,6 +1,6 @@ @@ -49,44 +52,48 @@ import { getLensRenderers, Lens, } from "~/helpers/lenses/lenses" -import { useReadonlyStream } from "@composables/stream" import { useI18n } from "@composables/i18n" -import type { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" -import { restTestResults$ } from "~/newstore/RESTSession" +import { useVModel } from "@vueuse/core" +import { HoppRESTTab } from "~/helpers/rest/tab" const props = defineProps<{ - response: HoppRESTResponse | null + tab: HoppRESTTab selectedTabPreference: string | null }>() const emit = defineEmits<{ + (e: "update:tab", val: HoppRESTTab): void (e: "update:selectedTabPreference", newTab: string): void }>() +const tab = useVModel(props, "tab", emit) +const selectedTabPreference = useVModel(props, "selectedTabPreference", emit) + const allLensRenderers = getLensRenderers() function lensRendererFor(name: string) { return allLensRenderers[name] } -const testResults = useReadonlyStream(restTestResults$, null) - const t = useI18n() const selectedLensTab = ref("") const maybeHeaders = computed(() => { if ( - !props.response || - !(props.response.type === "success" || props.response.type === "fail") + !tab.value.response || + !( + tab.value.response.type === "success" || + tab.value.response.type === "fail" + ) ) return null - return props.response.headers + return tab.value.response.headers }) const validLenses = computed(() => { - if (!props.response) return [] - return getSuitableLenses(props.response) + if (!tab.value.response) return [] + return getSuitableLenses(tab.value.response) }) watch( @@ -101,10 +108,10 @@ watch( ] if ( - props.selectedTabPreference && - validRenderers.includes(props.selectedTabPreference) + selectedTabPreference.value && + validRenderers.includes(selectedTabPreference.value) ) { - selectedLensTab.value = props.selectedTabPreference + selectedLensTab.value = selectedTabPreference.value } else { selectedLensTab.value = newLenses[0].renderer } @@ -113,6 +120,6 @@ watch( ) watch(selectedLensTab, (newLensID) => { - emit("update:selectedTabPreference", newLensID) + selectedTabPreference.value = newLensID }) diff --git a/packages/hoppscotch-common/src/components/workspace/Selector.vue b/packages/hoppscotch-common/src/components/workspace/Selector.vue index 2724e2347..733d4d49d 100644 --- a/packages/hoppscotch-common/src/components/workspace/Selector.vue +++ b/packages/hoppscotch-common/src/components/workspace/Selector.vue @@ -19,6 +19,12 @@ v-if="!loading && myTeams.length === 0" class="flex flex-col items-center justify-center flex-1 p-4 text-secondaryLight" > + {{ t("empty.teams") }} @@ -78,12 +84,14 @@ import { useI18n } from "@composables/i18n" import IconUser from "~icons/lucide/user" import IconUsers from "~icons/lucide/users" import IconPlus from "~icons/lucide/plus" +import { useColorMode } from "@composables/theming" import { changeWorkspace, workspaceStatus$ } from "~/newstore/workspace" import { GetMyTeamsQuery } from "~/helpers/backend/graphql" import IconDone from "~icons/lucide/check" import { useLocalState } from "~/newstore/localstate" const t = useI18n() +const colorMode = useColorMode() const showModalAdd = ref(false) diff --git a/packages/hoppscotch-common/src/helpers/RESTExtURLParams.ts b/packages/hoppscotch-common/src/helpers/RESTExtURLParams.ts index 1b9bf5c2b..fbb81e5ac 100644 --- a/packages/hoppscotch-common/src/helpers/RESTExtURLParams.ts +++ b/packages/hoppscotch-common/src/helpers/RESTExtURLParams.ts @@ -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 diff --git a/packages/hoppscotch-common/src/helpers/RESTRequest.ts b/packages/hoppscotch-common/src/helpers/RESTRequest.ts new file mode 100644 index 000000000..d44a2d55d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/RESTRequest.ts @@ -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( + RESTReqSchemaVersion + ) + public name$ = new BehaviorSubject("Untitled") + public endpoint$ = new BehaviorSubject("https://echo.hoppscotch.io/") + public params$ = new BehaviorSubject([]) + public headers$ = new BehaviorSubject([]) + public method$ = new BehaviorSubject("GET") + public auth$ = new BehaviorSubject({ + authType: "none", + authActive: true, + }) + public preRequestScript$ = new BehaviorSubject("") + public testScript$ = new BehaviorSubject("") + public body$ = new BehaviorSubject({ + contentType: null, + body: null, + }) + + public response$ = new BehaviorSubject(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, + }) + } +} diff --git a/packages/hoppscotch-common/src/helpers/RequestRunner.ts b/packages/hoppscotch-common/src/helpers/RequestRunner.ts index 4b6144d81..1ee02cbe4 100644 --- a/packages/hoppscotch-common/src/helpers/RequestRunner.ts +++ b/packages/hoppscotch-common/src/helpers/RequestRunner.ts @@ -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 -> => +export const executedResponses$ = new Subject< + HoppRESTResponse & { type: "success" | "fail " } +>() + +export const runRESTRequest$ = ( + tab: Ref +): TE.TaskEither> => 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) }) ) diff --git a/packages/hoppscotch-common/src/helpers/collection/affectedIndex.ts b/packages/hoppscotch-common/src/helpers/collection/affectedIndex.ts new file mode 100644 index 000000000..aa354e462 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/collection/affectedIndex.ts @@ -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() + 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 +} diff --git a/packages/hoppscotch-common/src/helpers/collection/collection.ts b/packages/hoppscotch-common/src/helpers/collection/collection.ts new file mode 100644 index 000000000..31de08e72 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/collection/collection.ts @@ -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() + 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[], + path: string +): HoppCollection[] { + 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 +} diff --git a/packages/hoppscotch-common/src/helpers/collection/request.ts b/packages/hoppscotch-common/src/helpers/collection/request.ts new file mode 100644 index 000000000..a4889c9b4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/collection/request.ts @@ -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[], + 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 +} diff --git a/packages/hoppscotch-common/src/helpers/curl/curlparser.ts b/packages/hoppscotch-common/src/helpers/curl/curlparser.ts index b6985d7b9..155923eee 100644 --- a/packages/hoppscotch-common/src/helpers/curl/curlparser.ts +++ b/packages/hoppscotch-common/src/helpers/curl/curlparser.ts @@ -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, diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/auth.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/auth.ts index 4b29da063..77a234221 100644 --- a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/auth.ts +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/auth.ts @@ -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() diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/method.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/method.ts index ba0e4f274..4b43a0e77 100644 --- a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/method.ts +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/method.ts @@ -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, diff --git a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/url.ts b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/url.ts index ef352280f..502072492 100644 --- a/packages/hoppscotch-common/src/helpers/curl/sub_helpers/url.ts +++ b/packages/hoppscotch-common/src/helpers/curl/sub_helpers/url.ts @@ -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() diff --git a/packages/hoppscotch-common/src/helpers/fb/request.ts b/packages/hoppscotch-common/src/helpers/fb/request.ts deleted file mode 100644 index 7522d1b38..000000000 --- a/packages/hoppscotch-common/src/helpers/fb/request.ts +++ /dev/null @@ -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 { - 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 -} diff --git a/packages/hoppscotch-common/src/helpers/rest/default.ts b/packages/hoppscotch-common/src/helpers/rest/default.ts new file mode 100644 index 000000000..873ee2d7d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/rest/default.ts @@ -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, + }, +}) diff --git a/packages/hoppscotch-common/src/helpers/rest/document.ts b/packages/hoppscotch-common/src/helpers/rest/document.ts new file mode 100644 index 000000000..e80ef8e88 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/rest/document.ts @@ -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 +} diff --git a/packages/hoppscotch-common/src/helpers/rest/labelColoring.ts b/packages/hoppscotch-common/src/helpers/rest/labelColoring.ts new file mode 100644 index 000000000..6c4f65387 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/rest/labelColoring.ts @@ -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) + ) +} diff --git a/packages/hoppscotch-common/src/helpers/rest/tab.ts b/packages/hoppscotch-common/src/helpers/rest/tab.ts new file mode 100644 index 000000000..31829ebb4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/rest/tab.ts @@ -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([ + [ + "test", + { + id: "test", + document: { + request: getDefaultRESTRequest(), + isDirty: false, + }, + }, + ], + ]) +) +const tabOrdering = ref(["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(() => ({ + 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)) +} diff --git a/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts b/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts index 014b76b39..0eddaa9e3 100644 --- a/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts +++ b/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts @@ -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 = | { diff --git a/packages/hoppscotch-common/src/newstore/RESTSession.ts b/packages/hoppscotch-common/src/newstore/RESTSession.ts deleted file mode 100644 index f1e18cf29..000000000 --- a/packages/hoppscotch-common/src/newstore/RESTSession.ts +++ /dev/null @@ -1,710 +0,0 @@ -import { pluck, distinctUntilChanged, map, filter } from "rxjs/operators" -import { Ref } from "vue" -import { - FormDataKeyValue, - HoppRESTHeader, - HoppRESTParam, - HoppRESTReqBody, - HoppRESTRequest, - RESTReqSchemaVersion, - HoppRESTAuth, - ValidContentTypes, -} from "@hoppscotch/data" -import DispatchingStore, { defineDispatchers } from "./DispatchingStore" -import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" -import { useStream } from "@composables/stream" -import { HoppTestResult } from "~/helpers/types/HoppTestResult" -import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext" -import { applyBodyTransition } from "~/helpers/rules/BodyTransition" - -type RESTSession = { - request: HoppRESTRequest - response: HoppRESTResponse | null - testResults: HoppTestResult | null - saveContext: HoppRequestSaveContext | null -} - -export const getDefaultRESTRequest = (): HoppRESTRequest => ({ - v: RESTReqSchemaVersion, - endpoint: "https://echo.hoppscotch.io", - name: "Untitled request", - params: [], - headers: [], - method: "GET", - auth: { - authType: "none", - authActive: true, - }, - preRequestScript: "", - testScript: "", - body: { - contentType: null, - body: null, - }, -}) - -const defaultRESTSession: RESTSession = { - request: getDefaultRESTRequest(), - response: null, - testResults: null, - saveContext: null, -} - -const dispatchers = defineDispatchers({ - setRequest(_: RESTSession, { req }: { req: HoppRESTRequest }) { - return { - request: req, - } - }, - setRequestName(curr: RESTSession, { newName }: { newName: string }) { - return { - request: { - ...curr.request, - name: newName, - }, - } - }, - setEndpoint(curr: RESTSession, { newEndpoint }: { newEndpoint: string }) { - return { - request: { - ...curr.request, - endpoint: newEndpoint, - }, - } - }, - setParams(curr: RESTSession, { entries }: { entries: HoppRESTParam[] }) { - return { - request: { - ...curr.request, - params: entries, - }, - } - }, - addParam(curr: RESTSession, { newParam }: { newParam: HoppRESTParam }) { - return { - request: { - ...curr.request, - params: [...curr.request.params, newParam], - }, - } - }, - updateParam( - curr: RESTSession, - { index, updatedParam }: { index: number; updatedParam: HoppRESTParam } - ) { - const newParams = curr.request.params.map((param, i) => { - if (i === index) return updatedParam - else return param - }) - - return { - request: { - ...curr.request, - params: newParams, - }, - } - }, - deleteParam(curr: RESTSession, { index }: { index: number }) { - const newParams = curr.request.params.filter((_x, i) => i !== index) - - return { - request: { - ...curr.request, - params: newParams, - }, - } - }, - deleteAllParams(curr: RESTSession, {}) { - return { - request: { - ...curr.request, - params: [], - }, - } - }, - updateMethod(curr: RESTSession, { newMethod }: { newMethod: string }) { - return { - request: { - ...curr.request, - method: newMethod, - }, - } - }, - setHeaders(curr: RESTSession, { entries }: { entries: HoppRESTHeader[] }) { - return { - request: { - ...curr.request, - headers: entries, - }, - } - }, - addHeader(curr: RESTSession, { entry }: { entry: HoppRESTHeader }) { - return { - request: { - ...curr.request, - headers: [...curr.request.headers, entry], - }, - } - }, - updateHeader( - curr: RESTSession, - { index, updatedEntry }: { index: number; updatedEntry: HoppRESTHeader } - ) { - return { - request: { - ...curr.request, - headers: curr.request.headers.map((header, i) => { - if (i === index) return updatedEntry - else return header - }), - }, - } - }, - deleteHeader(curr: RESTSession, { index }: { index: number }) { - return { - request: { - ...curr.request, - headers: curr.request.headers.filter((_, i) => i !== index), - }, - } - }, - deleteAllHeaders(curr: RESTSession, {}) { - return { - request: { - ...curr.request, - headers: [], - }, - } - }, - setAuth(curr: RESTSession, { newAuth }: { newAuth: HoppRESTAuth }) { - return { - request: { - ...curr.request, - auth: newAuth, - }, - } - }, - setPreRequestScript(curr: RESTSession, { newScript }: { newScript: string }) { - return { - request: { - ...curr.request, - preRequestScript: newScript, - }, - } - }, - setTestScript(curr: RESTSession, { newScript }: { newScript: string }) { - return { - request: { - ...curr.request, - testScript: newScript, - }, - } - }, - setContentType( - curr: RESTSession, - { newContentType }: { newContentType: ValidContentTypes | null } - ) { - // TODO: persist body evenafter switching content typees - return { - request: { - ...curr.request, - body: applyBodyTransition(curr.request.body, newContentType), - }, - } - }, - addFormDataEntry(curr: RESTSession, { entry }: { entry: FormDataKeyValue }) { - // Only perform update if the current content-type is formdata - if (curr.request.body.contentType !== "multipart/form-data") return {} - - return { - request: { - ...curr.request, - body: { - contentType: "multipart/form-data", - body: [...curr.request.body.body, entry], - }, - }, - } - }, - deleteFormDataEntry(curr: RESTSession, { index }: { index: number }) { - // Only perform update if the current content-type is formdata - if (curr.request.body.contentType !== "multipart/form-data") return {} - - return { - request: { - ...curr.request, - body: { - contentType: "multipart/form-data", - body: curr.request.body.body.filter((_, i) => i !== index), - }, - }, - } - }, - updateFormDataEntry( - curr: RESTSession, - { index, entry }: { index: number; entry: FormDataKeyValue } - ) { - // Only perform update if the current content-type is formdata - if (curr.request.body.contentType !== "multipart/form-data") return {} - - return { - request: { - ...curr.request, - body: { - contentType: "multipart/form-data", - body: curr.request.body.body.map((x, i) => (i !== index ? x : entry)), - }, - }, - } - }, - deleteAllFormDataEntries(curr: RESTSession, {}) { - // Only perform update if the current content-type is formdata - if (curr.request.body.contentType !== "multipart/form-data") return {} - - return { - request: { - ...curr.request, - body: { - contentType: "multipart/form-data", - body: [], - }, - }, - } - }, - setRequestBody(curr: RESTSession, { newBody }: { newBody: HoppRESTReqBody }) { - return { - request: { - ...curr.request, - body: newBody, - }, - } - }, - updateResponse( - _curr: RESTSession, - { updatedRes }: { updatedRes: HoppRESTResponse | null } - ) { - return { - response: updatedRes, - } - }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - clearResponse(_curr: RESTSession, {}) { - return { - response: null, - } - }, - setTestResults( - _curr: RESTSession, - { newResults }: { newResults: HoppTestResult | null } - ) { - return { - testResults: newResults, - } - }, - setSaveContext( - _, - { newContext }: { newContext: HoppRequestSaveContext | null } - ) { - return { - saveContext: newContext, - } - }, -}) - -const restSessionStore = new DispatchingStore(defaultRESTSession, dispatchers) - -export function getRESTRequest() { - return restSessionStore.subject$.value.request -} - -export function setRESTRequest( - req: HoppRESTRequest, - saveContext?: HoppRequestSaveContext | null -) { - restSessionStore.dispatch({ - dispatcher: "setRequest", - payload: { - req, - }, - }) - - if (saveContext) setRESTSaveContext(saveContext) -} - -export function setRESTSaveContext(saveContext: HoppRequestSaveContext | null) { - restSessionStore.dispatch({ - dispatcher: "setSaveContext", - payload: { - newContext: saveContext, - }, - }) -} - -export function getRESTSaveContext() { - return restSessionStore.value.saveContext -} - -export function resetRESTRequest() { - setRESTRequest(getDefaultRESTRequest()) -} - -export function setRESTEndpoint(newEndpoint: string) { - restSessionStore.dispatch({ - dispatcher: "setEndpoint", - payload: { - newEndpoint, - }, - }) -} - -export function setRESTRequestName(newName: string) { - restSessionStore.dispatch({ - dispatcher: "setRequestName", - payload: { - newName, - }, - }) -} - -export function setRESTParams(entries: HoppRESTParam[]) { - restSessionStore.dispatch({ - dispatcher: "setParams", - payload: { - entries, - }, - }) -} - -export function addRESTParam(newParam: HoppRESTParam) { - restSessionStore.dispatch({ - dispatcher: "addParam", - payload: { - newParam, - }, - }) -} - -export function updateRESTParam(index: number, updatedParam: HoppRESTParam) { - restSessionStore.dispatch({ - dispatcher: "updateParam", - payload: { - updatedParam, - index, - }, - }) -} - -export function deleteRESTParam(index: number) { - restSessionStore.dispatch({ - dispatcher: "deleteParam", - payload: { - index, - }, - }) -} - -export function deleteAllRESTParams() { - restSessionStore.dispatch({ - dispatcher: "deleteAllParams", - payload: {}, - }) -} - -export function updateRESTMethod(newMethod: string) { - restSessionStore.dispatch({ - dispatcher: "updateMethod", - payload: { - newMethod, - }, - }) -} - -export function setRESTHeaders(entries: HoppRESTHeader[]) { - restSessionStore.dispatch({ - dispatcher: "setHeaders", - payload: { - entries, - }, - }) -} - -export function addRESTHeader(entry: HoppRESTHeader) { - restSessionStore.dispatch({ - dispatcher: "addHeader", - payload: { - entry, - }, - }) -} - -export function updateRESTHeader(index: number, updatedEntry: HoppRESTHeader) { - restSessionStore.dispatch({ - dispatcher: "updateHeader", - payload: { - index, - updatedEntry, - }, - }) -} - -export function deleteRESTHeader(index: number) { - restSessionStore.dispatch({ - dispatcher: "deleteHeader", - payload: { - index, - }, - }) -} - -export function deleteAllRESTHeaders() { - restSessionStore.dispatch({ - dispatcher: "deleteAllHeaders", - payload: {}, - }) -} - -export function setRESTAuth(newAuth: HoppRESTAuth) { - restSessionStore.dispatch({ - dispatcher: "setAuth", - payload: { - newAuth, - }, - }) -} - -export function setRESTPreRequestScript(newScript: string) { - restSessionStore.dispatch({ - dispatcher: "setPreRequestScript", - payload: { - newScript, - }, - }) -} - -export function setRESTTestScript(newScript: string) { - restSessionStore.dispatch({ - dispatcher: "setTestScript", - payload: { - newScript, - }, - }) -} - -export function setRESTReqBody(newBody: HoppRESTReqBody) { - restSessionStore.dispatch({ - dispatcher: "setRequestBody", - payload: { - newBody, - }, - }) -} - -export function updateRESTResponse(updatedRes: HoppRESTResponse | null) { - restSessionStore.dispatch({ - dispatcher: "updateResponse", - payload: { - updatedRes, - }, - }) -} - -export function clearRESTResponse() { - restSessionStore.dispatch({ - dispatcher: "clearResponse", - payload: {}, - }) -} - -export function setRESTTestResults(newResults: HoppTestResult | null) { - restSessionStore.dispatch({ - dispatcher: "setTestResults", - payload: { - newResults, - }, - }) -} - -export function addFormDataEntry(entry: FormDataKeyValue) { - restSessionStore.dispatch({ - dispatcher: "addFormDataEntry", - payload: { - entry, - }, - }) -} - -export function deleteFormDataEntry(index: number) { - restSessionStore.dispatch({ - dispatcher: "deleteFormDataEntry", - payload: { - index, - }, - }) -} - -export function updateFormDataEntry(index: number, entry: FormDataKeyValue) { - restSessionStore.dispatch({ - dispatcher: "updateFormDataEntry", - payload: { - index, - entry, - }, - }) -} - -export function setRESTContentType(newContentType: ValidContentTypes | null) { - restSessionStore.dispatch({ - dispatcher: "setContentType", - payload: { - newContentType, - }, - }) -} - -export function deleteAllFormDataEntries() { - restSessionStore.dispatch({ - dispatcher: "deleteAllFormDataEntries", - payload: {}, - }) -} - -export const restSaveContext$ = restSessionStore.subject$.pipe( - pluck("saveContext"), - distinctUntilChanged() -) - -export const restRequest$ = restSessionStore.subject$.pipe( - pluck("request"), - distinctUntilChanged() -) - -export const restRequestName$ = restRequest$.pipe( - pluck("name"), - distinctUntilChanged() -) - -export const restEndpoint$ = restSessionStore.subject$.pipe( - pluck("request", "endpoint"), - distinctUntilChanged() -) - -export const restParams$ = restSessionStore.subject$.pipe( - pluck("request", "params"), - distinctUntilChanged() -) - -export const restActiveParamsCount$ = restParams$.pipe( - map( - (params) => - params.filter((x) => x.active && (x.key !== "" || x.value !== "")).length - ) -) - -export const restMethod$ = restSessionStore.subject$.pipe( - pluck("request", "method"), - distinctUntilChanged() -) - -export const restHeaders$ = restSessionStore.subject$.pipe( - pluck("request", "headers"), - distinctUntilChanged() -) - -export const restActiveHeadersCount$ = restHeaders$.pipe( - map( - (params) => - params.filter((x) => x.active && (x.key !== "" || x.value !== "")).length - ) -) - -export const restAuth$ = restRequest$.pipe(pluck("auth")) - -export const restPreRequestScript$ = restSessionStore.subject$.pipe( - pluck("request", "preRequestScript"), - distinctUntilChanged() -) - -export const restContentType$ = restRequest$.pipe( - pluck("body", "contentType"), - distinctUntilChanged() -) - -export const restTestScript$ = restSessionStore.subject$.pipe( - pluck("request", "testScript"), - distinctUntilChanged() -) - -export const restReqBody$ = restSessionStore.subject$.pipe( - pluck("request", "body"), - distinctUntilChanged() -) - -export const restResponse$ = restSessionStore.subject$.pipe( - pluck("response"), - distinctUntilChanged() -) - -export const completedRESTResponse$ = restResponse$.pipe( - filter( - (res) => - res !== null && - res.type !== "loading" && - res.type !== "network_fail" && - res.type !== "script_fail" - ) -) - -export const restTestResults$ = restSessionStore.subject$.pipe( - pluck("testResults"), - distinctUntilChanged() -) - -/** - * A Vue 3 composable function that gives access to a ref - * which is updated to the preRequestScript value in the store. - * The ref value is kept in sync with the store and all writes - * to the ref are dispatched to the store as `setPreRequestScript` - * dispatches. - */ -export function usePreRequestScript(): Ref { - return useStream( - restPreRequestScript$, - restSessionStore.value.request.preRequestScript, - (value) => { - setRESTPreRequestScript(value) - } - ) -} - -/** - * A Vue 3 composable function that gives access to a ref - * which is updated to the testScript value in the store. - * The ref value is kept in sync with the store and all writes - * to the ref are dispatched to the store as `setTestScript` - * dispatches. - */ -export function useTestScript(): Ref { - return useStream( - restTestScript$, - restSessionStore.value.request.testScript, - (value) => { - setRESTTestScript(value) - } - ) -} - -export function useRESTRequestBody(): Ref { - return useStream( - restReqBody$, - restSessionStore.value.request.body, - setRESTReqBody - ) -} - -export function useRESTRequestName(): Ref { - return useStream( - restRequestName$, - restSessionStore.value.request.name, - setRESTRequestName - ) -} diff --git a/packages/hoppscotch-common/src/newstore/collections.ts b/packages/hoppscotch-common/src/newstore/collections.ts index db4b23cc5..63870627d 100644 --- a/packages/hoppscotch-common/src/newstore/collections.ts +++ b/packages/hoppscotch-common/src/newstore/collections.ts @@ -6,8 +6,8 @@ import { makeCollection, } from "@hoppscotch/data" import DispatchingStore, { defineDispatchers } from "./DispatchingStore" -import { getRESTSaveContext, setRESTSaveContext } from "./RESTSession" import { cloneDeep } from "lodash-es" +import { getTabRefWithSaveContext } from "~/helpers/rest/tab" const defaultRESTCollectionState = { state: [ @@ -400,14 +400,17 @@ const restCollectionDispatchers = defineDispatchers({ targetLocation.requests.splice(requestIndex, 1) - // If the save context is set and is set to the same source, we invalidate it - const saveCtx = getRESTSaveContext() - if ( - saveCtx?.originLocation === "user-collection" && - saveCtx.folderPath === path && - saveCtx.requestIndex === requestIndex - ) { - setRESTSaveContext(null) + // Deal with situations where a tab with the given thing is deleted + // We are just going to dissociate the save context of the tab and mark it dirty + const tab = getTabRefWithSaveContext({ + originLocation: "user-collection", + folderPath: path, + requestIndex: requestIndex, + }) + + if (tab) { + tab.value.document.saveContext = undefined + tab.value.document.isDirty = true } return { @@ -457,6 +460,20 @@ const restCollectionDispatchers = defineDispatchers({ destLocation.requests.push(req) targetLocation.requests.splice(requestIndex, 1) + const possibleTab = getTabRefWithSaveContext({ + originLocation: "user-collection", + folderPath: path, + requestIndex, + }) + + if (possibleTab) { + possibleTab.value.document.saveContext = { + originLocation: "user-collection", + folderPath: destinationPath, + requestIndex: destLocation.requests.length - 1, + } + } + return { state: newState, } @@ -719,16 +736,6 @@ const gqlCollectionDispatchers = defineDispatchers({ targetLocation.requests.splice(requestIndex, 1) - // If the save context is set and is set to the same source, we invalidate it - const saveCtx = getRESTSaveContext() - if ( - saveCtx?.originLocation === "user-collection" && - saveCtx.folderPath === path && - saveCtx.requestIndex === requestIndex - ) { - setRESTSaveContext(null) - } - return { state: newState, } diff --git a/packages/hoppscotch-common/src/newstore/history.ts b/packages/hoppscotch-common/src/newstore/history.ts index e4e5d53d9..1f5518221 100644 --- a/packages/hoppscotch-common/src/newstore/history.ts +++ b/packages/hoppscotch-common/src/newstore/history.ts @@ -8,7 +8,7 @@ import { GQL_REQ_SCHEMA_VERSION, } from "@hoppscotch/data" import DispatchingStore, { defineDispatchers } from "./DispatchingStore" -import { completedRESTResponse$ } from "./RESTSession" +import { executedResponses$ } from "~/helpers/RequestRunner" export type RESTHistoryEntry = { v: number @@ -340,36 +340,27 @@ export function removeDuplicateGraphqlHistoryEntry(id: string) { } // Listen to completed responses to add to history -completedRESTResponse$.subscribe((res) => { - if (res !== null) { - if ( - res.type === "loading" || - res.type === "network_fail" || - res.type === "script_fail" - ) - return - - addRESTHistoryEntry( - makeRESTHistoryEntry({ - request: { - auth: res.req.auth, - body: res.req.body, - endpoint: res.req.endpoint, - headers: res.req.headers, - method: res.req.method, - name: res.req.name, - params: res.req.params, - preRequestScript: res.req.preRequestScript, - testScript: res.req.testScript, - v: res.req.v, - }, - responseMeta: { - duration: res.meta.responseDuration, - statusCode: res.statusCode, - }, - star: false, - updatedOn: new Date(), - }) - ) - } +executedResponses$.subscribe((res) => { + addRESTHistoryEntry( + makeRESTHistoryEntry({ + request: { + auth: res.req.auth, + body: res.req.body, + endpoint: res.req.endpoint, + headers: res.req.headers, + method: res.req.method, + name: res.req.name, + params: res.req.params, + preRequestScript: res.req.preRequestScript, + testScript: res.req.testScript, + v: res.req.v, + }, + responseMeta: { + duration: res.meta.responseDuration, + statusCode: res.statusCode, + }, + star: false, + updatedOn: new Date(), + }) + ) }) diff --git a/packages/hoppscotch-common/src/newstore/localpersistence.ts b/packages/hoppscotch-common/src/newstore/localpersistence.ts index cfde49425..40eec65cd 100644 --- a/packages/hoppscotch-common/src/newstore/localpersistence.ts +++ b/packages/hoppscotch-common/src/newstore/localpersistence.ts @@ -1,11 +1,9 @@ /* eslint-disable no-restricted-globals, no-restricted-syntax */ -import { clone, cloneDeep, assign, isEmpty } from "lodash-es" +import { clone, assign, isEmpty } from "lodash-es" import * as O from "fp-ts/Option" import { pipe } from "fp-ts/function" import { - safelyExtractRESTRequest, - translateToNewRequest, translateToNewRESTCollection, translateToNewGQLCollection, Environment, @@ -41,17 +39,16 @@ import { setSelectedEnvironmentIndex, selectedEnvironmentIndex$, } from "./environments" -import { - getDefaultRESTRequest, - restRequest$, - setRESTRequest, -} from "./RESTSession" import { WSRequest$, setWSRequest } from "./WebSocketSession" import { SIORequest$, setSIORequest } from "./SocketIOSession" import { SSERequest$, setSSERequest } from "./SSESession" import { MQTTRequest$, setMQTTRequest } from "./MQTTSession" import { bulkApplyLocalState, localStateStore } from "./localstate" -import { StorageLike } from "@vueuse/core" +import { StorageLike, watchDebounced } from "@vueuse/core" +import { + loadTabsFromPersistedState, + persistableTabState, +} from "~/helpers/rest/tab" function checkAndMigrateOldSettings() { const vuexData = JSON.parse(window.localStorage.getItem("vuex") || "{}") @@ -305,33 +302,28 @@ function setupGlobalEnvsPersistence() { }) } -function setupRequestPersistence() { - const localRequest = JSON.parse( - window.localStorage.getItem("restRequest") || "null" - ) - - if (localRequest) { - const parsedLocal = translateToNewRequest(localRequest) - setRESTRequest( - safelyExtractRESTRequest(parsedLocal, getDefaultRESTRequest()) +// TODO: Graceful error handling ? +export function setupRESTTabsPersistence() { + try { + const state = window.localStorage.getItem("restTabState") + if (state) { + const data = JSON.parse(state) + loadTabsFromPersistedState(data) + } + } catch (e) { + console.error( + `Failed parsing persisted tab state, state:`, + window.localStorage.getItem("restTabState") ) } - restRequest$.subscribe((req) => { - const reqClone = cloneDeep(req) - if (reqClone.body.contentType === "multipart/form-data") { - reqClone.body.body = reqClone.body.body.map((x) => { - if (x.isFile) - return { - ...x, - isFile: false, - value: "", - } - else return x - }) - } - window.localStorage.setItem("restRequest", JSON.stringify(reqClone)) - }) + watchDebounced( + persistableTabState, + (state) => { + window.localStorage.setItem("restTabState", JSON.stringify(state)) + }, + { debounce: 500, deep: true } + ) } export function setupLocalPersistence() { @@ -339,7 +331,7 @@ export function setupLocalPersistence() { setupLocalStatePersistence() setupSettingsPersistence() - setupRequestPersistence() + setupRESTTabsPersistence() setupHistoryPersistence() setupCollectionsPersistence() setupGlobalEnvsPersistence() diff --git a/packages/hoppscotch-common/src/pages/index.vue b/packages/hoppscotch-common/src/pages/index.vue index 87ec68713..71c40add7 100644 --- a/packages/hoppscotch-common/src/pages/index.vue +++ b/packages/hoppscotch-common/src/pages/index.vue @@ -1,51 +1,109 @@ - diff --git a/packages/hoppscotch-common/src/pages/r/_id.vue b/packages/hoppscotch-common/src/pages/r/_id.vue index d1faa769f..a810409bb 100644 --- a/packages/hoppscotch-common/src/pages/r/_id.vue +++ b/packages/hoppscotch-common/src/pages/r/_id.vue @@ -71,10 +71,11 @@ import { ResolveShortcodeQuery, ResolveShortcodeQueryVariables, } from "~/helpers/backend/graphql" -import { getDefaultRESTRequest, setRESTRequest } from "~/newstore/RESTSession" import IconHome from "~icons/lucide/home" import IconRefreshCW from "~icons/lucide/refresh-cw" +import { createNewTab } from "~/helpers/rest/tab" +import { getDefaultRESTRequest } from "~/helpers/rest/default" export default defineComponent({ setup() { @@ -106,9 +107,10 @@ export default defineComponent({ data.right.shortcode?.request as string ) - setRESTRequest( - safelyExtractRESTRequest(request, getDefaultRESTRequest()) - ) + createNewTab({ + request: safelyExtractRESTRequest(request, getDefaultRESTRequest()), + isDirty: false, + }) router.push({ path: "/" }) } diff --git a/packages/hoppscotch-common/src/platform/index.ts b/packages/hoppscotch-common/src/platform/index.ts index 5894d39c8..9c8a1ff08 100644 --- a/packages/hoppscotch-common/src/platform/index.ts +++ b/packages/hoppscotch-common/src/platform/index.ts @@ -4,6 +4,7 @@ import { EnvironmentsPlatformDef } from "./environments" import { CollectionsPlatformDef } from "./collections" import { SettingsPlatformDef } from "./settings" import { HistoryPlatformDef } from "./history" +import { TabStatePlatformDef } from "./tab" export type PlatformDef = { ui?: UIPlatformDef @@ -13,6 +14,7 @@ export type PlatformDef = { collections: CollectionsPlatformDef settings: SettingsPlatformDef history: HistoryPlatformDef + tabState: TabStatePlatformDef } } diff --git a/packages/hoppscotch-common/src/platform/tab.ts b/packages/hoppscotch-common/src/platform/tab.ts new file mode 100644 index 000000000..ea8472fcc --- /dev/null +++ b/packages/hoppscotch-common/src/platform/tab.ts @@ -0,0 +1,10 @@ +import { PersistableRESTTabState } from "~/helpers/rest/tab" +import { HoppUser } from "./auth" + +export type TabStatePlatformDef = { + loadTabStateFromSync: () => Promise + writeCurrentTabState: ( + user: HoppUser, + persistableTabState: PersistableRESTTabState + ) => Promise +} diff --git a/packages/hoppscotch-ui/src/components/smart/Window.vue b/packages/hoppscotch-ui/src/components/smart/Window.vue index ff6a82a63..0e28df2e1 100644 --- a/packages/hoppscotch-ui/src/components/smart/Window.vue +++ b/packages/hoppscotch-ui/src/components/smart/Window.vue @@ -1,5 +1,9 @@ @@ -27,16 +31,33 @@ const props = defineProps({ default: false, }, }) + const tabMeta = computed(() => ({ info: props.info, label: props.label, isRemovable: props.isRemovable, icon: slots.icon, + tabhead: slots.tabhead })) -const { activeTabID, addTabEntry, updateTabEntry, removeTabEntry } = - inject("tabs-system")! + +const { + activeTabID, + renderInactive, + addTabEntry, + updateTabEntry, + removeTabEntry, +} = inject("tabs-system")! + const active = computed(() => activeTabID.value === props.id) +const shouldRender = computed(() => { + // If render inactive is true, then it should be rendered nonetheless + if (renderInactive.value) return true + + // Else, return whatever is the active state + return active.value +}) + onMounted(() => { addTabEntry(props.id, tabMeta.value) }) diff --git a/packages/hoppscotch-ui/src/components/smart/Windows.vue b/packages/hoppscotch-ui/src/components/smart/Windows.vue index 15e4cb549..d15c4bbef 100644 --- a/packages/hoppscotch-ui/src/components/smart/Windows.vue +++ b/packages/hoppscotch-ui/src/components/smart/Windows.vue @@ -1,8 +1,8 @@