Compare commits

..

42 Commits

Author SHA1 Message Date
Joel Jacob Stephen
d13b381097 feat: added highlighting of search pattern and new search bar design 2022-05-30 22:27:21 +05:30
Joel Jacob Stephen
663da34e08 feat: realtime search through logs 2022-05-30 22:24:41 +05:30
liyasthomas
eb6c4f1a05 feat: add copy to clipboard button in response headers entry 2022-05-28 15:39:17 +05:30
liyasthomas
b132077cdd chore: improve ui consistency 2022-05-28 15:19:04 +05:30
Anwarul Islam
f6950bac0f refactor: real-time system (#2228)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: liyasthomas <liyascthomas@gmail.com>
2022-05-28 15:05:41 +05:30
kyteinsky
83bdd03f43 fix: curl parser url sanitisation (#2366) 2022-05-27 14:48:33 +05:30
liyasthomas
b1a2c9e9d5 fix: text overflow in table layout 2022-05-25 17:42:48 +05:30
liyasthomas
346ac8bde9 chore: improve ui consistency on table layout 2022-05-24 18:48:45 +05:30
Nivedin
cfa89a6ded feat: UI of shortcode actions (#2347)
Co-authored-by: liyasthomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2022-05-24 17:58:49 +05:30
Akash K
184914ba4f feat: extension identification improvements (#2332)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2022-05-19 13:41:05 +05:30
Deepanshu Dhruw
432337b801 chore: tests for hoppscotch-cli (#2300)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2022-05-11 15:44:19 +05:30
Andrew Bastin
d04520698d refactor: isolate computed header calculation on effective requests (#2313)
Co-authored-by: liyasthomas <liyascthomas@gmail.com>
2022-05-11 14:53:06 +05:30
liyasthomas
450af983e2 fix: overflow on log entry 2022-05-10 19:34:37 +05:30
Balázs Úr
c36e421d13 chore(i18n): updated Hungarian translation (#2331) 2022-05-10 11:42:54 +05:30
Joel Jacob Stephen
fb1da491d8 refactor: realtime log entry revamp (#2240)
Co-authored-by: liyasthomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2022-05-10 02:05:24 +05:30
Andrew Bastin
127bd7318f fix: improve indentation on GQL editors 2022-05-07 23:33:48 +05:30
Nivedin
c3b784c680 fix : save request popup bug (#2324) 2022-05-05 20:46:28 +05:30
Andrew Bastin
df55807fa4 fix: rest session not updating when the request is renamed from the sidebar (fixes #2297) 2022-05-05 20:07:13 +05:30
Akash K
4ef2844a22 feat: import collections from URL (#2262)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2022-05-03 17:54:47 +05:30
Andrew Bastin
c20339d222 fix: deprecated pnpx usage migrated to pnpm exec 2022-05-03 12:39:56 +05:30
liyasthomas
514210e167 fix: proper scrollbar width - resolved #2301 2022-04-30 19:53:23 +05:30
Anwarul Islam
50744136d0 fix: same key params are not overwritten to the last defined (#2299)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2022-04-30 19:23:43 +05:30
liyasthomas
07735994e1 refactor: update echo server URLs 2022-04-29 06:40:38 +05:30
liyasthomas
00acf06700 chore(i18n): updated translations 2022-04-26 12:48:56 +05:30
Andrew Bastin
9486279b02 fix: first env edit not being applied (thanks @17307) 2022-04-23 00:16:39 +05:30
Andrew Bastin
021908c8d1 fix: regressions in environment details modal 2022-04-22 11:58:29 +05:30
liyasthomas
bf97d8811c fix: proper toast for actions on environments - resolved #2279 2022-04-22 11:40:47 +05:30
kyteinsky
2452b1be4b feat: loading states for modal buttons (#2268) 2022-04-20 23:48:25 +05:30
liyasthomas
7bf76a5812 fix: remove newline in smart inputs - resolved #2277 2022-04-19 23:54:04 +05:30
Andrew Bastin
d1b339df5d chore: hoppscotch-cli release 0.1.14 2022-04-18 22:49:34 +05:30
Deepanshu Dhruw
06937fe9e8 feat: added execution duration and updated collection-metrics (#2257) 2022-04-18 22:43:43 +05:30
Andrew Bastin
62a5beb52f chore: update 'post' method usages to be uppercased as per HTTP spec 2022-04-18 21:37:15 +05:30
Kane Sweet
100e562dcc fix: add aria labels to forms (#2270) 2022-04-18 10:01:04 +05:30
liyasthomas
09af858fbc chore(deps): bump 2022-04-17 22:41:00 +05:30
Nivedin
01acbc8db6 refactor : migrate components to script setup on ts (#2267)
* refactor: migrate buttons to script setup on ts

* refactor: migrate env components to script setup on ts

* fix: reference sharing when requests are opened from the sidebar

* ci: deploy to prod from actions

* chore: type updation

* chore: update

* refactor: update types

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: liyasthomas <liyascthomas@gmail.com>
2022-04-17 22:29:32 +05:30
liyasthomas
f1c42f28de ci: deploy to prod from actions 2022-04-15 14:58:07 +05:30
Andrew Bastin
c99b224829 fix: reference sharing when requests are opened from the sidebar 2022-04-15 00:20:35 +05:30
Andrew Bastin
ede27e0600 refactor: implement updated equality heuristics for hopprestrequest struct 2022-04-14 20:28:59 +05:30
Nivedin
99148a0a0e feat: unsaved change popup (#2239)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: liyasthomas <liyascthomas@gmail.com>
2022-04-14 20:28:58 +05:30
liyasthomas
9232aad184 refactor: improve ui consistency 2022-04-14 19:00:04 +05:30
liyasthomas
f7ca3f8bd1 feat: add essential keymaps to codemirror input box - resolved #2264 2022-04-14 18:37:25 +05:30
kyteinsky
ff51b7e5df feat: add New Request button for folder and collection (#2241)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2022-04-14 17:09:02 +05:30
187 changed files with 10034 additions and 4460 deletions

34
.github/workflows/deploy-netlify.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Deploy to Netlify
on:
push:
branches: [main]
jobs:
build:
name: Push build files to Netlify
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v2
- name: Install pnpm
run: curl -f https://get.pnpm.io/v6.14.js | node - add --global pnpm@6
- name: Install Dependencies
run: pnpm install
- name: Setup Environment
run: mv packages/hoppscotch-app/.env.example packages/hoppscotch-app/.env
- name: Build Site
run: pnpm run generate
# Deploy the site with netlify-cli
- name: Deploy to Netlify
uses: netlify/actions/cli@master
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
with:
args: deploy --dir=packages/hoppscotch-app/dist --prod

View File

@@ -22,11 +22,11 @@
], ],
"dependencies": { "dependencies": {
"husky": "^7.0.4", "husky": "^7.0.4",
"lint-staged": "^12.3.7" "lint-staged": "^12.3.8"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^16.2.3", "@commitlint/cli": "^16.2.3",
"@commitlint/config-conventional": "^16.2.1", "@commitlint/config-conventional": "^16.2.1",
"@types/node": "^17.0.23" "@types/node": "^17.0.24"
} }
} }

View File

@@ -24,9 +24,9 @@
"devDependencies": { "devDependencies": {
"@lezer/generator": "^0.15.4", "@lezer/generator": "^0.15.4",
"mocha": "^9.2.2", "mocha": "^9.2.2",
"rollup": "^2.70.1", "rollup": "^2.70.2",
"rollup-plugin-dts": "^4.2.1", "rollup-plugin-dts": "^4.2.1",
"rollup-plugin-ts": "^2.0.5", "rollup-plugin-ts": "^2.0.7",
"typescript": "^4.6.3" "typescript": "^4.6.3"
} }
} }

View File

@@ -338,15 +338,17 @@ TypeSystemDirectiveLocation {
| @specialize<Name, "INPUT_FIELD_DEFINITION"> | @specialize<Name, "INPUT_FIELD_DEFINITION">
} }
@skip { Whitespace | Comment } @skip { whitespace | Comment }
@tokens { @tokens {
Whitespace { whitespace {
std.whitespace+ std.whitespace+
} }
StringValue { StringValue {
"\"\"\"" (!["] | "\\n" | "\"" "\""? !["])* "\"\"\"" | "\"" !["\\\n]* "\"" "\"\"\"" (!["] | "\\n" | "\"" "\""? !["])* "\"\"\"" | "\"" !["\\\n]* "\""
} }
IntValue { IntValue {
"-"? "0" "-"? "0"
| "-"? std.digit+ | "-"? std.digit+
@@ -367,6 +369,8 @@ TypeSystemDirectiveLocation {
Comma { Comma {
"," ","
} }
"{" "}" "[" "]"
} }
@detectDelim @detectDelim

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="17" y1="7" x2="7" y2="17"></line>
<polyline points="17 17 7 17 7 7"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<polyline points="19 12 12 19 5 12"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="7" y1="17" x2="17" y2="7"></line>
<polyline points="7 7 17 7 17 17"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="7 13 12 18 17 13"></polyline>
<polyline points="7 6 12 11 17 6"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 11 12 6 7 11"></polyline>
<polyline points="17 18 12 13 7 18"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@@ -8,11 +8,7 @@
{{ t("settings.interceptor_description") }} {{ t("settings.interceptor_description") }}
</p> </p>
</div> </div>
<SmartRadioGroup <SmartRadioGroup v-model="interceptorSelection" :radios="interceptors" />
:radios="interceptors"
:selected="interceptorSelection"
@change="toggleSettingKey"
/>
<div <div
v-if="interceptorSelection == 'EXTENSIONS_ENABLED' && !extensionVersion" v-if="interceptorSelection == 'EXTENSIONS_ENABLED' && !extensionVersion"
class="flex space-x-2" class="flex space-x-2"
@@ -38,58 +34,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watchEffect } from "@nuxtjs/composition-api" import { computed } from "@nuxtjs/composition-api"
import { KeysMatching } from "~/types/ts-utils" import { applySetting, toggleSetting, useSetting } from "~/newstore/settings"
import { import { useI18n, useReadonlyStream } from "~/helpers/utils/composables"
applySetting, import { extensionStatus$ } from "~/newstore/HoppExtension"
SettingsType,
toggleSetting,
useSetting,
} from "~/newstore/settings"
import { hasExtensionInstalled } from "~/helpers/strategies/ExtensionStrategy"
import { useI18n, usePolled } from "~/helpers/utils/composables"
const t = useI18n() const t = useI18n()
const PROXY_ENABLED = useSetting("PROXY_ENABLED") const PROXY_ENABLED = useSetting("PROXY_ENABLED")
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED") const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
const toggleSettingKey = < const currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
K extends KeysMatching<SettingsType | "BROWSER_ENABLED", boolean>
>(
key: K
) => {
interceptorSelection.value = key
if (key === "EXTENSIONS_ENABLED") {
applySetting("EXTENSIONS_ENABLED", true)
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
}
if (key === "PROXY_ENABLED") {
applySetting("PROXY_ENABLED", true)
if (EXTENSIONS_ENABLED.value) toggleSetting("EXTENSIONS_ENABLED")
}
if (key === "BROWSER_ENABLED") {
applySetting("PROXY_ENABLED", false)
applySetting("EXTENSIONS_ENABLED", false)
}
}
const extensionVersion = usePolled(5000, (stopPolling) => { const extensionVersion = computed(() => {
const result = hasExtensionInstalled() return currentExtensionStatus.value === "available"
? window.__POSTWOMAN_EXTENSION_HOOK__.getVersion() ? window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() ?? null
: null : null
// We don't need to poll anymore after we get value
if (result) stopPolling()
return result
}) })
const interceptors = computed(() => [ const interceptors = computed(() => [
{ value: "BROWSER_ENABLED", label: t("state.none") }, { value: "BROWSER_ENABLED" as const, label: t("state.none") },
{ value: "PROXY_ENABLED", label: t("settings.proxy") }, { value: "PROXY_ENABLED" as const, label: t("settings.proxy") },
{ {
value: "EXTENSIONS_ENABLED", value: "EXTENSIONS_ENABLED" as const,
label: label:
`${t("settings.extensions")}: ` + `${t("settings.extensions")}: ` +
(extensionVersion.value !== null (extensionVersion.value !== null
@@ -98,15 +65,27 @@ const interceptors = computed(() => [
}, },
]) ])
const interceptorSelection = ref("") type InterceptorMode = typeof interceptors["value"][number]["value"]
watchEffect(() => { const interceptorSelection = computed<InterceptorMode>({
if (PROXY_ENABLED.value) { get() {
interceptorSelection.value = "PROXY_ENABLED" if (PROXY_ENABLED.value) return "PROXY_ENABLED"
} else if (EXTENSIONS_ENABLED.value) { if (EXTENSIONS_ENABLED.value) return "EXTENSIONS_ENABLED"
interceptorSelection.value = "EXTENSIONS_ENABLED" return "BROWSER_ENABLED"
} else { },
interceptorSelection.value = "BROWSER_ENABLED" set(val) {
if (val === "EXTENSIONS_ENABLED") {
applySetting("EXTENSIONS_ENABLED", true)
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
} }
if (val === "PROXY_ENABLED") {
applySetting("PROXY_ENABLED", true)
if (EXTENSIONS_ENABLED.value) toggleSetting("EXTENSIONS_ENABLED")
}
if (val === "BROWSER_ENABLED") {
applySetting("PROXY_ENABLED", false)
applySetting("EXTENSIONS_ENABLED", false)
}
},
}) })
</script> </script>

View File

@@ -28,7 +28,7 @@
</Splitpanes> </Splitpanes>
</Pane> </Pane>
<Pane <Pane
v-if="SIDEBAR" v-if="SIDEBAR && hasSidebar"
size="25" size="25"
min-size="20" min-size="20"
class="hide-scrollbar !overflow-auto flex flex-col" class="hide-scrollbar !overflow-auto flex flex-col"
@@ -42,6 +42,7 @@
import { Splitpanes, Pane } from "splitpanes" import { Splitpanes, Pane } from "splitpanes"
import "splitpanes/dist/splitpanes.css" import "splitpanes/dist/splitpanes.css"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core" import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { computed, useSlots } from "@nuxtjs/composition-api"
import { useSetting } from "~/newstore/settings" import { useSetting } from "~/newstore/settings"
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT") const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
@@ -52,4 +53,8 @@ const mdAndLarger = breakpoints.greater("md")
const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT") const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
const SIDEBAR = useSetting("SIDEBAR") const SIDEBAR = useSetting("SIDEBAR")
const slots = useSlots()
const hasSidebar = computed(() => !!slots.sidebar)
</script> </script>

View File

@@ -25,7 +25,6 @@
]" ]"
:disabled="disabled" :disabled="disabled"
:tabindex="loading ? '-1' : '0'" :tabindex="loading ? '-1' : '0'"
:type="type"
role="button" role="button"
> >
<span <span
@@ -67,79 +66,41 @@
</SmartLink> </SmartLink>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from "@nuxtjs/composition-api" interface Props {
to: string
export default defineComponent({ exact: boolean
props: { blank: boolean
to: { label: string
type: String, icon: string
default: "", svg: string
}, color: string
exact: { disabled: boolean
type: Boolean, loading: boolean
default: true, large: boolean
}, shadow: boolean
blank: { reverse: boolean
type: Boolean, rounded: boolean
default: false, gradient: boolean
}, outline: boolean
label: { shortcut: string[]
type: String, }
default: "", withDefaults(defineProps<Props>(), {
}, to: "",
icon: { exact: true,
type: String, blank: false,
default: "", label: "",
}, icon: "",
svg: { svg: "",
type: String, color: "",
default: "", disabled: false,
}, loading: false,
color: { large: false,
type: String, shadow: false,
default: "", reverse: false,
}, rounded: false,
disabled: { gradient: false,
type: Boolean, outline: false,
default: false, shortcut: () => [],
},
loading: {
type: Boolean,
default: false,
},
large: {
type: Boolean,
default: false,
},
shadow: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
rounded: {
type: Boolean,
default: false,
},
gradient: {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
shortcut: {
type: Array,
default: () => [],
},
type: {
type: String,
default: "button",
},
},
}) })
</script> </script>

View File

@@ -66,71 +66,39 @@
</SmartLink> </SmartLink>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from "@nuxtjs/composition-api" interface Props {
to: string
export default defineComponent({ exact: boolean
props: { blank: boolean
to: { label: string
type: String, icon: string
default: "", svg: string
}, color: string
exact: { disabled: boolean
type: Boolean, loading: boolean
default: true, reverse: boolean
}, rounded: boolean
blank: { large: boolean
type: Boolean, outline: boolean
default: false, shortcut: string[]
}, filled: boolean
label: { }
type: String, withDefaults(defineProps<Props>(), {
default: "", to: "",
}, exact: true,
icon: { blank: false,
type: String, label: "",
default: "", icon: "",
}, svg: "",
svg: { color: "",
type: String, disabled: false,
default: "", loading: false,
}, reverse: false,
color: { rounded: false,
type: String, large: false,
default: "", outline: false,
}, shortcut: () => [],
disabled: { filled: false,
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
rounded: {
type: Boolean,
default: false,
},
large: {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
shortcut: {
type: Array,
default: () => [],
},
filled: {
type: Boolean,
default: false,
},
},
}) })
</script> </script>

View File

@@ -26,6 +26,7 @@
<span> <span>
<ButtonPrimary <ButtonPrimary
:label="$t('action.save')" :label="$t('action.save')"
:loading="loadingState"
@click.native="addNewCollection" @click.native="addNewCollection"
/> />
<ButtonSecondary <ButtonSecondary
@@ -43,6 +44,7 @@ import { defineComponent } from "@nuxtjs/composition-api"
export default defineComponent({ export default defineComponent({
props: { props: {
show: Boolean, show: Boolean,
loadingState: Boolean,
}, },
data() { data() {
return { return {
@@ -56,7 +58,6 @@ export default defineComponent({
return return
} }
this.$emit("submit", this.name) this.$emit("submit", this.name)
this.hideModal()
}, },
hideModal() { hideModal() {
this.name = null this.name = null

View File

@@ -24,7 +24,11 @@
</template> </template>
<template #footer> <template #footer>
<span> <span>
<ButtonPrimary :label="$t('action.save')" @click.native="addFolder" /> <ButtonPrimary
:label="$t('action.save')"
:loading="loadingState"
@click.native="addFolder"
/>
<ButtonSecondary <ButtonSecondary
:label="$t('action.cancel')" :label="$t('action.cancel')"
@click.native="hideModal" @click.native="hideModal"
@@ -43,6 +47,7 @@ export default defineComponent({
folder: { type: Object, default: () => {} }, folder: { type: Object, default: () => {} },
folderPath: { type: String, default: null }, folderPath: { type: String, default: null },
collectionIndex: { type: Number, default: null }, collectionIndex: { type: Number, default: null },
loadingState: Boolean,
}, },
data() { data() {
return { return {
@@ -60,7 +65,6 @@ export default defineComponent({
folder: this.folder, folder: this.folder,
path: this.folderPath || `${this.collectionIndex}`, path: this.folderPath || `${this.collectionIndex}`,
}) })
this.hideModal()
}, },
hideModal() { hideModal() {
this.name = null this.name = null

View File

@@ -0,0 +1,92 @@
<template>
<SmartModal
v-if="show"
dialog
:title="$t('request.new')"
@close="$emit('hide-modal')"
>
<template #body>
<div class="flex flex-col px-2">
<input
id="selectLabelAddRequest"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addRequest"
/>
<label for="selectLabelAddRequest">{{ $t("action.label") }}</label>
</div>
</template>
<template #footer>
<span>
<ButtonPrimary
:label="$t('action.save')"
:loading="loadingState"
@click.native="addRequest"
/>
<ButtonSecondary
:label="$t('action.cancel')"
@click.native="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script setup lang="ts">
import { ref, watch } from "@nuxtjs/composition-api"
import { useI18n, useToast } from "~/helpers/utils/composables"
import { getRESTRequest } from "~/newstore/RESTSession"
const toast = useToast()
const t = useI18n()
const props = defineProps<{
show: boolean
loadingState: boolean
folder?: object
folderPath?: string
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
(
e: "add-request",
v: {
name: string
folder: object | undefined
path: string | undefined
}
): void
}>()
const name = ref("")
watch(
() => props.show,
(show) => {
if (show) {
name.value = getRESTRequest().name
}
}
)
const addRequest = () => {
if (!name.value) {
toast.error(`${t("error.empty_req_name")}`)
return
}
emit("add-request", {
name: name.value,
folder: props.folder,
path: props.folderPath,
})
}
const hideModal = () => {
emit("hide-modal")
}
</script>

View File

@@ -26,6 +26,7 @@
<span> <span>
<ButtonPrimary <ButtonPrimary
:label="$t('action.save')" :label="$t('action.save')"
:loading="loadingState"
@click.native="saveCollection" @click.native="saveCollection"
/> />
<ButtonSecondary <ButtonSecondary
@@ -44,6 +45,7 @@ export default defineComponent({
props: { props: {
show: Boolean, show: Boolean,
editingCollectionName: { type: String, default: null }, editingCollectionName: { type: String, default: null },
loadingState: Boolean,
}, },
data() { data() {
return { return {
@@ -62,7 +64,6 @@ export default defineComponent({
return return
} }
this.$emit("submit", this.name) this.$emit("submit", this.name)
this.hideModal()
}, },
hideModal() { hideModal() {
this.name = null this.name = null

View File

@@ -24,7 +24,11 @@
</template> </template>
<template #footer> <template #footer>
<span> <span>
<ButtonPrimary :label="$t('action.save')" @click.native="editFolder" /> <ButtonPrimary
:label="$t('action.save')"
:loading="loadingState"
@click.native="editFolder"
/>
<ButtonSecondary <ButtonSecondary
:label="$t('action.cancel')" :label="$t('action.cancel')"
@click.native="hideModal" @click.native="hideModal"
@@ -41,6 +45,7 @@ export default defineComponent({
props: { props: {
show: Boolean, show: Boolean,
editingFolderName: { type: String, default: null }, editingFolderName: { type: String, default: null },
loadingState: Boolean,
}, },
data() { data() {
return { return {
@@ -59,7 +64,6 @@ export default defineComponent({
return return
} }
this.$emit("submit", this.name) this.$emit("submit", this.name)
this.hideModal()
}, },
hideModal() { hideModal() {
this.name = null this.name = null

View File

@@ -24,7 +24,11 @@
</template> </template>
<template #footer> <template #footer>
<span> <span>
<ButtonPrimary :label="$t('action.save')" @click.native="saveRequest" /> <ButtonPrimary
:label="$t('action.save')"
:loading="loadingState"
@click.native="saveRequest"
/>
<ButtonSecondary <ButtonSecondary
:label="$t('action.cancel')" :label="$t('action.cancel')"
@click.native="hideModal" @click.native="hideModal"
@@ -41,6 +45,7 @@ export default defineComponent({
props: { props: {
show: Boolean, show: Boolean,
editingRequestName: { type: String, default: null }, editingRequestName: { type: String, default: null },
loadingState: Boolean,
}, },
data() { data() {
return { return {
@@ -61,7 +66,6 @@ export default defineComponent({
return return
} }
this.$emit("submit", this.requestUpdateData) this.$emit("submit", this.requestUpdateData)
this.hideModal()
}, },
hideModal() { hideModal() {
this.requestUpdateData = { name: null } this.requestUpdateData = { name: null }

View File

@@ -233,6 +233,7 @@ const saveRequestAs = async () => {
originLocation: "user-collection", originLocation: "user-collection",
folderPath: picked.value.folderPath, folderPath: picked.value.folderPath,
requestIndex: picked.value.requestIndex, requestIndex: picked.value.requestIndex,
req: cloneDeep(requestUpdated),
}) })
requestSaved() requestSaved()
@@ -249,6 +250,7 @@ const saveRequestAs = async () => {
originLocation: "user-collection", originLocation: "user-collection",
folderPath: picked.value.folderPath, folderPath: picked.value.folderPath,
requestIndex: insertionIndex, requestIndex: insertionIndex,
req: cloneDeep(requestUpdated),
}) })
requestSaved() requestSaved()
@@ -265,6 +267,7 @@ const saveRequestAs = async () => {
originLocation: "user-collection", originLocation: "user-collection",
folderPath: `${picked.value.collectionIndex}`, folderPath: `${picked.value.collectionIndex}`,
requestIndex: insertionIndex, requestIndex: insertionIndex,
req: cloneDeep(requestUpdated),
}) })
requestSaved() requestSaved()
@@ -293,6 +296,7 @@ const saveRequestAs = async () => {
setRESTSaveContext({ setRESTSaveContext({
originLocation: "team-collection", originLocation: "team-collection",
requestID: picked.value.requestID, requestID: picked.value.requestID,
req: cloneDeep(requestUpdated),
}) })
} else if (picked.value.pickedType === "teams-folder") { } else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated)) if (!isHoppRESTRequest(requestUpdated))
@@ -319,6 +323,7 @@ const saveRequestAs = async () => {
requestID: result.right.createRequestInCollection.id, requestID: result.right.createRequestInCollection.id,
teamID: collectionsType.value.selectedTeam.id, teamID: collectionsType.value.selectedTeam.id,
collectionID: picked.value.folderID, collectionID: picked.value.folderID,
req: cloneDeep(requestUpdated),
}) })
requestSaved() requestSaved()
@@ -348,6 +353,7 @@ const saveRequestAs = async () => {
requestID: result.right.createRequestInCollection.id, requestID: result.right.createRequestInCollection.id,
teamID: collectionsType.value.selectedTeam.id, teamID: collectionsType.value.selectedTeam.id,
collectionID: picked.value.collectionID, collectionID: picked.value.collectionID,
req: cloneDeep(requestUpdated),
}) })
requestSaved() requestSaved()

View File

@@ -50,12 +50,16 @@ export default defineComponent({
}, },
methods: { methods: {
addFolder() { addFolder() {
// TODO: Blocking when name is null ? if (!this.name) {
this.$toast.error(`${this.$t("folder.name_length_insufficient")}`)
return
}
this.$emit("add-folder", { this.$emit("add-folder", {
name: this.name, name: this.name,
path: this.folderPath || `${this.collectionIndex}`, path: this.folderPath || `${this.collectionIndex}`,
}) })
this.hideModal() this.hideModal()
}, },
hideModal() { hideModal() {

View File

@@ -0,0 +1,87 @@
<template>
<SmartModal
v-if="show"
dialog
:title="$t('request.new')"
@close="$emit('hide-modal')"
>
<template #body>
<div class="flex flex-col px-2">
<input
id="selectLabelGqlAddRequest"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addRequest"
/>
<label for="selectLabelGqlAddRequest">
{{ $t("action.label") }}
</label>
</div>
</template>
<template #footer>
<span>
<ButtonPrimary :label="$t('action.save')" @click.native="addRequest" />
<ButtonSecondary
:label="$t('action.cancel')"
@click.native="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script setup lang="ts">
import { ref, watch } from "@nuxtjs/composition-api"
import { useI18n, useToast } from "~/helpers/utils/composables"
import { getGQLSession } from "~/newstore/GQLSession"
const toast = useToast()
const t = useI18n()
const props = defineProps<{
show: boolean
folderPath?: string
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
(
e: "add-request",
v: {
name: string
path: string | undefined
}
): void
}>()
const name = ref("")
watch(
() => props.show,
(show) => {
if (show) {
name.value = getGQLSession().request.name
}
}
)
const addRequest = () => {
if (!name.value) {
toast.error(`${t("error.empty_req_name")}`)
return
}
emit("add-request", {
name: name.value,
path: props.folderPath,
})
hideModal()
}
const hideModal = () => {
emit("hide-modal")
}
</script>

View File

@@ -29,6 +29,17 @@
</span> </span>
</span> </span>
<div class="flex"> <div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
svg="file-plus"
:title="$t('request.new')"
class="hidden group-hover:inline-flex"
@click.native="
$emit('add-request', {
path: `${collectionIndex}`,
})
"
/>
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
svg="folder-plus" svg="folder-plus"
@@ -61,11 +72,26 @@
class="flex flex-col focus:outline-none" class="flex flex-col focus:outline-none"
tabindex="0" tabindex="0"
role="menu" role="menu"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()" @keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()" @keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()" @keyup.delete="deleteAction.$el.click()"
@keyup.escape="options.tippy().hide()" @keyup.escape="options.tippy().hide()"
> >
<SmartItem
ref="requestAction"
svg="file-plus"
:label="`${$t('request.new')}`"
:shortcut="['R']"
@click.native="
() => {
$emit('add-request', {
path: `${collectionIndex}`,
})
options.tippy().hide()
}
"
/>
<SmartItem <SmartItem
ref="folderAction" ref="folderAction"
svg="folder-plus" svg="folder-plus"
@@ -126,6 +152,7 @@
:collection-index="collectionIndex" :collection-index="collectionIndex"
:doc="doc" :doc="doc"
:is-filtered="isFiltered" :is-filtered="isFiltered"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)" @add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)" @edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)" @edit-request="$emit('edit-request', $event)"
@@ -196,6 +223,7 @@ export default defineComponent({
return { return {
tippyActions: ref<any | null>(null), tippyActions: ref<any | null>(null),
options: ref<any | null>(null), options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null), folderAction: ref<any | null>(null),
edit: ref<any | null>(null), edit: ref<any | null>(null),
deleteAction: ref<any | null>(null), deleteAction: ref<any | null>(null),

View File

@@ -29,6 +29,13 @@
</span> </span>
</span> </span>
<div class="flex"> <div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
svg="file-plus"
:title="$t('request.new')"
class="hidden group-hover:inline-flex"
@click.native="$emit('add-request', { path: folderPath })"
/>
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
svg="folder-plus" svg="folder-plus"
@@ -57,11 +64,24 @@
class="flex flex-col focus:outline-none" class="flex flex-col focus:outline-none"
tabindex="0" tabindex="0"
role="menu" role="menu"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()" @keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()" @keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()" @keyup.delete="deleteAction.$el.click()"
@keyup.escape="options.tippy().hide()" @keyup.escape="options.tippy().hide()"
> >
<SmartItem
ref="requestAction"
svg="file-plus"
:label="`${$t('request.new')}`"
:shortcut="['R']"
@click.native="
() => {
$emit('add-request', { path: folderPath })
options.tippy().hide()
}
"
/>
<SmartItem <SmartItem
ref="folderAction" ref="folderAction"
svg="folder-plus" svg="folder-plus"
@@ -120,6 +140,7 @@
:collection-index="collectionIndex" :collection-index="collectionIndex"
:doc="doc" :doc="doc"
:is-filtered="isFiltered" :is-filtered="isFiltered"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)" @add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)" @edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)" @edit-request="$emit('edit-request', $event)"
@@ -193,6 +214,7 @@ export default defineComponent({
return { return {
tippyActions: ref<any | null>(null), tippyActions: ref<any | null>(null),
options: ref<any | null>(null), options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null), folderAction: ref<any | null>(null),
edit: ref<any | null>(null), edit: ref<any | null>(null),
deleteAction: ref<any | null>(null), deleteAction: ref<any | null>(null),

View File

@@ -49,6 +49,7 @@
:is-filtered="filterText.length > 0" :is-filtered="filterText.length > 0"
:saving-mode="savingMode" :saving-mode="savingMode"
@edit-collection="editCollection(collection, index)" @edit-collection="editCollection(collection, index)"
@add-request="addRequest($event)"
@add-folder="addFolder($event)" @add-folder="addFolder($event)"
@edit-folder="editFolder($event)" @edit-folder="editFolder($event)"
@edit-request="editRequest($event)" @edit-request="editRequest($event)"
@@ -96,6 +97,12 @@
:editing-collection-name="editingCollection ? editingCollection.name : ''" :editing-collection-name="editingCollection ? editingCollection.name : ''"
@hide-modal="displayModalEdit(false)" @hide-modal="displayModalEdit(false)"
/> />
<CollectionsGraphqlAddRequest
:show="showModalAddRequest"
:folder-path="editingFolderPath"
@add-request="onAddRequest($event)"
@hide-modal="displayModalAddRequest(false)"
/>
<CollectionsGraphqlAddFolder <CollectionsGraphqlAddFolder
:show="showModalAddFolder" :show="showModalAddFolder"
:folder-path="editingFolderPath" :folder-path="editingFolderPath"
@@ -136,6 +143,7 @@ import {
addGraphqlFolder, addGraphqlFolder,
saveGraphqlRequestAs, saveGraphqlRequestAs,
} from "~/newstore/collections" } from "~/newstore/collections"
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -156,6 +164,7 @@ export default defineComponent({
showModalAdd: false, showModalAdd: false,
showModalEdit: false, showModalEdit: false,
showModalImportExport: false, showModalImportExport: false,
showModalAddRequest: false,
showModalAddFolder: false, showModalAddFolder: false,
showModalEditFolder: false, showModalEditFolder: false,
showModalEditRequest: false, showModalEditRequest: false,
@@ -222,6 +231,11 @@ export default defineComponent({
displayModalImportExport(shouldDisplay) { displayModalImportExport(shouldDisplay) {
this.showModalImportExport = shouldDisplay this.showModalImportExport = shouldDisplay
}, },
displayModalAddRequest(shouldDisplay) {
this.showModalAddRequest = shouldDisplay
if (!shouldDisplay) this.resetSelectedData()
},
displayModalAddFolder(shouldDisplay) { displayModalAddFolder(shouldDisplay) {
this.showModalAddFolder = shouldDisplay this.showModalAddFolder = shouldDisplay
@@ -242,6 +256,26 @@ export default defineComponent({
this.$data.editingCollectionIndex = collectionIndex this.$data.editingCollectionIndex = collectionIndex
this.displayModalEdit(true) this.displayModalEdit(true)
}, },
onAddRequest({ name, path }) {
const newRequest = {
...getGQLSession().request,
name,
}
saveGraphqlRequestAs(path, newRequest)
setGQLSession({
request: newRequest,
schema: "",
response: "",
})
this.displayModalAddRequest(false)
},
addRequest(payload) {
const { path } = payload
this.$data.editingFolderPath = path
this.displayModalAddRequest(true)
},
onAddFolder({ name, path }) { onAddFolder({ name, path }) {
addGraphqlFolder(name, path) addGraphqlFolder(name, path)
this.displayModalAddFolder(false) this.displayModalAddFolder(false)

View File

@@ -82,6 +82,7 @@
:picked="picked" :picked="picked"
:loading-collection-i-ds="loadingCollectionIDs" :loading-collection-i-ds="loadingCollectionIDs"
@edit-collection="editCollection(collection, index)" @edit-collection="editCollection(collection, index)"
@add-request="addRequest($event)"
@add-folder="addFolder($event)" @add-folder="addFolder($event)"
@edit-folder="editFolder($event)" @edit-folder="editFolder($event)"
@edit-request="editRequest($event)" @edit-request="editRequest($event)"
@@ -93,6 +94,7 @@
@expand-collection="expandCollection" @expand-collection="expandCollection"
@remove-collection="removeCollection" @remove-collection="removeCollection"
@remove-request="removeRequest" @remove-request="removeRequest"
@remove-folder="removeFolder"
/> />
</div> </div>
<div <div
@@ -146,6 +148,7 @@
</div> </div>
<CollectionsAdd <CollectionsAdd
:show="showModalAdd" :show="showModalAdd"
:loading-state="modalLoadingState"
@submit="addNewRootCollection" @submit="addNewRootCollection"
@hide-modal="displayModalAdd(false)" @hide-modal="displayModalAdd(false)"
/> />
@@ -156,13 +159,23 @@
? editingCollection.name || editingCollection.title ? editingCollection.name || editingCollection.title
: '' : ''
" "
:loading-state="modalLoadingState"
@hide-modal="displayModalEdit(false)" @hide-modal="displayModalEdit(false)"
@submit="updateEditingCollection" @submit="updateEditingCollection"
/> />
<CollectionsAddRequest
:show="showModalAddRequest"
:folder="editingFolder"
:folder-path="editingFolderPath"
:loading-state="modalLoadingState"
@add-request="onAddRequest($event)"
@hide-modal="displayModalAddRequest(false)"
/>
<CollectionsAddFolder <CollectionsAddFolder
:show="showModalAddFolder" :show="showModalAddFolder"
:folder="editingFolder" :folder="editingFolder"
:folder-path="editingFolderPath" :folder-path="editingFolderPath"
:loading-state="modalLoadingState"
@add-folder="onAddFolder($event)" @add-folder="onAddFolder($event)"
@hide-modal="displayModalAddFolder(false)" @hide-modal="displayModalAddFolder(false)"
/> />
@@ -171,12 +184,14 @@
:editing-folder-name=" :editing-folder-name="
editingFolder ? editingFolder.name || editingFolder.title : '' editingFolder ? editingFolder.name || editingFolder.title : ''
" "
:loading-state="modalLoadingState"
@submit="updateEditingFolder" @submit="updateEditingFolder"
@hide-modal="displayModalEditFolder(false)" @hide-modal="displayModalEditFolder(false)"
/> />
<CollectionsEditRequest <CollectionsEditRequest
:show="showModalEditRequest" :show="showModalEditRequest"
:editing-request-name="editingRequest ? editingRequest.name : ''" :editing-request-name="editingRequest ? editingRequest.name : ''"
:loading-state="modalLoadingState"
@submit="updateEditingRequest" @submit="updateEditingRequest"
@hide-modal="displayModalEditRequest(false)" @hide-modal="displayModalEditRequest(false)"
/> />
@@ -186,6 +201,13 @@
@hide-modal="displayModalImportExport(false)" @hide-modal="displayModalImportExport(false)"
@update-team-collections="updateTeamCollections" @update-team-collections="updateTeamCollections"
/> />
<SmartConfirmModal
:show="showConfirmModal"
:title="confirmModalTitle"
:loading-state="modalLoadingState"
@hide-modal="showConfirmModal = false"
@resolve="resolveConfirmModal"
/>
</div> </div>
</template> </template>
@@ -204,11 +226,17 @@ import {
editRESTCollection, editRESTCollection,
addRESTFolder, addRESTFolder,
removeRESTCollection, removeRESTCollection,
removeRESTFolder,
editRESTFolder, editRESTFolder,
removeRESTRequest, removeRESTRequest,
editRESTRequest, editRESTRequest,
saveRESTRequestAs, saveRESTRequestAs,
} from "~/newstore/collections" } from "~/newstore/collections"
import {
setRESTRequest,
getRESTRequest,
getRESTSaveContext,
} from "~/newstore/RESTSession"
import { import {
useReadonlyStream, useReadonlyStream,
useStreamSubscriber, useStreamSubscriber,
@@ -250,17 +278,22 @@ export default defineComponent({
showModalAdd: false, showModalAdd: false,
showModalEdit: false, showModalEdit: false,
showModalImportExport: false, showModalImportExport: false,
showModalAddRequest: false,
showModalAddFolder: false, showModalAddFolder: false,
showModalEditFolder: false, showModalEditFolder: false,
showModalEditRequest: false, showModalEditRequest: false,
showConfirmModal: false,
modalLoadingState: false,
editingCollection: undefined, editingCollection: undefined,
editingCollectionIndex: undefined, editingCollectionIndex: undefined,
editingCollectionID: undefined,
editingFolder: undefined, editingFolder: undefined,
editingFolderName: undefined, editingFolderName: undefined,
editingFolderIndex: undefined, editingFolderIndex: undefined,
editingFolderPath: undefined, editingFolderPath: undefined,
editingRequest: undefined, editingRequest: undefined,
editingRequestIndex: undefined, editingRequestIndex: undefined,
confirmModalTitle: undefined,
filterText: "", filterText: "",
collectionsType: { collectionsType: {
type: "my-collections", type: "my-collections",
@@ -375,14 +408,18 @@ export default defineComponent({
requests: [], requests: [],
}) })
) )
this.displayModalAdd(false)
} else if ( } else if (
this.collectionsType.type === "team-collections" && this.collectionsType.type === "team-collections" &&
this.collectionsType.selectedTeam.myRole !== "VIEWER" this.collectionsType.selectedTeam.myRole !== "VIEWER"
) { ) {
this.modalLoadingState = true
runMutation(CreateNewRootCollectionDocument, { runMutation(CreateNewRootCollectionDocument, {
title: name, title: name,
teamID: this.collectionsType.selectedTeam.id, teamID: this.collectionsType.selectedTeam.id,
})().then((result) => { })().then((result) => {
this.modalLoadingState = false
if (E.isLeft(result)) { if (E.isLeft(result)) {
if (result.left.error === "team_coll/short_title") if (result.left.error === "team_coll/short_title")
this.$toast.error(this.$t("collection.name_length_insufficient")) this.$toast.error(this.$t("collection.name_length_insufficient"))
@@ -390,10 +427,10 @@ export default defineComponent({
console.error(result.left.error) console.error(result.left.error)
} else { } else {
this.$toast.success(this.$t("collection.created")) this.$toast.success(this.$t("collection.created"))
this.displayModalAdd(false)
} }
}) })
} }
this.displayModalAdd(false)
}, },
// Intented to be called by CollectionEdit modal submit event // Intented to be called by CollectionEdit modal submit event
updateEditingCollection(newName) { updateEditingCollection(newName) {
@@ -408,66 +445,107 @@ export default defineComponent({
} }
editRESTCollection(this.editingCollectionIndex, collectionUpdated) editRESTCollection(this.editingCollectionIndex, collectionUpdated)
this.displayModalEdit(false)
} else if ( } else if (
this.collectionsType.type === "team-collections" && this.collectionsType.type === "team-collections" &&
this.collectionsType.selectedTeam.myRole !== "VIEWER" this.collectionsType.selectedTeam.myRole !== "VIEWER"
) { ) {
this.modalLoadingState = true
runMutation(RenameCollectionDocument, { runMutation(RenameCollectionDocument, {
collectionID: this.editingCollection.id, collectionID: this.editingCollection.id,
newTitle: newName, newTitle: newName,
})().then((result) => { })().then((result) => {
this.modalLoadingState = false
if (E.isLeft(result)) { if (E.isLeft(result)) {
this.$toast.error(this.$t("error.something_went_wrong")) this.$toast.error(this.$t("error.something_went_wrong"))
console.error(e) console.error(result.left.error)
} else { } else {
this.$toast.success(this.$t("collection.renamed")) this.$toast.success(this.$t("collection.renamed"))
this.displayModalEdit(false)
} }
}) })
} }
this.displayModalEdit(false)
}, },
// Intended to be called by CollectionEditFolder modal submit event // Intended to be called by CollectionEditFolder modal submit event
updateEditingFolder(name) { updateEditingFolder(name) {
if (this.collectionsType.type === "my-collections") { if (this.collectionsType.type === "my-collections") {
editRESTFolder(this.editingFolderPath, { ...this.editingFolder, name }) editRESTFolder(this.editingFolderPath, { ...this.editingFolder, name })
this.displayModalEditFolder(false)
} else if ( } else if (
this.collectionsType.type === "team-collections" && this.collectionsType.type === "team-collections" &&
this.collectionsType.selectedTeam.myRole !== "VIEWER" this.collectionsType.selectedTeam.myRole !== "VIEWER"
) { ) {
this.modalLoadingState = true
runMutation(RenameCollectionDocument, { runMutation(RenameCollectionDocument, {
collectionID: this.editingFolder.id, collectionID: this.editingFolder.id,
newTitle: name, newTitle: name,
})().then((result) => { })().then((result) => {
this.modalLoadingState = false
if (E.isLeft(result)) { if (E.isLeft(result)) {
this.$toast.error(this.$t("error.something_went_wrong")) if (result.left.error === "team_coll/short_title")
console.error(e) this.$toast.error(this.$t("folder.name_length_insufficient"))
else this.$toast.error(this.$t("error.something_went_wrong"))
console.error(result.left.error)
} else { } else {
this.$toast.success(this.$t("folder.renamed")) this.$toast.success(this.$t("folder.renamed"))
this.displayModalEditFolder(false)
} }
}) })
} }
this.displayModalEditFolder(false)
}, },
// Intented to by called by CollectionsEditRequest modal submit event // Intented to by called by CollectionsEditRequest modal submit event
updateEditingRequest(requestUpdateData) { updateEditingRequest(requestUpdateData) {
const saveCtx = getRESTSaveContext()
const requestUpdated = { const requestUpdated = {
...this.editingRequest, ...this.editingRequest,
name: requestUpdateData.name || this.editingRequest.name, name: requestUpdateData.name || this.editingRequest.name,
} }
if (this.collectionsType.type === "my-collections") { if (this.collectionsType.type === "my-collections") {
// Update REST Session with the updated state
if (
saveCtx &&
saveCtx.originLocation === "user-collection" &&
saveCtx.requestIndex === this.editingRequestIndex &&
saveCtx.folderPath === this.editingFolderPath
) {
setRESTRequest({
...getRESTRequest(),
name: requestUpdateData.name,
})
}
editRESTRequest( editRESTRequest(
this.editingFolderPath, this.editingFolderPath,
this.editingRequestIndex, this.editingRequestIndex,
requestUpdated requestUpdated
) )
this.displayModalEditRequest(false)
} else if ( } else if (
this.collectionsType.type === "team-collections" && this.collectionsType.type === "team-collections" &&
this.collectionsType.selectedTeam.myRole !== "VIEWER" this.collectionsType.selectedTeam.myRole !== "VIEWER"
) { ) {
this.modalLoadingState = true
const requestName = requestUpdateData.name || this.editingRequest.name const requestName = requestUpdateData.name || this.editingRequest.name
// Update REST Session with the updated state
if (
saveCtx &&
saveCtx.originLocation === "team-collection" &&
saveCtx.requestID === this.editingRequestIndex
) {
setRESTRequest({
...getRESTRequest(),
name: requestUpdateData.name,
})
}
runMutation(UpdateRequestDocument, { runMutation(UpdateRequestDocument, {
data: { data: {
request: JSON.stringify(requestUpdated), request: JSON.stringify(requestUpdated),
@@ -475,17 +553,18 @@ export default defineComponent({
}, },
requestID: this.editingRequestIndex, requestID: this.editingRequestIndex,
})().then((result) => { })().then((result) => {
this.modalLoadingState = false
if (E.isLeft(result)) { if (E.isLeft(result)) {
this.$toast.error(this.$t("error.something_went_wrong")) this.$toast.error(this.$t("error.something_went_wrong"))
console.error(e) console.error(result.left.error)
} else { } else {
this.$toast.success(this.$t("request.renamed")) this.$toast.success(this.$t("request.renamed"))
this.$emit("update-team-collections") this.$emit("update-team-collections")
this.displayModalEditRequest(false)
} }
}) })
} }
this.displayModalEditRequest(false)
}, },
displayModalAdd(shouldDisplay) { displayModalAdd(shouldDisplay) {
this.showModalAdd = shouldDisplay this.showModalAdd = shouldDisplay
@@ -498,6 +577,11 @@ export default defineComponent({
displayModalImportExport(shouldDisplay) { displayModalImportExport(shouldDisplay) {
this.showModalImportExport = shouldDisplay this.showModalImportExport = shouldDisplay
}, },
displayModalAddRequest(shouldDisplay) {
this.showModalAddRequest = shouldDisplay
if (!shouldDisplay) this.resetSelectedData()
},
displayModalAddFolder(shouldDisplay) { displayModalAddFolder(shouldDisplay) {
this.showModalAddFolder = shouldDisplay this.showModalAddFolder = shouldDisplay
@@ -513,6 +597,11 @@ export default defineComponent({
if (!shouldDisplay) this.resetSelectedData() if (!shouldDisplay) this.resetSelectedData()
}, },
displayConfirmModal(shouldDisplay) {
this.showConfirmModal = shouldDisplay
if (!shouldDisplay) this.resetSelectedData()
},
editCollection(collection, collectionIndex) { editCollection(collection, collectionIndex) {
this.$data.editingCollection = collection this.$data.editingCollection = collection
this.$data.editingCollectionIndex = collectionIndex this.$data.editingCollectionIndex = collectionIndex
@@ -521,12 +610,17 @@ export default defineComponent({
onAddFolder({ name, folder, path }) { onAddFolder({ name, folder, path }) {
if (this.collectionsType.type === "my-collections") { if (this.collectionsType.type === "my-collections") {
addRESTFolder(name, path) addRESTFolder(name, path)
} else if (this.collectionsType.type === "team-collections") { this.displayModalAddFolder(false)
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") { } else if (
this.collectionsType.type === "team-collections" &&
this.collectionsType.selectedTeam.myRole !== "VIEWER"
) {
this.modalLoadingState = true
runMutation(CreateChildCollectionDocument, { runMutation(CreateChildCollectionDocument, {
childTitle: name, childTitle: name,
collectionID: folder.id, collectionID: folder.id,
})().then((result) => { })().then((result) => {
this.modalLoadingState = false
if (E.isLeft(result)) { if (E.isLeft(result)) {
if (result.left.error === "team_coll/short_title") if (result.left.error === "team_coll/short_title")
this.$toast.error(this.$t("folder.name_length_insufficient")) this.$toast.error(this.$t("folder.name_length_insufficient"))
@@ -534,13 +628,11 @@ export default defineComponent({
console.error(result.left.error) console.error(result.left.error)
} else { } else {
this.$toast.success(this.$t("folder.created")) this.$toast.success(this.$t("folder.created"))
this.displayModalAddFolder(false)
this.$emit("update-team-collections") this.$emit("update-team-collections")
} }
}) })
} }
}
this.displayModalAddFolder(false)
}, },
addFolder(payload) { addFolder(payload) {
const { folder, path } = payload const { folder, path } = payload
@@ -578,16 +670,30 @@ export default defineComponent({
resetSelectedData() { resetSelectedData() {
this.$data.editingCollection = undefined this.$data.editingCollection = undefined
this.$data.editingCollectionIndex = undefined this.$data.editingCollectionIndex = undefined
this.$data.editingCollectionID = undefined
this.$data.editingFolder = undefined this.$data.editingFolder = undefined
this.$data.editingFolderPath = undefined
this.$data.editingFolderIndex = undefined this.$data.editingFolderIndex = undefined
this.$data.editingRequest = undefined this.$data.editingRequest = undefined
this.$data.editingRequestIndex = undefined this.$data.editingRequestIndex = undefined
this.$data.confirmModalTitle = undefined
}, },
expandCollection(collectionID) { expandCollection(collectionID) {
this.teamCollectionAdapter.expandCollection(collectionID) this.teamCollectionAdapter.expandCollection(collectionID)
}, },
removeCollection({ collectionsType, collectionIndex, collectionID }) { removeCollection({ collectionIndex, collectionID }) {
if (collectionsType.type === "my-collections") { this.$data.editingCollectionIndex = collectionIndex
this.$data.editingCollectionID = collectionID
this.confirmModalTitle = `${this.$t("confirm.remove_collection")}`
this.displayConfirmModal(true)
},
onRemoveCollection() {
const collectionIndex = this.$data.editingCollectionIndex
const collectionID = this.$data.editingCollectionID
if (this.collectionsType.type === "my-collections") {
// Cancel pick if picked collection is deleted // Cancel pick if picked collection is deleted
if ( if (
this.picked && this.picked &&
@@ -598,8 +704,12 @@ export default defineComponent({
} }
removeRESTCollection(collectionIndex) removeRESTCollection(collectionIndex)
this.$toast.success(this.$t("state.deleted")) this.$toast.success(this.$t("state.deleted"))
} else if (collectionsType.type === "team-collections") { this.displayConfirmModal(false)
} else if (this.collectionsType.type === "team-collections") {
this.modalLoadingState = true
// Cancel pick if picked collection is deleted // Cancel pick if picked collection is deleted
if ( if (
this.picked && this.picked &&
@@ -609,21 +719,89 @@ export default defineComponent({
this.$emit("select", { picked: null }) this.$emit("select", { picked: null })
} }
if (collectionsType.selectedTeam.myRole !== "VIEWER") { if (this.collectionsType.selectedTeam.myRole !== "VIEWER") {
runMutation(DeleteCollectionDocument, { runMutation(DeleteCollectionDocument, {
collectionID, collectionID,
})().then((result) => { })().then((result) => {
this.modalLoadingState = false
if (E.isLeft(result)) { if (E.isLeft(result)) {
this.$toast.error(this.$t("error.something_went_wrong")) this.$toast.error(this.$t("error.something_went_wrong"))
console.error(e) console.error(result.left.error)
} else { } else {
this.$toast.success(this.$t("state.deleted")) this.$toast.success(this.$t("state.deleted"))
this.displayConfirmModal(false)
}
})
}
}
},
removeFolder({ collectionID, folder, folderPath }) {
this.$data.editingCollectionID = collectionID
this.$data.editingFolder = folder
this.$data.editingFolderPath = folderPath
this.confirmModalTitle = `${this.$t("confirm.remove_folder")}`
this.displayConfirmModal(true)
},
onRemoveFolder() {
const folder = this.$data.editingFolder
const folderPath = this.$data.editingFolderPath
if (this.collectionsType.type === "my-collections") {
// Cancel pick if picked folder was deleted
if (
this.picked &&
this.picked.pickedType === "my-folder" &&
this.picked.folderPath === folderPath
) {
this.$emit("select", { picked: null })
}
removeRESTFolder(folderPath)
this.$toast.success(this.$t("state.deleted"))
this.displayConfirmModal(false)
} else if (this.collectionsType.type === "team-collections") {
this.modalLoadingState = true
// Cancel pick if picked collection folder was deleted
if (
this.picked &&
this.picked.pickedType === "teams-folder" &&
this.picked.folderID === folder.id
) {
this.$emit("select", { picked: null })
}
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") {
runMutation(DeleteCollectionDocument, {
collectionID: folder.id,
})().then((result) => {
this.modalLoadingState = false
if (E.isLeft(result)) {
this.$toast.error(`${this.$t("error.something_went_wrong")}`)
console.error(result.left.error)
} else {
this.$toast.success(`${this.$t("state.deleted")}`)
this.displayConfirmModal(false)
this.updateTeamCollections()
} }
}) })
} }
} }
}, },
removeRequest({ requestIndex, folderPath }) { removeRequest({ requestIndex, folderPath }) {
this.$data.editingRequestIndex = requestIndex
this.$data.editingFolderPath = folderPath
this.confirmModalTitle = `${this.$t("confirm.remove_request")}`
this.displayConfirmModal(true)
},
onRemoveRequest() {
const requestIndex = this.$data.editingRequestIndex
const folderPath = this.$data.editingFolderPath
if (this.collectionsType.type === "my-collections") { if (this.collectionsType.type === "my-collections") {
// Cancel pick if the picked item is being deleted // Cancel pick if the picked item is being deleted
if ( if (
@@ -635,8 +813,11 @@ export default defineComponent({
this.$emit("select", { picked: null }) this.$emit("select", { picked: null })
} }
removeRESTRequest(folderPath, requestIndex) removeRESTRequest(folderPath, requestIndex)
this.$toast.success(this.$t("state.deleted")) this.$toast.success(this.$t("state.deleted"))
this.displayConfirmModal(false)
} else if (this.collectionsType.type === "team-collections") { } else if (this.collectionsType.type === "team-collections") {
this.modalLoadingState = true
// Cancel pick if the picked item is being deleted // Cancel pick if the picked item is being deleted
if ( if (
this.picked && this.picked &&
@@ -649,11 +830,68 @@ export default defineComponent({
runMutation(DeleteRequestDocument, { runMutation(DeleteRequestDocument, {
requestID: requestIndex, requestID: requestIndex,
})().then((result) => { })().then((result) => {
this.modalLoadingState = false
if (E.isLeft(result)) { if (E.isLeft(result)) {
this.$toast.error(this.$t("error.something_went_wrong")) this.$toast.error(this.$t("error.something_went_wrong"))
console.error(e) console.error(result.left.error)
} else { } else {
this.$toast.success(this.$t("state.deleted")) this.$toast.success(this.$t("state.deleted"))
this.displayConfirmModal(false)
}
})
}
},
addRequest(payload) {
// TODO: check if the request being worked on
// is being overwritten (selected or not)
const { folder, path } = payload
this.$data.editingFolder = folder
this.$data.editingFolderPath = path
this.displayModalAddRequest(true)
},
onAddRequest({ name, folder, path }) {
const newRequest = {
...cloneDeep(getRESTRequest()),
name,
}
if (this.collectionsType.type === "my-collections") {
const insertionIndex = saveRESTRequestAs(path, newRequest)
// point to it
setRESTRequest(newRequest, {
originLocation: "user-collection",
folderPath: path,
requestIndex: insertionIndex,
})
this.displayModalAddRequest(false)
} else if (
this.collectionsType.type === "team-collections" &&
this.collectionsType.selectedTeam.myRole !== "VIEWER"
) {
this.modalLoadingState = true
runMutation(CreateRequestInCollectionDocument, {
collectionID: folder.id,
data: {
request: JSON.stringify(newRequest),
teamID: this.collectionsType.selectedTeam.id,
title: name,
},
})().then((result) => {
this.modalLoadingState = false
if (E.isLeft(result)) {
this.$toast.error(this.$t("error.something_went_wrong"))
console.error(result.left.error)
} else {
const { createRequestInCollection } = result.right
// point to it
setRESTRequest(newRequest, {
originLocation: "team-collection",
requestID: createRequestInCollection.id,
collectionID: createRequestInCollection.collection.id,
teamID: createRequestInCollection.collection.team.id,
})
this.displayModalAddRequest(false)
} }
}) })
} }
@@ -681,6 +919,22 @@ export default defineComponent({
}) })
} }
}, },
resolveConfirmModal(title) {
if (title === `${this.$t("confirm.remove_collection")}`)
this.onRemoveCollection()
else if (title === `${this.$t("confirm.remove_request")}`)
this.onRemoveRequest()
else if (title === `${this.$t("confirm.remove_folder")}`)
this.onRemoveFolder()
else {
console.error(
`Confirm modal title ${title} is not handled by the component`
)
this.$toast.error(this.$t("error.something_went_wrong"))
this.displayConfirmModal(false)
}
},
}, },
}) })
// request inside folder is not being deleted, you dumb fuck
</script> </script>

View File

@@ -45,6 +45,18 @@
color="green" color="green"
@click.native="$emit('unselect-collection')" @click.native="$emit('unselect-collection')"
/> />
<ButtonSecondary
v-if="!doc"
v-tippy="{ theme: 'tooltip' }"
svg="file-plus"
:title="$t('request.new')"
class="hidden group-hover:inline-flex"
@click.native="
$emit('add-request', {
path: `${collectionIndex}`,
})
"
/>
<ButtonSecondary <ButtonSecondary
v-if="!doc" v-if="!doc"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -79,12 +91,27 @@
class="flex flex-col focus:outline-none" class="flex flex-col focus:outline-none"
tabindex="0" tabindex="0"
role="menu" role="menu"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()" @keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()" @keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()" @keyup.delete="deleteAction.$el.click()"
@keyup.x="exportAction.$el.click()" @keyup.x="exportAction.$el.click()"
@keyup.escape="options.tippy().hide()" @keyup.escape="options.tippy().hide()"
> >
<SmartItem
ref="requestAction"
svg="file-plus"
:label="$t('request.new')"
:shortcut="['R']"
@click.native="
() => {
$emit('add-request', {
path: `${collectionIndex}`,
})
options.tippy().hide()
}
"
/>
<SmartItem <SmartItem
ref="folderAction" ref="folderAction"
svg="folder-plus" svg="folder-plus"
@@ -131,7 +158,7 @@
:shortcut="['⌫']" :shortcut="['⌫']"
@click.native=" @click.native="
() => { () => {
confirmRemove = true removeCollection()
options.tippy().hide() options.tippy().hide()
} }
" "
@@ -159,12 +186,14 @@
:collections-type="collectionsType" :collections-type="collectionsType"
:is-filtered="isFiltered" :is-filtered="isFiltered"
:picked="picked" :picked="picked"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)" @add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)" @edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)" @edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)" @duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)" @select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)" @remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
/> />
<CollectionsMyRequest <CollectionsMyRequest
v-for="(request, index) in collection.requests" v-for="(request, index) in collection.requests"
@@ -205,12 +234,6 @@
</div> </div>
</div> </div>
</div> </div>
<SmartConfirmModal
:show="confirmRemove"
:title="$t('confirm.remove_collection')"
@hide-modal="confirmRemove = false"
@resolve="removeCollection"
/>
</div> </div>
</template> </template>
@@ -233,6 +256,7 @@ export default defineComponent({
return { return {
tippyActions: ref<any | null>(null), tippyActions: ref<any | null>(null),
options: ref<any | null>(null), options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null), folderAction: ref<any | null>(null),
edit: ref<any | null>(null), edit: ref<any | null>(null),
deleteAction: ref<any | null>(null), deleteAction: ref<any | null>(null),
@@ -244,7 +268,6 @@ export default defineComponent({
showChildren: false, showChildren: false,
dragging: false, dragging: false,
selectedFolder: {}, selectedFolder: {},
confirmRemove: false,
prevCursor: "", prevCursor: "",
cursor: "", cursor: "",
pageNo: 0, pageNo: 0,
@@ -297,7 +320,6 @@ export default defineComponent({
}, },
removeCollection() { removeCollection() {
this.$emit("remove-collection", { this.$emit("remove-collection", {
collectionsType: this.collectionsType,
collectionIndex: this.collectionIndex, collectionIndex: this.collectionIndex,
collectionID: this.collection.id, collectionID: this.collection.id,
}) })

View File

@@ -29,6 +29,13 @@
</span> </span>
</span> </span>
<div class="flex"> <div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
svg="file-plus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click.native="$emit('add-request', { path: folderPath })"
/>
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
svg="folder-plus" svg="folder-plus"
@@ -57,12 +64,25 @@
class="flex flex-col focus:outline-none" class="flex flex-col focus:outline-none"
tabindex="0" tabindex="0"
role="menu" role="menu"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()" @keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()" @keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()" @keyup.delete="deleteAction.$el.click()"
@keyup.x="exportAction.$el.click()" @keyup.x="exportAction.$el.click()"
@keyup.escape="options.tippy().hide()" @keyup.escape="options.tippy().hide()"
> >
<SmartItem
ref="requestAction"
svg="file-plus"
:label="$t('request.new')"
:shortcut="['R']"
@click.native="
() => {
$emit('add-request', { path: folderPath })
options.tippy().hide()
}
"
/>
<SmartItem <SmartItem
ref="folderAction" ref="folderAction"
svg="folder-plus" svg="folder-plus"
@@ -111,7 +131,7 @@
:shortcut="['⌫']" :shortcut="['⌫']"
@click.native=" @click.native="
() => { () => {
confirmRemove = true removeFolder()
options.tippy().hide() options.tippy().hide()
} }
" "
@@ -138,13 +158,15 @@
:collections-type="collectionsType" :collections-type="collectionsType"
:folder-path="`${folderPath}/${subFolderIndex}`" :folder-path="`${folderPath}/${subFolderIndex}`"
:picked="picked" :picked="picked"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)" @add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)" @edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)" @edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)" @duplicate-request="$emit('duplicate-request', $event)"
@update-team-collections="$emit('update-team-collections')" @update-team-collections="$emit('update-team-collections')"
@select="$emit('select', $event)" @select="$emit('select', $event)"
@remove-request="removeRequest" @remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
/> />
<CollectionsMyRequest <CollectionsMyRequest
v-for="(request, index) in folder.requests" v-for="(request, index) in folder.requests"
@@ -162,7 +184,7 @@
@edit-request="$emit('edit-request', $event)" @edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)" @duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)" @select="$emit('select', $event)"
@remove-request="removeRequest" @remove-request="$emit('remove-request', $event)"
/> />
<div <div
v-if=" v-if="
@@ -185,23 +207,13 @@
</div> </div>
</div> </div>
</div> </div>
<SmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_folder')"
@hide-modal="confirmRemove = false"
@resolve="removeFolder"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api" import { defineComponent, ref } from "@nuxtjs/composition-api"
import { useI18n } from "~/helpers/utils/composables" import { useI18n } from "~/helpers/utils/composables"
import { import { moveRESTRequest } from "~/newstore/collections"
removeRESTFolder,
removeRESTRequest,
moveRESTRequest,
} from "~/newstore/collections"
export default defineComponent({ export default defineComponent({
name: "Folder", name: "Folder",
@@ -222,6 +234,7 @@ export default defineComponent({
return { return {
tippyActions: ref<any | null>(null), tippyActions: ref<any | null>(null),
options: ref<any | null>(null), options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null), folderAction: ref<any | null>(null),
edit: ref<any | null>(null), edit: ref<any | null>(null),
deleteAction: ref<any | null>(null), deleteAction: ref<any | null>(null),
@@ -233,7 +246,6 @@ export default defineComponent({
return { return {
showChildren: false, showChildren: false,
dragging: false, dragging: false,
confirmRemove: false,
prevCursor: "", prevCursor: "",
cursor: "", cursor: "",
} }
@@ -284,17 +296,10 @@ export default defineComponent({
this.showChildren = !this.showChildren this.showChildren = !this.showChildren
}, },
removeFolder() { removeFolder() {
// TODO: Bubble it up ? this.$emit("remove-folder", {
// Cancel pick if picked folder was deleted folder: this.folder,
if ( folderPath: this.folderPath,
this.picked && })
this.picked.pickedType === "my-folder" &&
this.picked.folderPath === this.folderPath
) {
this.$emit("select", { picked: null })
}
removeRESTFolder(this.folderPath)
this.$toast.success(`${this.$t("state.deleted")}`)
}, },
dropEvent({ dataTransfer }) { dropEvent({ dataTransfer }) {
this.dragging = !this.dragging this.dragging = !this.dragging
@@ -302,19 +307,6 @@ export default defineComponent({
const requestIndex = dataTransfer.getData("requestIndex") const requestIndex = dataTransfer.getData("requestIndex")
moveRESTRequest(folderPath, requestIndex, this.folderPath) moveRESTRequest(folderPath, requestIndex, this.folderPath)
}, },
removeRequest({ requestIndex }) {
// TODO: Bubble it up to root ?
// Cancel pick if the picked item is being deleted
if (
this.picked &&
this.picked.pickedType === "my-request" &&
this.picked.folderPath === this.folderPath &&
this.picked.requestIndex === requestIndex
) {
this.$emit("select", { picked: null })
}
removeRESTRequest(this.folderPath, requestIndex)
},
}, },
}) })
</script> </script>

View File

@@ -126,7 +126,7 @@
:shortcut="['⌫']" :shortcut="['⌫']"
@click.native=" @click.native="
() => { () => {
confirmRemove = true removeRequest()
options.tippy().hide() options.tippy().hide()
} }
" "
@@ -136,12 +136,6 @@
</span> </span>
</div> </div>
</div> </div>
<SmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_request')"
@hide-modal="confirmRemove = false"
@resolve="removeRequest"
/>
<HttpReqChangeConfirmModal <HttpReqChangeConfirmModal
:show="confirmChange" :show="confirmChange"
@hide-modal="confirmChange = false" @hide-modal="confirmChange = false"
@@ -165,6 +159,7 @@ import {
isEqualHoppRESTRequest, isEqualHoppRESTRequest,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import cloneDeep from "lodash/cloneDeep"
import { import {
useI18n, useI18n,
useToast, useToast,
@@ -221,8 +216,6 @@ const emit = defineEmits<{
( (
e: "remove-request", e: "remove-request",
data: { data: {
collectionIndex: number
folderName: string
folderPath: string folderPath: string
requestIndex: number requestIndex: number
} }
@@ -264,7 +257,6 @@ const requestMethodLabels = {
delete: "text-red-500", delete: "text-red-500",
default: "text-gray-500", default: "text-gray-500",
} }
const confirmRemove = ref(false)
const confirmChange = ref(false) const confirmChange = ref(false)
const showSaveRequestModal = ref(false) const showSaveRequestModal = ref(false)
@@ -303,8 +295,6 @@ const dragStart = ({ dataTransfer }: DragEvent) => {
const removeRequest = () => { const removeRequest = () => {
emit("remove-request", { emit("remove-request", {
collectionIndex: props.collectionIndex,
folderName: props.folderName,
folderPath: props.folderPath, folderPath: props.folderPath,
requestIndex: props.requestIndex, requestIndex: props.requestIndex,
}) })
@@ -317,15 +307,17 @@ const getRequestLabelColor = (method: string) =>
const setRestReq = (request: any) => { const setRestReq = (request: any) => {
setRESTRequest( setRESTRequest(
cloneDeep(
safelyExtractRESTRequest( safelyExtractRESTRequest(
translateToNewRequest(request), translateToNewRequest(request),
getDefaultRESTRequest() getDefaultRESTRequest()
)
), ),
{ {
originLocation: "user-collection", originLocation: "user-collection",
folderPath: props.folderPath, folderPath: props.folderPath,
requestIndex: props.requestIndex, requestIndex: props.requestIndex,
req: request, req: cloneDeep(request),
} }
) )
} }
@@ -397,7 +389,7 @@ const discardRequestChange = () => {
originLocation: "user-collection", originLocation: "user-collection",
folderPath: props.folderPath, folderPath: props.folderPath,
requestIndex: props.requestIndex, requestIndex: props.requestIndex,
req: props.request, req: cloneDeep(props.request),
}) })
} }

View File

@@ -45,6 +45,19 @@
color="green" color="green"
@click.native="$emit('unselect-collection')" @click.native="$emit('unselect-collection')"
/> />
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
svg="file-plus"
:title="$t('request.new')"
class="hidden group-hover:inline-flex"
@click.native="
$emit('add-request', {
folder: collection,
path: `${collectionIndex}`,
})
"
/>
<ButtonSecondary <ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'" v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -80,12 +93,28 @@
class="flex flex-col focus:outline-none" class="flex flex-col focus:outline-none"
tabindex="0" tabindex="0"
role="menu" role="menu"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()" @keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()" @keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()" @keyup.delete="deleteAction.$el.click()"
@keyup.x="exportAction.$el.click()" @keyup.x="exportAction.$el.click()"
@keyup.escape="options.tippy().hide()" @keyup.escape="options.tippy().hide()"
> >
<SmartItem
ref="requestAction"
svg="file-plus"
:label="t('request.new')"
:shortcut="['R']"
@click.native="
() => {
$emit('add-request', {
folder: collection,
path: `${collectionIndex}`,
})
options.tippy().hide()
}
"
/>
<SmartItem <SmartItem
ref="folderAction" ref="folderAction"
svg="folder-plus" svg="folder-plus"
@@ -128,7 +157,7 @@
:shortcut="['⌫']" :shortcut="['⌫']"
@click.native=" @click.native="
() => { () => {
confirmRemove = true removeCollection()
options.tippy().hide() options.tippy().hide()
} }
" "
@@ -157,12 +186,14 @@
:is-filtered="isFiltered" :is-filtered="isFiltered"
:picked="picked" :picked="picked"
:loading-collection-i-ds="loadingCollectionIDs" :loading-collection-i-ds="loadingCollectionIDs"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)" @add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)" @edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)" @edit-request="$emit('edit-request', $event)"
@select="$emit('select', $event)" @select="$emit('select', $event)"
@expand-collection="expandCollection" @expand-collection="expandCollection"
@remove-request="removeRequest" @remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
@duplicate-request="$emit('duplicate-request', $event)" @duplicate-request="$emit('duplicate-request', $event)"
/> />
<CollectionsTeamsRequest <CollectionsTeamsRequest
@@ -180,7 +211,7 @@
:picked="picked" :picked="picked"
@edit-request="editRequest($event)" @edit-request="editRequest($event)"
@select="$emit('select', $event)" @select="$emit('select', $event)"
@remove-request="removeRequest" @remove-request="$emit('remove-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)" @duplicate-request="$emit('duplicate-request', $event)"
/> />
<div <div
@@ -211,12 +242,6 @@
</div> </div>
</div> </div>
</div> </div>
<SmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_collection')"
@hide-modal="confirmRemove = false"
@resolve="removeCollection"
/>
</div> </div>
</template> </template>
@@ -248,6 +273,7 @@ export default defineComponent({
return { return {
tippyActions: ref<any | null>(null), tippyActions: ref<any | null>(null),
options: ref<any | null>(null), options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null), folderAction: ref<any | null>(null),
edit: ref<any | null>(null), edit: ref<any | null>(null),
deleteAction: ref<any | null>(null), deleteAction: ref<any | null>(null),
@@ -261,7 +287,6 @@ export default defineComponent({
showChildren: false, showChildren: false,
dragging: false, dragging: false,
selectedFolder: {}, selectedFolder: {},
confirmRemove: false,
prevCursor: "", prevCursor: "",
cursor: "", cursor: "",
pageNo: 0, pageNo: 0,
@@ -344,7 +369,6 @@ export default defineComponent({
}, },
removeCollection() { removeCollection() {
this.$emit("remove-collection", { this.$emit("remove-collection", {
collectionsType: this.collectionsType,
collectionIndex: this.collectionIndex, collectionIndex: this.collectionIndex,
collectionID: this.collection.id, collectionID: this.collection.id,
}) })
@@ -362,13 +386,6 @@ export default defineComponent({
if (E.isLeft(moveRequestResult)) if (E.isLeft(moveRequestResult))
this.$toast.error(`${this.$t("error.something_went_wrong")}`) this.$toast.error(`${this.$t("error.something_went_wrong")}`)
}, },
removeRequest({ collectionIndex, folderName, requestIndex }: any) {
this.$emit("remove-request", {
collectionIndex,
folderName,
requestIndex,
})
},
}, },
}) })
</script> </script>

View File

@@ -29,6 +29,14 @@
</span> </span>
</span> </span>
<div class="flex"> <div class="flex">
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
svg="file-plus"
:title="$t('request.new')"
class="hidden group-hover:inline-flex"
@click.native="$emit('add-request', { folder, path: folderPath })"
/>
<ButtonSecondary <ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'" v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -59,12 +67,25 @@
class="flex flex-col focus:outline-none" class="flex flex-col focus:outline-none"
tabindex="0" tabindex="0"
role="menu" role="menu"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()" @keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()" @keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()" @keyup.delete="deleteAction.$el.click()"
@keyup.x="exportAction.$el.click()" @keyup.x="exportAction.$el.click()"
@keyup.escape="options.tippy().hide()" @keyup.escape="options.tippy().hide()"
> >
<SmartItem
ref="requestAction"
svg="file-plus"
:label="$t('request.new')"
:shortcut="['R']"
@click.native="
() => {
$emit('add-request', { folder, path: folderPath })
options.tippy().hide()
}
"
/>
<SmartItem <SmartItem
ref="folderAction" ref="folderAction"
svg="folder-plus" svg="folder-plus"
@@ -109,7 +130,7 @@
:shortcut="['⌫']" :shortcut="['⌫']"
@click.native=" @click.native="
() => { () => {
confirmRemove = true removeFolder()
options.tippy().hide() options.tippy().hide()
} }
" "
@@ -137,13 +158,15 @@
:folder-path="`${folderPath}/${subFolderIndex}`" :folder-path="`${folderPath}/${subFolderIndex}`"
:picked="picked" :picked="picked"
:loading-collection-i-ds="loadingCollectionIDs" :loading-collection-i-ds="loadingCollectionIDs"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)" @add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)" @edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)" @edit-request="$emit('edit-request', $event)"
@update-team-collections="$emit('update-team-collections')" @update-team-collections="$emit('update-team-collections')"
@select="$emit('select', $event)" @select="$emit('select', $event)"
@expand-collection="expandCollection" @expand-collection="expandCollection"
@remove-request="removeRequest" @remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
@duplicate-request="$emit('duplicate-request', $event)" @duplicate-request="$emit('duplicate-request', $event)"
/> />
<CollectionsTeamsRequest <CollectionsTeamsRequest
@@ -161,7 +184,7 @@
:collection-i-d="folder.id" :collection-i-d="folder.id"
@edit-request="$emit('edit-request', $event)" @edit-request="$emit('edit-request', $event)"
@select="$emit('select', $event)" @select="$emit('select', $event)"
@remove-request="removeRequest" @remove-request="$emit('remove-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)" @duplicate-request="$emit('duplicate-request', $event)"
/> />
<div <div
@@ -190,20 +213,12 @@
</div> </div>
</div> </div>
</div> </div>
<SmartConfirmModal
:show="confirmRemove"
:title="$t('confirm.remove_folder')"
@hide-modal="confirmRemove = false"
@resolve="removeFolder"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api" import { defineComponent, ref } from "@nuxtjs/composition-api"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { runMutation } from "~/helpers/backend/GQLClient"
import { DeleteCollectionDocument } from "~/helpers/backend/graphql"
import { import {
getCompleteCollectionTree, getCompleteCollectionTree,
teamCollToHoppRESTColl, teamCollToHoppRESTColl,
@@ -228,6 +243,7 @@ export default defineComponent({
return { return {
tippyActions: ref<any | null>(null), tippyActions: ref<any | null>(null),
options: ref<any | null>(null), options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null), folderAction: ref<any | null>(null),
edit: ref<any | null>(null), edit: ref<any | null>(null),
deleteAction: ref<any | null>(null), deleteAction: ref<any | null>(null),
@@ -239,7 +255,6 @@ export default defineComponent({
return { return {
showChildren: false, showChildren: false,
dragging: false, dragging: false,
confirmRemove: false,
prevCursor: "", prevCursor: "",
cursor: "", cursor: "",
} }
@@ -310,30 +325,10 @@ export default defineComponent({
this.showChildren = !this.showChildren this.showChildren = !this.showChildren
}, },
removeFolder() { removeFolder() {
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") { this.$emit("remove-folder", {
// Cancel pick if picked collection folder was deleted collectionsType: this.collectionsType,
if ( folder: this.folder,
this.picked &&
this.picked.pickedType === "teams-folder" &&
this.picked.folderID === this.folder.id
) {
this.$emit("select", { picked: null })
}
runMutation(DeleteCollectionDocument, {
collectionID: this.folder.id,
})().then((result) => {
if (E.isLeft(result)) {
this.$toast.error(`${this.$t("error.something_went_wrong")}`)
console.error(result.left)
} else {
this.$toast.success(`${this.$t("state.deleted")}`)
this.$emit("update-team-collections")
}
}) })
this.$emit("update-team-collections")
}
}, },
expandCollection(collectionID: number) { expandCollection(collectionID: number) {
this.$emit("expand-collection", collectionID) this.$emit("expand-collection", collectionID)
@@ -348,13 +343,6 @@ export default defineComponent({
if (E.isLeft(moveRequestResult)) if (E.isLeft(moveRequestResult))
this.$toast.error(`${this.$t("error.something_went_wrong")}`) this.$toast.error(`${this.$t("error.something_went_wrong")}`)
}, },
removeRequest({ collectionIndex, folderName, requestIndex }: any) {
this.$emit("remove-request", {
collectionIndex,
folderName,
requestIndex,
})
},
}, },
}) })
</script> </script>

View File

@@ -123,7 +123,7 @@
:shortcut="['⌫']" :shortcut="['⌫']"
@click.native=" @click.native="
() => { () => {
confirmRemove = true removeRequest()
options.tippy().hide() options.tippy().hide()
} }
" "
@@ -133,12 +133,6 @@
</span> </span>
</div> </div>
</div> </div>
<SmartConfirmModal
:show="confirmRemove"
:title="$t('confirm.remove_request')"
@hide-modal="confirmRemove = false"
@resolve="removeRequest"
/>
<HttpReqChangeConfirmModal <HttpReqChangeConfirmModal
:show="confirmChange" :show="confirmChange"
@hide-modal="confirmChange = false" @hide-modal="confirmChange = false"
@@ -215,8 +209,7 @@ const emit = defineEmits<{
( (
e: "remove-request", e: "remove-request",
data: { data: {
collectionIndex: number folderPath: string | undefined
folderName: string | undefined
requestIndex: string requestIndex: string
} }
): void ): void
@@ -253,7 +246,6 @@ const requestMethodLabels = {
delete: "text-red-500", delete: "text-red-500",
default: "text-gray-500", default: "text-gray-500",
} }
const confirmRemove = ref(false)
const confirmChange = ref(false) const confirmChange = ref(false)
const showSaveRequestModal = ref(false) const showSaveRequestModal = ref(false)
@@ -289,8 +281,7 @@ const dragStart = ({ dataTransfer }: DragEvent) => {
const removeRequest = () => { const removeRequest = () => {
emit("remove-request", { emit("remove-request", {
collectionIndex: props.collectionIndex, folderPath: props.folderName,
folderName: props.folderName,
requestIndex: props.requestIndex, requestIndex: props.requestIndex,
}) })
} }

View File

@@ -2,7 +2,7 @@
<SmartModal <SmartModal
v-if="show" v-if="show"
dialog dialog
:title="$t(`environment.${action}`)" :title="t(`environment.${action}`)"
@close="hideModal" @close="hideModal"
> >
<template #body> <template #body>
@@ -20,24 +20,24 @@
@keyup.enter="saveEnvironment" @keyup.enter="saveEnvironment"
/> />
<label for="selectLabelEnvEdit"> <label for="selectLabelEnvEdit">
{{ $t("action.label") }} {{ t("action.label") }}
</label> </label>
</div> </div>
<div class="flex items-center justify-between flex-1"> <div class="flex items-center justify-between flex-1">
<label for="variableList" class="p-4"> <label for="variableList" class="p-4">
{{ $t("environment.variable_list") }} {{ t("environment.variable_list") }}
</label> </label>
<div class="flex"> <div class="flex">
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="$t('action.clear_all')" :title="t('action.clear_all')"
:svg="clearIcon" :svg="clearIcon"
@click.native="clearContent()" @click.native="clearContent()"
/> />
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
svg="plus" svg="plus"
:title="$t('add.new')" :title="t('add.new')"
@click.native="addEnvironmentVariable" @click.native="addEnvironmentVariable"
/> />
</div> </div>
@@ -46,7 +46,7 @@
v-if="evnExpandError" v-if="evnExpandError"
class="w-full px-4 py-2 mb-2 overflow-auto font-mono text-red-400 whitespace-normal rounded bg-primaryLight" class="w-full px-4 py-2 mb-2 overflow-auto font-mono text-red-400 whitespace-normal rounded bg-primaryLight"
> >
{{ $t("environment.nested_overflow") }} {{ t("environment.nested_overflow") }}
</div> </div>
<div class="border rounded divide-y divide-dividerLight border-divider"> <div class="border rounded divide-y divide-dividerLight border-divider">
<div <div
@@ -57,12 +57,12 @@
<input <input
v-model="variable.key" v-model="variable.key"
class="flex flex-1 px-4 py-2 bg-transparent" class="flex flex-1 px-4 py-2 bg-transparent"
:placeholder="`${$t('count.variable', { count: index + 1 })}`" :placeholder="`${t('count.variable', { count: index + 1 })}`"
:name="'param' + index" :name="'param' + index"
/> />
<SmartEnvInput <SmartEnvInput
v-model="variable.value" v-model="variable.value"
:placeholder="`${$t('count.value', { count: index + 1 })}`" :placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs" :envs="liveEnvs"
:name="'value' + index" :name="'value' + index"
/> />
@@ -70,7 +70,7 @@
<ButtonSecondary <ButtonSecondary
id="variable" id="variable"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="$t('action.remove')" :title="t('action.remove')"
svg="trash" svg="trash"
color="red" color="red"
@click.native="removeEnvironmentVariable(index)" @click.native="removeEnvironmentVariable(index)"
@@ -85,13 +85,13 @@
:src="`/images/states/${$colorMode.value}/blockchain.svg`" :src="`/images/states/${$colorMode.value}/blockchain.svg`"
loading="lazy" loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4" class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${$t('empty.environments')}`" :alt="`${t('empty.environments')}`"
/> />
<span class="pb-4 text-center"> <span class="pb-4 text-center">
{{ $t("empty.environments") }} {{ t("empty.environments") }}
</span> </span>
<ButtonSecondary <ButtonSecondary
:label="`${$t('add.new')}`" :label="`${t('add.new')}`"
filled filled
class="mb-4" class="mb-4"
@click.native="addEnvironmentVariable" @click.native="addEnvironmentVariable"
@@ -103,11 +103,11 @@
<template #footer> <template #footer>
<span> <span>
<ButtonPrimary <ButtonPrimary
:label="`${$t('action.save')}`" :label="`${t('action.save')}`"
@click.native="saveEnvironment" @click.native="saveEnvironment"
/> />
<ButtonSecondary <ButtonSecondary
:label="`${$t('action.cancel')}`" :label="`${t('action.cancel')}`"
@click.native="hideModal" @click.native="hideModal"
/> />
</span> </span>
@@ -115,9 +115,9 @@
</SmartModal> </SmartModal>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import clone from "lodash/clone" import clone from "lodash/clone"
import { computed, defineComponent, PropType } from "@nuxtjs/composition-api" import { computed, ref, watch } from "@nuxtjs/composition-api"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { Environment, parseTemplateStringE } from "@hoppscotch/data" import { Environment, parseTemplateStringE } from "@hoppscotch/data"
import { import {
@@ -130,25 +130,38 @@ import {
setGlobalEnvVariables, setGlobalEnvVariables,
updateEnvironment, updateEnvironment,
} from "~/newstore/environments" } from "~/newstore/environments"
import { useReadonlyStream } from "~/helpers/utils/composables" import {
useReadonlyStream,
useI18n,
useToast,
} from "~/helpers/utils/composables"
const t = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
show: boolean
action: "edit" | "new"
editingEnvironmentIndex: number | "Global" | null
envVars: () => Environment["variables"]
}>(),
{
show: false,
action: "edit",
editingEnvironmentIndex: null,
envVars: () => [],
}
)
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const name = ref<string | null>(null)
const vars = ref([{ key: "", value: "" }])
const clearIcon = ref("trash-2")
export default defineComponent({
props: {
show: Boolean,
action: {
type: String as PropType<"new" | "edit">,
default: "edit",
},
editingEnvironmentIndex: {
type: [Number, String] as PropType<number | "Global" | null>,
default: null,
},
envVars: {
type: Function as PropType<() => Environment["variables"]>,
default: () => [],
},
},
setup(props) {
const globalVars = useReadonlyStream(globalEnv$, []) const globalVars = useReadonlyStream(globalEnv$, [])
const workingEnv = computed(() => { const workingEnv = computed(() => {
@@ -169,23 +182,11 @@ export default defineComponent({
} }
}) })
return { const envList = useReadonlyStream(environments$, []) || props.envVars()
globalVars,
workingEnv, const evnExpandError = computed(() => {
envList: useReadonlyStream(environments$, []) || props.envVars(), for (const variable of vars.value) {
} const result = parseTemplateStringE(variable.value.toString(), vars.value)
},
data() {
return {
name: null as string | null,
vars: [] as { key: string; value: string }[],
clearIcon: "trash-2",
}
},
computed: {
evnExpandError(): boolean {
for (const variable of this.vars) {
const result = parseTemplateStringE(variable.value, this.vars)
if (E.isLeft(result)) { if (E.isLeft(result)) {
console.error("error", result.left) console.error("error", result.left)
@@ -193,73 +194,83 @@ export default defineComponent({
} }
} }
return false return false
}, })
liveEnvs(): Array<{ key: string; value: string; source: string }> {
if (this.evnExpandError) { const liveEnvs = computed(() => {
if (evnExpandError) {
return [] return []
} }
if (this.$props.editingEnvironmentIndex === "Global") { if (props.editingEnvironmentIndex === "Global") {
return [...this.vars.map((x) => ({ ...x, source: this.name! }))] return [...vars.value.map((x) => ({ ...x, source: name.value! }))]
} else { } else {
return [ return [
...this.vars.map((x) => ({ ...x, source: this.name! })), ...vars.value.map((x) => ({ ...x, source: name.value! })),
...this.globalVars.map((x) => ({ ...x, source: "Global" })), ...globalVars.value.map((x) => ({ ...x, source: "Global" })),
] ]
} }
}, })
},
watch: { watch(
show() { () => props.show,
this.name = this.workingEnv?.name ?? null (show) => {
this.vars = clone(this.workingEnv?.variables ?? []) if (show) {
}, name.value = workingEnv.value?.name ?? null
}, vars.value = clone(workingEnv.value?.variables ?? [])
methods: { }
clearContent() { }
this.vars = [] )
this.clearIcon = "check"
this.$toast.success(`${this.$t("state.cleared")}`) const clearContent = () => {
setTimeout(() => (this.clearIcon = "trash-2"), 1000) vars.value = []
}, clearIcon.value = "check"
addEnvironmentVariable() { toast.success(`${t("state.cleared")}`)
this.vars.push({ setTimeout(() => (clearIcon.value = "trash-2"), 1000)
}
const addEnvironmentVariable = () => {
vars.value.push({
key: "", key: "",
value: "", value: "",
}) })
}, }
removeEnvironmentVariable(index: number) {
this.vars.splice(index, 1) const removeEnvironmentVariable = (index: number) => {
}, vars.value.splice(index, 1)
saveEnvironment() { }
if (!this.name) {
this.$toast.error(`${this.$t("environment.invalid_name")}`) const saveEnvironment = () => {
if (!name.value) {
toast.error(`${t("environment.invalid_name")}`)
return return
} }
if (this.action === "new") {
createEnvironment(this.name)
setCurrentEnvironment(this.envList.length - 1)
}
const environmentUpdated: Environment = { const environmentUpdated: Environment = {
name: this.name, name: name.value,
variables: this.vars, variables: vars.value,
} }
if (this.editingEnvironmentIndex === "Global") if (props.action === "new") {
// Creating a new environment
createEnvironment(name.value)
updateEnvironment(envList.value.length - 1, environmentUpdated)
setCurrentEnvironment(envList.value.length - 1)
toast.success(`${t("environment.created")}`)
} else if (props.editingEnvironmentIndex === "Global") {
// Editing the Global environment
setGlobalEnvVariables(environmentUpdated.variables) setGlobalEnvVariables(environmentUpdated.variables)
else if (this.action === "new") { toast.success(`${t("environment.updated")}`)
updateEnvironment(this.envList.length - 1, environmentUpdated) } else if (props.editingEnvironmentIndex !== null) {
} else { // Editing an environment
updateEnvironment(this.editingEnvironmentIndex!, environmentUpdated) updateEnvironment(props.editingEnvironmentIndex, environmentUpdated)
toast.success(`${t("environment.updated")}`)
}
hideModal()
}
const hideModal = () => {
name.value = null
emit("hide-modal")
} }
this.hideModal()
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
</script> </script>

View File

@@ -5,13 +5,13 @@
> >
<span <span
class="flex items-center justify-center px-4 cursor-pointer" class="flex items-center justify-center px-4 cursor-pointer"
@click="$emit('edit-environment')" @click="emit('edit-environment')"
> >
<SmartIcon class="svg-icons" name="layers" /> <SmartIcon class="svg-icons" name="layers" />
</span> </span>
<span <span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark" class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="$emit('edit-environment')" @click="emit('edit-environment')"
> >
<span class="truncate"> <span class="truncate">
{{ environment.name }} {{ environment.name }}
@@ -29,7 +29,7 @@
<template #trigger> <template #trigger>
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="$t('action.more')" :title="t('action.more')"
svg="more-vertical" svg="more-vertical"
/> />
</template> </template>
@@ -48,11 +48,11 @@
<SmartItem <SmartItem
ref="edit" ref="edit"
svg="edit" svg="edit"
:label="`${$t('action.edit')}`" :label="`${t('action.edit')}`"
:shortcut="['E']" :shortcut="['E']"
@click.native=" @click.native="
() => { () => {
$emit('edit-environment') emit('edit-environment')
options.tippy().hide() options.tippy().hide()
} }
" "
@@ -60,11 +60,11 @@
<SmartItem <SmartItem
ref="duplicate" ref="duplicate"
svg="copy" svg="copy"
:label="`${$t('action.duplicate')}`" :label="`${t('action.duplicate')}`"
:shortcut="['D']" :shortcut="['D']"
@click.native=" @click.native="
() => { () => {
duplicateEnvironment() duplicateEnvironments()
options.tippy().hide() options.tippy().hide()
} }
" "
@@ -73,7 +73,7 @@
v-if="!(environmentIndex === 'Global')" v-if="!(environmentIndex === 'Global')"
ref="deleteAction" ref="deleteAction"
svg="trash-2" svg="trash-2"
:label="`${$t('action.delete')}`" :label="`${t('action.delete')}`"
:shortcut="['⌫']" :shortcut="['⌫']"
@click.native=" @click.native="
() => { () => {
@@ -87,15 +87,17 @@
</span> </span>
<SmartConfirmModal <SmartConfirmModal
:show="confirmRemove" :show="confirmRemove"
:title="`${$t('confirm.remove_environment')}`" :title="`${t('confirm.remove_environment')}`"
@hide-modal="confirmRemove = false" @hide-modal="confirmRemove = false"
@resolve="removeEnvironment" @resolve="removeEnvironment"
/> />
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, PropType, ref } from "@nuxtjs/composition-api" import { ref } from "@nuxtjs/composition-api"
import { Environment } from "@hoppscotch/data"
import cloneDeep from "lodash/cloneDeep"
import { import {
deleteEnvironment, deleteEnvironment,
duplicateEnvironment, duplicateEnvironment,
@@ -104,47 +106,43 @@ import {
getGlobalVariables, getGlobalVariables,
environmentsStore, environmentsStore,
} from "~/newstore/environments" } from "~/newstore/environments"
import { useI18n, useToast } from "~/helpers/utils/composables"
export default defineComponent({ const t = useI18n()
props: { const toast = useToast()
environment: { type: Object, default: () => {} },
environmentIndex: { const props = defineProps<{
type: [Number, String] as PropType<number | "Global">, environment: Environment
default: null, environmentIndex: number | "Global" | null
}, }>()
},
setup() { const emit = defineEmits<{
return { (e: "edit-environment"): void
tippyActions: ref<any | null>(null), }>()
options: ref<any | null>(null),
edit: ref<any | null>(null), const confirmRemove = ref(false)
duplicate: ref<any | null>(null),
deleteAction: ref<any | null>(null), const tippyActions = ref<any | null>(null)
const options = ref<any | null>(null)
const edit = ref<any | null>(null)
const duplicate = ref<any | null>(null)
const deleteAction = ref<any | null>(null)
const removeEnvironment = () => {
if (props.environmentIndex === null) return
if (props.environmentIndex !== "Global")
deleteEnvironment(props.environmentIndex)
toast.success(`${t("state.deleted")}`)
} }
},
data() { const duplicateEnvironments = () => {
return { if (props.environmentIndex === null) return
confirmRemove: false, if (props.environmentIndex === "Global") {
} createEnvironment(`Global - ${t("action.duplicate")}`)
},
methods: {
removeEnvironment() {
if (this.environmentIndex !== "Global")
deleteEnvironment(this.environmentIndex)
this.$toast.success(`${this.$t("state.deleted")}`)
},
duplicateEnvironment() {
if (this.environmentIndex === "Global") {
createEnvironment(`Global - ${this.$t("action.duplicate")}`)
setEnvironmentVariables( setEnvironmentVariables(
environmentsStore.value.environments.length - 1, environmentsStore.value.environments.length - 1,
getGlobalVariables().reduce((gVars, gVar) => { cloneDeep(getGlobalVariables())
gVars.push({ key: gVar.key, value: gVar.value })
return gVars
}, [])
) )
} else duplicateEnvironment(this.environmentIndex) } else duplicateEnvironment(props.environmentIndex)
}, }
},
})
</script> </script>

View File

@@ -5,7 +5,7 @@
<template #trigger> <template #trigger>
<span <span
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="`${$t('environment.select')}`" :title="`${t('environment.select')}`"
class="flex-1 bg-transparent border-b border-dividerLight select-wrapper" class="flex-1 bg-transparent border-b border-dividerLight select-wrapper"
> >
<ButtonSecondary <ButtonSecondary
@@ -15,20 +15,20 @@
/> />
<ButtonSecondary <ButtonSecondary
v-else v-else
:label="`${$t('environment.select')}`" :label="`${t('environment.select')}`"
class="flex-1 !justify-start pr-8 rounded-none" class="flex-1 !justify-start pr-8 rounded-none"
/> />
</span> </span>
</template> </template>
<div class="flex flex-col" role="menu"> <div class="flex flex-col" role="menu">
<SmartItem <SmartItem
:label="`${$t('environment.no_environment')}`" :label="`${t('environment.no_environment')}`"
:info-icon="selectedEnvironmentIndex === -1 ? 'done' : ''" :info-icon="selectedEnvironmentIndex === -1 ? 'done' : ''"
:active-info-icon="selectedEnvironmentIndex === -1" :active-info-icon="selectedEnvironmentIndex === -1"
@click.native=" @click.native="
() => { () => {
selectedEnvironmentIndex = -1 selectedEnvironmentIndex = -1
$refs.options.tippy().hide() options.tippy().hide()
} }
" "
/> />
@@ -42,7 +42,7 @@
@click.native=" @click.native="
() => { () => {
selectedEnvironmentIndex = index selectedEnvironmentIndex = index
$refs.options.tippy().hide() options.tippy().hide()
} }
" "
/> />
@@ -51,7 +51,7 @@
<div class="flex justify-between flex-1 border-b border-dividerLight"> <div class="flex justify-between flex-1 border-b border-dividerLight">
<ButtonSecondary <ButtonSecondary
svg="plus" svg="plus"
:label="`${$t('action.new')}`" :label="`${t('action.new')}`"
class="!rounded-none" class="!rounded-none"
@click.native="displayModalAdd(true)" @click.native="displayModalAdd(true)"
/> />
@@ -60,13 +60,13 @@
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/environments" to="https://docs.hoppscotch.io/features/environments"
blank blank
:title="$t('app.wiki')" :title="t('app.wiki')"
svg="help-circle" svg="help-circle"
/> />
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
svg="archive" svg="archive"
:title="$t('modal.import_export')" :title="t('modal.import_export')"
@click.native="displayModalImportExport(true)" @click.native="displayModalImportExport(true)"
/> />
</div> </div>
@@ -95,13 +95,13 @@
:src="`/images/states/${$colorMode.value}/blockchain.svg`" :src="`/images/states/${$colorMode.value}/blockchain.svg`"
loading="lazy" loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4" class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${$t('empty.environments')}`" :alt="`${t('empty.environments')}`"
/> />
<span class="pb-4 text-center"> <span class="pb-4 text-center">
{{ $t("empty.environments") }} {{ t("empty.environments") }}
</span> </span>
<ButtonSecondary <ButtonSecondary
:label="`${$t('add.new')}`" :label="`${t('add.new')}`"
filled filled
class="mb-4" class="mb-4"
@click.native="displayModalAdd(true)" @click.native="displayModalAdd(true)"
@@ -120,9 +120,13 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api" import { computed, ref } from "@nuxtjs/composition-api"
import { useReadonlyStream, useStream } from "~/helpers/utils/composables" import {
useReadonlyStream,
useStream,
useI18n,
} from "~/helpers/utils/composables"
import { import {
environments$, environments$,
setCurrentEnvironment, setCurrentEnvironment,
@@ -130,8 +134,10 @@ import {
globalEnv$, globalEnv$,
} from "~/newstore/environments" } from "~/newstore/environments"
export default defineComponent({ const t = useI18n()
setup() {
const options = ref<any | null>(null)
const globalEnv = useReadonlyStream(globalEnv$, []) const globalEnv = useReadonlyStream(globalEnv$, [])
const globalEnvironment = computed(() => ({ const globalEnvironment = computed(() => ({
@@ -139,46 +145,38 @@ export default defineComponent({
variables: globalEnv.value, variables: globalEnv.value,
})) }))
return { const environments = useReadonlyStream(environments$, [])
environments: useReadonlyStream(environments$, []),
globalEnvironment, const selectedEnvironmentIndex = useStream(
selectedEnvironmentIndex: useStream(
selectedEnvIndex$, selectedEnvIndex$,
-1, -1,
setCurrentEnvironment setCurrentEnvironment
), )
}
},
data() {
return {
showModalImportExport: false,
showModalDetails: false,
action: "edit" as "new" | "edit",
editingEnvironmentIndex: undefined as number | "Global" | undefined,
}
},
methods: {
displayModalAdd(shouldDisplay: boolean) {
this.action = "new"
this.showModalDetails = shouldDisplay
},
displayModalEdit(shouldDisplay: boolean) {
this.action = "edit"
this.showModalDetails = shouldDisplay
if (!shouldDisplay) this.resetSelectedData() const showModalImportExport = ref(false)
}, const showModalDetails = ref(false)
displayModalImportExport(shouldDisplay: boolean) { const action = ref<"new" | "edit">("edit")
this.showModalImportExport = shouldDisplay const editingEnvironmentIndex = ref<number | "Global" | null>(null)
},
editEnvironment(environmentIndex: number | "Global") { const displayModalAdd = (shouldDisplay: boolean) => {
this.$data.editingEnvironmentIndex = environmentIndex action.value = "new"
this.action = "edit" showModalDetails.value = shouldDisplay
this.displayModalEdit(true) }
}, const displayModalEdit = (shouldDisplay: boolean) => {
resetSelectedData() { action.value = "edit"
this.$data.editingEnvironmentIndex = undefined showModalDetails.value = shouldDisplay
},
}, if (!shouldDisplay) resetSelectedData()
}) }
const displayModalImportExport = (shouldDisplay: boolean) => {
showModalImportExport.value = shouldDisplay
}
const editEnvironment = (environmentIndex: number | "Global") => {
editingEnvironmentIndex.value = environmentIndex
action.value = "edit"
displayModalEdit(true)
}
const resetSelectedData = () => {
editingEnvironmentIndex.value = null
}
</script> </script>

View File

@@ -137,6 +137,47 @@
/> />
</span> </span>
</div> </div>
<div
v-for="(header, index) in computedHeaders"
:key="`header-${index}`"
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
>
<span>
<ButtonSecondary
svg="lock"
class="opacity-25 cursor-auto text-secondaryLight bg-divider"
tabindex="-1"
/>
</span>
<SmartEnvInput
v-model="header.header.key"
:placeholder="`${t('count.value', { count: index + 1 })}`"
readonly
/>
<SmartEnvInput
:value="mask(header)"
:placeholder="`${t('count.value', { count: index + 1 })}`"
readonly
/>
<span>
<ButtonSecondary
v-if="header.source === 'auth'"
:svg="masking ? 'eye' : 'eye-off'"
@click.native="toggleMask()"
/>
<ButtonSecondary
v-else
svg="arrow-up-right"
class="cursor-auto text-primary hover:text-primary"
/>
</span>
<span>
<ButtonSecondary
svg="arrow-up-right"
@click.native="changeTab(header.source)"
/>
</span>
</div>
</draggable> </draggable>
<div <div
v-if="workingHeaders.length === 0" v-if="workingHeaders.length === 0"
@@ -162,7 +203,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Ref, ref, watch } from "@nuxtjs/composition-api" import { computed, Ref, ref, watch } from "@nuxtjs/composition-api"
import isEqual from "lodash/isEqual" import isEqual from "lodash/isEqual"
import { import {
HoppRESTHeader, HoppRESTHeader,
@@ -177,13 +218,29 @@ import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array" import * as A from "fp-ts/Array"
import cloneDeep from "lodash/cloneDeep" import cloneDeep from "lodash/cloneDeep"
import draggable from "vuedraggable" import draggable from "vuedraggable"
import { RequestOptionTabs } from "./RequestOptions.vue"
import { useCodemirror } from "~/helpers/editor/codemirror" import { useCodemirror } from "~/helpers/editor/codemirror"
import { restHeaders$, setRESTHeaders } from "~/newstore/RESTSession" import {
getRESTRequest,
restHeaders$,
restRequest$,
setRESTHeaders,
} from "~/newstore/RESTSession"
import { commonHeaders } from "~/helpers/headers" import { commonHeaders } from "~/helpers/headers"
import { useI18n, useStream, useToast } from "~/helpers/utils/composables" import {
useI18n,
useReadonlyStream,
useStream,
useToast,
} from "~/helpers/utils/composables"
import linter from "~/helpers/editor/linting/rawKeyValue" import linter from "~/helpers/editor/linting/rawKeyValue"
import { throwError } from "~/helpers/functional/error" import { throwError } from "~/helpers/functional/error"
import { objRemoveKey } from "~/helpers/functional/object" import { objRemoveKey } from "~/helpers/functional/object"
import {
ComputedHeader,
getComputedHeaders,
} from "~/helpers/utils/EffectiveURL"
import { aggregateEnvs$, getAggregateEnvs } from "~/newstore/environments"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
@@ -196,6 +253,10 @@ const bulkEditor = ref<any | null>(null)
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null) const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
const emit = defineEmits<{
(e: "change-tab", value: RequestOptionTabs): void
}>()
useCodemirror(bulkEditor, bulkHeaders, { useCodemirror(bulkEditor, bulkHeaders, {
extendedEditorConfig: { extendedEditorConfig: {
mode: "text/x-yaml", mode: "text/x-yaml",
@@ -379,4 +440,28 @@ const clearContent = () => {
bulkHeaders.value = "" bulkHeaders.value = ""
} }
const restRequest = useReadonlyStream(restRequest$, getRESTRequest())
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs())
const computedHeaders = computed(() =>
getComputedHeaders(restRequest.value, aggregateEnvs.value)
)
const masking = ref(true)
const toggleMask = () => {
masking.value = !masking.value
}
const mask = (header: ComputedHeader) => {
if (header.source === "auth" && masking.value)
return header.header.value.replace(/\S/gi, "*")
return header.header.value
}
const changeTab = (tab: ComputedHeader["source"]) => {
if (tab === "auth") emit("change-tab", "authorization")
else emit("change-tab", "bodyParams")
}
</script> </script>

View File

@@ -171,6 +171,12 @@
} }
" "
/> />
<SmartItem
svg="link-2"
:label="`${t('request.view_my_links')}`"
to="/profile"
/>
<hr />
<SmartItem <SmartItem
ref="saveRequestAction" ref="saveRequestAction"
:label="`${t('request.save_as')}`" :label="`${t('request.save_as')}`"
@@ -208,6 +214,7 @@
import { computed, ref, watch } from "@nuxtjs/composition-api" import { computed, ref, watch } from "@nuxtjs/composition-api"
import { isLeft, isRight } from "fp-ts/lib/Either" import { isLeft, isRight } from "fp-ts/lib/Either"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import cloneDeep from "lodash/cloneDeep"
import { import {
updateRESTResponse, updateRESTResponse,
restEndpoint$, restEndpoint$,
@@ -477,14 +484,21 @@ const saveRequest = () => {
showSaveRequestModal.value = true showSaveRequestModal.value = true
return return
} }
if (saveCtx.originLocation === "user-collection") { if (saveCtx.originLocation === "user-collection") {
const req = getRESTRequest()
try { try {
editRESTRequest( editRESTRequest(
saveCtx.folderPath, saveCtx.folderPath,
saveCtx.requestIndex, saveCtx.requestIndex,
getRESTRequest() getRESTRequest()
) )
setRESTSaveContext({
originLocation: "user-collection",
folderPath: saveCtx.folderPath,
requestIndex: saveCtx.requestIndex,
req: cloneDeep(req),
})
toast.success(`${t("request.saved")}`) toast.success(`${t("request.saved")}`)
} catch (e) { } catch (e) {
setRESTSaveContext(null) setRESTSaveContext(null)
@@ -505,6 +519,11 @@ const saveRequest = () => {
if (E.isLeft(result)) { if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`) toast.error(`${t("profile.no_permission")}`)
} else { } else {
setRESTSaveContext({
originLocation: "team-collection",
requestID: saveCtx.requestID,
req: cloneDeep(req),
})
toast.success(`${t("request.saved")}`) toast.success(`${t("request.saved")}`)
} }
}) })

View File

@@ -18,7 +18,7 @@
:label="`${$t('tab.headers')}`" :label="`${$t('tab.headers')}`"
:info="`${newActiveHeadersCount$}`" :info="`${newActiveHeadersCount$}`"
> >
<HttpHeaders /> <HttpHeaders @change-tab="changeTab" />
</SmartTab> </SmartTab>
<SmartTab :id="'authorization'" :label="`${$t('tab.authorization')}`"> <SmartTab :id="'authorization'" :label="`${$t('tab.authorization')}`">
<HttpAuthorization /> <HttpAuthorization />

View File

@@ -17,26 +17,11 @@
/> />
</div> </div>
</div> </div>
<div <LensesHeadersRendererEntry
v-for="(header, index) in headers" v-for="(header, index) in headers"
:key="`header-${index}`" :key="index"
class="flex border-b divide-x divide-dividerLight border-dividerLight group" :header="header"
> />
<span
class="flex flex-1 min-w-0 px-4 py-2 transition group-hover:text-secondaryDark"
>
<span class="truncate rounded-sm select-all">
{{ header.key }}
</span>
</span>
<span
class="flex flex-1 min-w-0 px-4 py-2 transition group-hover:text-secondaryDark"
>
<span class="truncate rounded-sm select-all">
{{ header.value }}
</span>
</span>
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,52 @@
<template>
<div
class="flex border-b divide-x divide-dividerLight border-dividerLight group"
>
<span
class="flex flex-1 min-w-0 px-4 py-2 transition group-hover:text-secondaryDark"
>
<span class="truncate rounded-sm select-all">
{{ header.key }}
</span>
</span>
<span
class="flex flex-1 min-w-0 pl-4 py-2 transition group-hover:text-secondaryDark justify-between"
>
<span class="truncate rounded-sm select-all">
{{ header.value }}
</span>
<ButtonSecondary
ref="copyHeader"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:svg="copyIcon"
class="hidden group-hover:inline-flex !py-0"
@click.native="copyHeader(header.value)"
/>
</span>
</div>
</template>
<script setup lang="ts">
import { ref } from "@nuxtjs/composition-api"
import { HoppRESTHeader } from "~/../hoppscotch-data/dist"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useI18n, useToast } from "~/helpers/utils/composables"
const t = useI18n()
const toast = useToast()
defineProps<{
header: HoppRESTHeader
}>()
const copyIcon = ref("copy")
const copyHeader = (headerValue: string) => {
copyToClipboard(headerValue)
copyIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyIcon.value = "copy"), 1000)
}
</script>

View File

@@ -0,0 +1,135 @@
<template>
<div
class="table-row-groups lg:flex block my-6 lg:my-0 w-full border lg:border-0 divide-y lg:divide-y-0 lg:divide-x divide-dividerLight border-dividerLight"
>
<div
class="table-column font-mono text-tiny"
:data-label="t('shortcodes.short_code')"
>
{{ shortcode.id }}
</div>
<div
class="table-column"
:class="requestLabelColor"
:data-label="t('shortcodes.method')"
>
{{ parseShortcodeRequest.method }}
</div>
<div class="table-column" :data-label="t('shortcodes.url')">
{{ parseShortcodeRequest.endpoint }}
</div>
<div
ref="timeStampRef"
class="table-column"
:data-label="t('shortcodes.created_on')"
>
<span v-tippy="{ theme: 'tooltip' }" :title="timeStamp">
{{ dateStamp }}
</span>
</div>
<div
class="flex flex-1 items-center justify-center px-3"
:data-label="t('shortcodes.actions')"
>
<SmartAnchor
v-tippy="{ theme: 'tooltip' }"
:title="t('action.open_workspace')"
:to="`https://hopp.sh/r/${shortcode.id}`"
blank
svg="external-link"
class="px-3 text-accent hover:text-accent"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
color="green"
:svg="copyIconRefs"
class="px-3"
@click.native="copyShortcode(shortcode.id)"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
svg="trash"
color="red"
class="px-3"
@click.native="deleteShortcode(shortcode.id)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "@nuxtjs/composition-api"
import { pipe } from "fp-ts/function"
import * as RR from "fp-ts/ReadonlyRecord"
import * as O from "fp-ts/Option"
import { translateToNewRequest } from "@hoppscotch/data"
import { useI18n, useToast } from "~/helpers/utils/composables"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { Shortcode } from "~/helpers/shortcodes/Shortcode"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
shortcode: Shortcode
}>()
const emit = defineEmits<{
(e: "delete-shortcode", codeID: string): void
}>()
const deleteShortcode = (codeID: string) => {
emit("delete-shortcode", codeID)
}
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 timeStampRef = ref()
const copyIconRefs = ref<"copy" | "check">("copy")
const parseShortcodeRequest = computed(() =>
pipe(props.shortcode.request, JSON.parse, translateToNewRequest)
)
const requestLabelColor = computed(() =>
pipe(
requestMethodLabels,
RR.lookup(parseShortcodeRequest.value.method.toLowerCase()),
O.getOrElseW(() => requestMethodLabels.default)
)
)
const dateStamp = computed(() =>
new Date(props.shortcode.createdOn).toLocaleDateString()
)
const timeStamp = computed(() =>
new Date(props.shortcode.createdOn).toLocaleTimeString()
)
const copyShortcode = (codeID: string) => {
copyToClipboard(`https://hopp.sh/r/${codeID}`)
toast.success(`${t("state.copied_to_clipboard")}`)
copyIconRefs.value = "check"
setTimeout(() => (copyIconRefs.value = "copy"), 1000)
}
</script>
<style lang="scss">
.table-column {
@apply flex flex-1 items-center px-3 py-3 truncate;
}
.table-row-groups {
.table-column {
@apply before:text-secondary before:font-bold before:content-[attr(data-label)] lg:before:hidden;
}
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="flex flex-col flex-1">
<div v-if="showEventField" class="flex items-center justify-between p-4">
<input
id="event_name"
v-model="eventName"
class="input"
name="event_name"
:placeholder="`${t('socketio.event_name')}`"
type="text"
autocomplete="off"
/>
</div>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperMobileSecondaryStickyFold sm:top-upperSecondaryStickyFold"
>
<span class="flex items-center">
<label class="font-semibold text-secondaryLight">
{{ $t("websocket.message") }}
</label>
<tippy
ref="contentTypeOptions"
interactive
trigger="click"
theme="popover"
arrow
>
<template #trigger>
<span class="select-wrapper">
<ButtonSecondary
:label="contentType || $t('state.none').toLowerCase()"
class="pr-8 ml-2 rounded-none"
/>
</span>
</template>
<div class="flex flex-col" role="menu">
<SmartItem
v-for="(contentTypeItem, index) in validContentTypes"
:key="`contentTypeItem-${index}`"
:label="contentTypeItem"
:info-icon="contentTypeItem === contentType ? 'done' : ''"
:active-info-icon="contentTypeItem === contentType"
@click.native="
() => {
contentType = contentTypeItem
$refs.contentTypeOptions.tippy().hide()
}
"
/>
</div>
</tippy>
</span>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t('action.send')}`"
:label="`${t('action.send')}`"
:disabled="!communicationBody || !isConnected"
svg="send"
class="rounded-none !text-accent !hover:text-accentDark"
@click.native="sendMessage()"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/body"
blank
:title="t('app.wiki')"
svg="help-circle"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
svg="wrap-text"
@click.native.prevent="linewrapEnabled = !linewrapEnabled"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear')"
svg="trash-2"
@click.native="clearContent"
/>
<ButtonSecondary
v-if="contentType && contentType == 'JSON'"
ref="prettifyRequest"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.prettify')"
:svg="prettifyIcon"
@click.native="prettifyRequestBody"
/>
<label for="payload">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('import.title')"
svg="file-plus"
@click.native="$refs.payload.click()"
/>
</label>
<input
ref="payload"
class="input"
name="payload"
type="file"
@change="uploadPayload"
/>
</div>
</div>
<div ref="wsCommunicationBody" class="flex flex-col flex-1"></div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "@nuxtjs/composition-api"
import { pipe } from "fp-ts/function"
import * as TO from "fp-ts/TaskOption"
import * as O from "fp-ts/Option"
import { useCodemirror } from "~/helpers/editor/codemirror"
import jsonLinter from "~/helpers/editor/linting/json"
import { readFileAsText } from "~/helpers/functional/files"
import { useI18n, useToast } from "~/helpers/utils/composables"
import { isJSONContentType } from "~/helpers/utils/contenttypes"
defineProps({
showEventField: {
type: Boolean,
default: false,
},
isConnected: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(
e: "send-message",
body: {
eventName: string
message: string
}
): void
}>()
const t = useI18n()
const toast = useToast()
const linewrapEnabled = ref(true)
const wsCommunicationBody = ref<HTMLElement>()
const prettifyIcon = ref<"wand" | "check" | "info">("wand")
const knownContentTypes = {
JSON: "application/ld+json",
Raw: "text/plain",
} as const
const validContentTypes = Object.keys(knownContentTypes)
const contentType = ref<keyof typeof knownContentTypes>("JSON")
const eventName = ref("")
const communicationBody = ref("")
const rawInputEditorLang = computed(() => knownContentTypes[contentType.value])
const langLinter = computed(() =>
isJSONContentType(contentType.value) ? jsonLinter : null
)
useCodemirror(
wsCommunicationBody,
communicationBody,
reactive({
extendedEditorConfig: {
lineWrapping: linewrapEnabled,
mode: rawInputEditorLang,
placeholder: t("websocket.message").toString(),
},
linter: langLinter,
completer: null,
environmentHighlights: true,
})
)
const clearContent = () => {
communicationBody.value = ""
}
const sendMessage = () => {
if (!communicationBody.value) return
emit("send-message", {
eventName: eventName.value,
message: communicationBody.value,
})
communicationBody.value = ""
}
const uploadPayload = async (e: InputEvent) => {
const result = await pipe(
(e.target as HTMLInputElement).files?.[0],
TO.fromNullable,
TO.chain(readFileAsText)
)()
if (O.isSome(result)) {
communicationBody.value = result.value
toast.success(`${t("state.file_imported")}`)
} else {
toast.error(`${t("action.choose_file")}`)
}
}
const prettifyRequestBody = () => {
try {
const jsonObj = JSON.parse(communicationBody.value)
communicationBody.value = JSON.stringify(jsonObj, null, 2)
prettifyIcon.value = "check"
} catch (e) {
console.error(e)
prettifyIcon.value = "info"
toast.error(`${t("error.json_prettify_invalid_body")}`)
}
setTimeout(() => (prettifyIcon.value = "wand"), 1000)
}
</script>

View File

@@ -1,77 +1,164 @@
<template> <template>
<div class="flex flex-col flex-1"> <div ref="container" class="flex flex-col flex-1 overflow-y-auto">
<div <div
class="sticky top-0 z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight" class="sticky top-0 z-10 flex items-center justify-between flex-none pl-4 border-b bg-primary border-dividerLight"
> >
<label for="log" class="py-2 font-semibold text-secondaryLight"> <label for="log" class="py-2 font-semibold text-secondaryLight">
{{ title }} {{ title }}
</label> </label>
<div>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.search')"
svg="search"
@click.native="toggleSearch = !toggleSearch"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
svg="trash"
@click.native="emit('delete')"
/>
<ButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.scroll_to_top')"
svg="arrow-up"
@click.native="scrollTo('top')"
/>
<ButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.scroll_to_bottom')"
svg="arrow-down"
@click.native="scrollTo('bottom')"
/>
<ButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.autoscroll')"
svg="chevrons-down"
:class="toggleAutoscrollColor"
@click.native="toggleAutoscroll()"
/>
</div> </div>
<div ref="logsRef" name="log" class="realtime-log"> </div>
<span v-if="log" class="space-y-2">
<span <div
v-for="(entry, index) in log" v-if="toggleSearch"
:key="`entry-${index}`" class="w-full p-2 sticky top-0 z-10 text-center border-b border-dividerLight"
:style="{ color: entry.color }"
class="font-mono"
>{{ entry.ts }}{{ source(entry.source) }}{{ entry.payload }}</span
> >
<span
class="bg-primaryLight border-divider text-secondaryDark rounded inline-flex"
>
<ButtonSecondary svg="search" class="item-center" />
<input
id=""
v-model="pattern"
type="text"
placeholder="Enter search pattern"
class="rounded w-64 bg-primaryLight text-secondaryDark text-center"
/>
</span> </span>
<span v-else>{{ t("response.waiting_for_connection") }}</span> </div>
<div
v-if="log.length !== 0"
ref="logs"
class="overflow-y-auto border-b border-dividerLight"
>
<div
class="flex flex-col h-auto h-full border-r divide-y divide-dividerLight border-dividerLight"
>
<RealtimeLogEntry
v-for="(entry, index) in logEntries"
:key="`entry-${index}`"
:entry="entry"
:highlight-regex="pattern === '' ? undefined : patternRegex"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref, watch } from "@nuxtjs/composition-api" import { ref, computed, watch } from "@nuxtjs/composition-api"
import { getSourcePrefix as source } from "~/helpers/utils/string" import { useThrottleFn, useScroll } from "@vueuse/core"
import { regexEscape } from "~/helpers/functional/regex"
import { useI18n } from "~/helpers/utils/composables" import { useI18n } from "~/helpers/utils/composables"
export type LogEntryData = {
prefix?: string
ts: number | undefined
source: "info" | "client" | "server" | "disconnected"
payload: string
event: "connecting" | "connected" | "disconnected" | "error"
}
const props = defineProps<{ log: LogEntryData[]; title: string }>()
const emit = defineEmits<{
(e: "delete"): void
}>()
const t = useI18n() const t = useI18n()
const props = defineProps({ const container = ref<HTMLElement | null>(null)
log: { type: Array, default: () => [] }, const logs = ref<HTMLElement | null>(null)
title: {
type: String, const autoScrollEnabled = ref(true)
default: "",
}, const logListScroll = useScroll(logs)
// Disable autoscroll when scrolling to top
watch(logListScroll.isScrolling, (isScrolling) => {
if (isScrolling && logListScroll.directions.top)
autoScrollEnabled.value = false
}) })
const logsRef = ref<any | null>(null) const scrollTo = (position: "top" | "bottom") => {
const BOTTOM_SCROLL_DIST_INACCURACY = 5 if (position === "top") {
logs.value?.scroll({
behavior: "smooth",
top: 0,
})
} else if (position === "bottom") {
logs.value?.scroll({
behavior: "smooth",
top: logs.value?.scrollHeight,
})
}
}
watch( watch(
() => props.log, () => props.log,
() => { useThrottleFn(() => {
if (!logsRef.value) return if (autoScrollEnabled.value) scrollTo("bottom")
const distToBottom = }, 200),
logsRef.value.scrollHeight - { flush: "post" }
logsRef.value.scrollTop - )
logsRef.value.clientHeight
if (distToBottom < BOTTOM_SCROLL_DIST_INACCURACY) { const toggleAutoscroll = () => {
nextTick(() => (logsRef.value.scrollTop = logsRef.value.scrollHeight)) autoScrollEnabled.value = !autoScrollEnabled.value
}
} }
const pattern = ref("")
const toggleSearch = ref(false)
const patternRegex = computed(
() => new RegExp(regexEscape(pattern.value), "gi")
)
const logEntries = computed(() => {
if (patternRegex.value) {
return props.log.filter((entry) => entry.payload.match(patternRegex.value))
} else return props.log
})
const toggleAutoscrollColor = computed(() =>
autoScrollEnabled.value ? "text-green-500" : "text-red-500"
) )
</script> </script>
<style scoped lang="scss"> <style></style>
.realtime-log {
@apply p-4;
@apply bg-transparent;
@apply text-secondary;
@apply overflow-auto;
height: 256px;
&,
span {
@apply select-text;
}
span {
@apply block;
@apply break-words break-all;
}
}
</style>

View File

@@ -0,0 +1,442 @@
<template>
<div v-if="entry" class="divide-y divide-dividerLight">
<div :style="{ color: entryColor }" class="realtime-log">
<div class="flex group">
<div class="flex flex-1 divide-x divide-dividerLight">
<div class="inline-flex items-center p-2">
<SmartIcon
class="svg-icons"
:name="iconName"
:style="{ color: iconColor }"
@click.native="copyQuery(entry.payload)"
/>
</div>
<div
v-if="entry.ts !== undefined"
class="items-center hidden px-1 w-18 sm:inline-flex"
>
<span
v-tippy="{ theme: 'tooltip' }"
:title="relativeTime"
class="mx-auto truncate ts-font text-secondaryLight hover:text-secondary hover:text-center"
>
{{ new Date(entry.ts).toLocaleTimeString() }}
</span>
</div>
<div
class="items-center flex-1 min-w-0 p-2 inline-grid"
@click="toggleExpandPayload()"
>
<div class="truncate">
<span v-if="entry.prefix !== undefined" class="!inline">{{
entry.prefix
}}</span>
<span
v-for="(section, index) in highlightingSections"
:key="index"
class="!inline"
:class="section.mode === 'highlight' ? 'highlight' : ''"
>{{ section.text }}</span
>
</div>
</div>
</div>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:svg="`${copyQueryIcon}`"
class="hidden group-hover:inline-flex"
@click.native="copyQuery(entry.payload)"
/>
<ButtonSecondary
svg="chevron-down"
class="transform"
:class="{ 'rotate-180': !minimized }"
@click.native="toggleExpandPayload()"
/>
</div>
</div>
<div v-if="!minimized" class="overflow-hidden bg-primaryLight">
<SmartTabs v-model="selectedTab" styles="bg-primaryLight">
<SmartTab v-if="isJSON(entry.payload)" id="json" label="JSON" />
<SmartTab id="raw" label="Raw" />
</SmartTabs>
<div
class="z-10 flex items-center justify-between pl-4 border-b border-dividerLight top-lowerSecondaryStickyFold"
>
<label class="font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
svg="wrap-text"
@click.native.prevent="linewrapEnabled = !linewrapEnabled"
/>
<ButtonSecondary
ref="downloadResponse"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:svg="downloadIcon"
@click.native="downloadResponse"
/>
<ButtonSecondary
ref="copyResponse"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:svg="copyIcon"
@click.native="copyResponse"
/>
</div>
</div>
<div ref="editor"></div>
<div
v-if="outlinePath && selectedTab === 'json'"
class="sticky bottom-0 z-10 flex px-2 overflow-auto border-t bg-primaryLight border-dividerLight flex-nowrap hide-scrollbar"
>
<div
v-for="(item, index) in outlinePath"
:key="`item-${index}`"
class="flex items-center"
>
<tippy
ref="outlineOptions"
interactive
trigger="click"
theme="popover"
arrow
>
<template #trigger>
<div v-if="item.kind === 'RootObject'" class="outline-item">
{}
</div>
<div v-if="item.kind === 'RootArray'" class="outline-item">
[]
</div>
<div v-if="item.kind === 'ArrayMember'" class="outline-item">
{{ item.index }}
</div>
<div v-if="item.kind === 'ObjectMember'" class="outline-item">
{{ item.name }}
</div>
</template>
<div
v-if="item.kind === 'ArrayMember' || item.kind === 'ObjectMember'"
>
<div
v-if="item.kind === 'ArrayMember'"
class="flex flex-col"
role="menu"
>
<SmartItem
v-for="(arrayMember, astIndex) in item.astParent.values"
:key="`ast-${astIndex}`"
:label="`${astIndex}`"
@click.native="
() => {
jumpCursor(arrayMember)
outlineOptions[index].tippy().hide()
}
"
/>
</div>
<div
v-if="item.kind === 'ObjectMember'"
class="flex flex-col"
role="menu"
>
<SmartItem
v-for="(objectMember, astIndex) in item.astParent.members"
:key="`ast-${astIndex}`"
:label="objectMember.key.value"
@click.native="
() => {
jumpCursor(objectMember)
outlineOptions[index].tippy().hide()
}
"
/>
</div>
</div>
<div
v-if="item.kind === 'RootObject'"
class="flex flex-col"
role="menu"
>
<SmartItem
label="{}"
@click.native="
() => {
jumpCursor(item.astValue)
outlineOptions[index].tippy().hide()
}
"
/>
</div>
<div
v-if="item.kind === 'RootArray'"
class="flex flex-col"
role="menu"
>
<SmartItem
label="[]"
@click.native="
() => {
jumpCursor(item.astValue)
outlineOptions[index].tippy().hide()
}
"
/>
</div>
</tippy>
<i
v-if="index + 1 !== outlinePath.length"
class="opacity-50 text-secondaryLight material-icons"
>
chevron_right
</i>
</div>
</div>
</div>
</div>
<div v-else>{{ t("response.waiting_for_connection") }}</div>
</template>
<script setup lang="ts">
import * as LJSON from "lossless-json"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import { ref, computed, reactive, watch } from "@nuxtjs/composition-api"
import { useTimeAgo } from "@vueuse/core"
import { LogEntryData } from "./Log.vue"
import { useI18n } from "~/helpers/utils/composables"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { isJSON } from "~/helpers/functional/json"
import { regexFindAllMatches } from "~/helpers/functional/regex"
import useCopyResponse from "~/helpers/lenses/composables/useCopyResponse"
import useDownloadResponse from "~/helpers/lenses/composables/useDownloadResponse"
import { useCodemirror } from "~/helpers/editor/codemirror"
import jsonParse, { JSONObjectMember, JSONValue } from "~/helpers/jsonParse"
import { getJSONOutlineAtPos } from "~/helpers/newOutline"
import {
convertIndexToLineCh,
convertLineChToIndex,
} from "~/helpers/editor/utils"
const t = useI18n()
const props = defineProps<{
entry: LogEntryData
highlightRegex?: RegExp
}>()
const outlineOptions = ref<any | null>(null)
const editor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const logPayload = computed(() => props.entry.payload)
type HighlightSection = {
mode: "normal" | "highlight"
text: string
}
const highlightingSections = computed<HighlightSection[]>(() => {
if (!props.highlightRegex)
return [{ mode: "normal", text: props.entry.payload }]
const line = props.entry.payload.split("\n")[0]
const ranges = pipe(line, regexFindAllMatches(props.highlightRegex))
const result: HighlightSection[] = []
let point = 0
ranges.forEach(({ startIndex, endIndex }) => {
if (point < startIndex)
result.push({
mode: "normal",
text: line.slice(point, startIndex),
})
result.push({
mode: "highlight",
text: line.slice(startIndex, endIndex + 1),
})
point = endIndex + 1
})
if (point < line.length)
result.push({
mode: "normal",
text: line.slice(point, line.length),
})
return result
})
const selectedTab = ref<"json" | "raw">(
isJSON(props.entry.payload) ? "json" : "raw"
)
// CodeMirror Implementation
const jsonBodyText = computed(() =>
pipe(
logPayload.value,
O.tryCatchK(LJSON.parse),
O.map((val) => LJSON.stringify(val, undefined, 2)),
O.getOrElse(() => logPayload.value)
)
)
const ast = computed(() =>
pipe(
jsonBodyText.value,
O.tryCatchK(jsonParse),
O.getOrElseW(() => null)
)
)
const editorText = computed(() => {
if (selectedTab.value === "json") return jsonBodyText.value
else return logPayload.value
})
const editorMode = computed(() => {
if (selectedTab.value === "json") return "application/ld+json"
else return "text/plain"
})
const { cursor } = useCodemirror(
editor,
editorText,
reactive({
extendedEditorConfig: {
mode: editorMode,
readOnly: true,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,
environmentHighlights: false,
})
)
const jumpCursor = (ast: JSONValue | JSONObjectMember) => {
const pos = convertIndexToLineCh(jsonBodyText.value, ast.start)
pos.line--
cursor.value = pos
}
const outlinePath = computed(() =>
pipe(
ast.value,
O.fromNullable,
O.map((ast) =>
getJSONOutlineAtPos(
ast,
convertLineChToIndex(jsonBodyText.value, cursor.value)
)
),
O.getOrElseW(() => null)
)
)
// Code for UI Changes
const minimized = ref(true)
watch(minimized, () => {
selectedTab.value = isJSON(props.entry.payload) ? "json" : "raw"
})
const toggleExpandPayload = () => {
minimized.value = !minimized.value
}
const { copyIcon, copyResponse } = useCopyResponse(logPayload)
const { downloadIcon, downloadResponse } = useDownloadResponse(
"application/json",
logPayload
)
const copyQueryIcon = ref("copy")
const copyQuery = (entry: string) => {
copyToClipboard(entry)
copyQueryIcon.value = "check"
setTimeout(() => (copyQueryIcon.value = "copy"), 1000)
}
// Relative Time
// TS could be undefined here. We're just assigning a default value to 0 because we're not showing it in the UI
const relativeTime = useTimeAgo(computed(() => props.entry.ts ?? 0))
const ENTRY_COLORS = {
connected: "#10b981",
connecting: "#10b981",
error: "#ff5555",
disconnected: "#ff5555",
} as const
// Assigns color based on entry event
const entryColor = computed(() => ENTRY_COLORS[props.entry.event])
const ICONS = {
info: {
iconName: "info-realtime",
iconColor: "#10b981",
},
client: {
iconName: "arrow-up-right",
iconColor: "#eaaa45",
},
server: {
iconName: "arrow-down-left",
iconColor: "#38d4ea",
},
disconnected: {
iconName: "info-disconnect",
iconColor: "#ff5555",
},
} as const
const iconColor = computed(() => ICONS[props.entry.source].iconColor)
const iconName = computed(() => ICONS[props.entry.source].iconName)
</script>
<style scoped lang="scss">
.realtime-log {
@apply text-secondary;
@apply overflow-hidden;
&,
span {
@apply select-text;
}
span {
@apply block;
@apply break-words break-all;
}
}
.outline-item {
@apply cursor-pointer;
@apply flex-grow-0 flex-shrink-0;
@apply text-secondaryLight;
@apply inline-flex;
@apply items-center;
@apply px-2;
@apply py-1;
@apply transition;
@apply hover: text-secondary;
}
.ts-font {
font-size: 0.6rem;
}
.highlight {
color: yellow;
}
</style>

View File

@@ -13,17 +13,22 @@
spellcheck="false" spellcheck="false"
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark" class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
:placeholder="$t('mqtt.url')" :placeholder="$t('mqtt.url')"
:disabled="connectionState" :disabled="
@keyup.enter="validUrl ? toggleConnection() : null" connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/> />
<ButtonPrimary <ButtonPrimary
id="connect" id="connect"
:disabled="!validUrl" :disabled="!isUrlValid"
class="w-32" class="w-32"
:label=" :label="
connectionState ? $t('action.disconnect') : $t('action.connect') connectionState === 'DISCONNECTED'
? t('action.connect')
: t('action.disconnect')
" "
:loading="connectingState" :loading="connectionState === 'CONNECTING'"
@click.native="toggleConnection" @click.native="toggleConnection"
/> />
</div> </div>
@@ -48,18 +53,22 @@
</div> </div>
</template> </template>
<template #secondary> <template #secondary>
<RealtimeLog :title="$t('mqtt.log')" :log="log" /> <RealtimeLog
:title="$t('mqtt.log')"
:log="log"
@delete="clearLogEntries()"
/>
</template> </template>
<template #sidebar> <template #sidebar>
<div class="flex items-center justify-between p-4"> <div class="flex items-center justify-between p-4">
<label for="pub_topic" class="font-semibold text-secondaryLight"> <label for="pubTopic" class="font-semibold text-secondaryLight">
{{ $t("mqtt.topic") }} {{ $t("mqtt.topic") }}
</label> </label>
</div> </div>
<div class="flex px-4"> <div class="flex px-4">
<input <input
id="pub_topic" id="pubTopic"
v-model="pub_topic" v-model="pubTopic"
class="input" class="input"
:placeholder="$t('mqtt.topic_name')" :placeholder="$t('mqtt.topic_name')"
type="text" type="text"
@@ -75,7 +84,7 @@
<div class="flex px-4 space-x-2"> <div class="flex px-4 space-x-2">
<input <input
id="mqtt-message" id="mqtt-message"
v-model="msg" v-model="message"
class="input" class="input"
type="text" type="text"
autocomplete="off" autocomplete="off"
@@ -85,7 +94,7 @@
<ButtonPrimary <ButtonPrimary
id="publish" id="publish"
name="get" name="get"
:disabled="!canpublish" :disabled="!canPublish"
:label="$t('mqtt.publish')" :label="$t('mqtt.publish')"
@click.native="publish" @click.native="publish"
/> />
@@ -93,14 +102,14 @@
<div <div
class="flex items-center justify-between p-4 mt-4 border-t border-dividerLight" class="flex items-center justify-between p-4 mt-4 border-t border-dividerLight"
> >
<label for="sub_topic" class="font-semibold text-secondaryLight"> <label for="subTopic" class="font-semibold text-secondaryLight">
{{ $t("mqtt.topic") }} {{ $t("mqtt.topic") }}
</label> </label>
</div> </div>
<div class="flex px-4 space-x-2"> <div class="flex px-4 space-x-2">
<input <input
id="sub_topic" id="subTopic"
v-model="sub_topic" v-model="subTopic"
type="text" type="text"
autocomplete="off" autocomplete="off"
:placeholder="$t('mqtt.topic_name')" :placeholder="$t('mqtt.topic_name')"
@@ -110,7 +119,7 @@
<ButtonPrimary <ButtonPrimary
id="subscribe" id="subscribe"
name="get" name="get"
:disabled="!cansubscribe" :disabled="!canSubscribe"
:label=" :label="
subscriptionState ? $t('mqtt.unsubscribe') : $t('mqtt.subscribe') subscriptionState ? $t('mqtt.unsubscribe') : $t('mqtt.subscribe')
" "
@@ -122,261 +131,220 @@
</AppPaneLayout> </AppPaneLayout>
</template> </template>
<script> <script setup lang="ts">
import { defineComponent } from "@nuxtjs/composition-api" import {
import Paho from "paho-mqtt" computed,
import debounce from "lodash/debounce" onMounted,
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics" onUnmounted,
ref,
watch,
} from "@nuxtjs/composition-api"
import debounce from "lodash/debounce"
import { MQTTConnection, MQTTError } from "~/helpers/realtime/MQTTConnection"
import {
useI18n,
useNuxt,
useReadonlyStream,
useStream,
useStreamSubscriber,
useToast,
} from "~/helpers/utils/composables"
import { import {
MQTTEndpoint$,
setMQTTEndpoint,
MQTTConnectingState$,
MQTTConnectionState$,
setMQTTConnectingState,
setMQTTConnectionState,
MQTTSubscriptionState$,
setMQTTSubscriptionState,
MQTTSocket$,
setMQTTSocket,
MQTTLog$,
setMQTTLog,
addMQTTLogLine, addMQTTLogLine,
MQTTConn$,
MQTTEndpoint$,
MQTTLog$,
setMQTTConn,
setMQTTEndpoint,
setMQTTLog,
} from "~/newstore/MQTTSession" } from "~/newstore/MQTTSession"
import { useStream } from "~/helpers/utils/composables"
export default defineComponent({ const t = useI18n()
setup() { const nuxt = useNuxt()
return { const toast = useToast()
url: useStream(MQTTEndpoint$, "", setMQTTEndpoint), const { subscribeToStream } = useStreamSubscriber()
connectionState: useStream(
MQTTConnectionState$, const url = useStream(MQTTEndpoint$, "", setMQTTEndpoint)
false, const log = useStream(MQTTLog$, [], setMQTTLog)
setMQTTConnectionState const socket = useStream(MQTTConn$, new MQTTConnection(), setMQTTConn)
), const connectionState = useReadonlyStream(
connectingState: useStream( socket.value.connectionState$,
MQTTConnectingState$, "DISCONNECTED"
false, )
setMQTTConnectingState const subscriptionState = useReadonlyStream(
), socket.value.subscriptionState$,
subscriptionState: useStream( false
MQTTSubscriptionState$, )
false,
setMQTTSubscriptionState const isUrlValid = ref(true)
), const pubTopic = ref("")
log: useStream(MQTTLog$, null, setMQTTLog), const subTopic = ref("")
client: useStream(MQTTSocket$, null, setMQTTSocket), const message = ref("")
const username = ref("")
const password = ref("")
let worker: Worker
const canPublish = computed(
() =>
pubTopic.value !== "" &&
message.value !== "" &&
connectionState.value === "CONNECTED"
)
const canSubscribe = computed(
() => subTopic.value !== "" && connectionState.value === "CONNECTED"
)
const workerResponseHandler = ({
data,
}: {
data: { url: string; result: boolean }
}) => {
if (data.url === url.value) isUrlValid.value = data.result
} }
},
data() { onMounted(() => {
return { worker = nuxt.value.$worker.createRejexWorker()
isUrlValid: true, worker.addEventListener("message", workerResponseHandler)
pub_topic: "",
sub_topic: "", subscribeToStream(socket.value.event$, (event) => {
msg: "", switch (event?.type) {
manualDisconnect: false, case "CONNECTING":
username: "", log.value = [
password: "",
}
},
computed: {
validUrl() {
return this.isUrlValid
},
canpublish() {
return this.pub_topic !== "" && this.msg !== "" && this.connectionState
},
cansubscribe() {
return this.sub_topic !== "" && this.connectionState
},
},
watch: {
url() {
this.debouncer()
},
},
created() {
if (process.browser) {
this.worker = this.$worker.createRejexWorker()
this.worker.addEventListener("message", this.workerResponseHandler)
}
},
destroyed() {
this.worker.terminate()
},
methods: {
debouncer: debounce(function () {
this.worker.postMessage({ type: "ws", url: this.url })
}, 1000),
workerResponseHandler({ data }) {
if (data.url === this.url) this.isUrlValid = data.result
},
connect() {
this.connectingState = true
this.log = [
{ {
payload: this.$t("state.connecting_to", { name: this.url }), payload: `${t("state.connecting_to", { name: url.value })}`,
source: "info", source: "info",
color: "var(--accent-color)", color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(), ts: undefined,
}, },
] ]
const parseUrl = new URL(this.url) break
this.client = new Paho.Client(
`${parseUrl.hostname}${
parseUrl.pathname !== "/" ? parseUrl.pathname : ""
}`,
parseUrl.port !== "" ? Number(parseUrl.port) : 8081,
"hoppscotch"
)
const connectOptions = {
onSuccess: this.onConnectionSuccess,
onFailure: this.onConnectionFailure,
useSSL: parseUrl.protocol !== "ws:",
}
if (this.username !== "") {
connectOptions.userName = this.username
}
if (this.password !== "") {
connectOptions.password = this.password
}
this.client.connect(connectOptions)
this.client.onConnectionLost = this.onConnectionLost
this.client.onMessageArrived = this.onMessageArrived
logHoppRequestRunToAnalytics({ case "CONNECTED":
platform: "mqtt", log.value = [
}) {
payload: `${t("state.connected_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: Date.now(),
}, },
onConnectionFailure() { ]
this.connectingState = false toast.success(`${t("state.connected")}`)
this.connectionState = false break
case "MESSAGE_SENT":
addMQTTLogLine({ addMQTTLogLine({
payload: this.$t("error.something_went_wrong"), prefix: `${event.message.topic}`,
payload: event.message.message,
source: "client",
ts: Date.now(),
})
break
case "MESSAGE_RECEIVED":
addMQTTLogLine({
prefix: `${event.message.topic}`,
payload: event.message.message,
source: "server",
ts: event.time,
})
break
case "SUBSCRIBED":
addMQTTLogLine({
payload: subscriptionState.value
? `${t("state.subscribed_success", { topic: subTopic.value })}`
: `${t("state.unsubscribed_success", { topic: subTopic.value })}`,
source: "server",
ts: event.time,
})
break
case "SUBSCRIPTION_FAILED":
addMQTTLogLine({
payload: subscriptionState.value
? `${t("state.subscribed_failed", { topic: subTopic.value })}`
: `${t("state.unsubscribed_failed", { topic: subTopic.value })}`,
source: "server",
ts: event.time,
})
break
case "ERROR":
addMQTTLogLine({
payload: getI18nError(event.error),
source: "info", source: "info",
color: "#ff5555", color: "#ff5555",
ts: new Date().toLocaleTimeString(), ts: event.time,
}) })
}, break
onConnectionSuccess() {
this.connectingState = false case "DISCONNECTED":
this.connectionState = true
addMQTTLogLine({ addMQTTLogLine({
payload: this.$t("state.connected_to", { name: this.url }), payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "info", source: "info",
color: "var(--accent-color)", color: "#ff5555",
ts: new Date().toLocaleTimeString(), ts: event.time,
}) })
this.$toast.success(this.$t("state.connected")) toast.error(`${t("state.disconnected")}`)
}, break
onMessageArrived({ payloadString, destinationName }) { }
addMQTTLogLine({
payload: `Message: ${payloadString} arrived on topic: ${destinationName}`,
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
}) })
}, })
toggleConnection() {
if (this.connectionState) { const debouncer = debounce(function () {
this.disconnect() worker.postMessage({ type: "ws", url: url.value })
}, 1000)
watch(url, (newUrl) => {
if (newUrl) debouncer()
})
onUnmounted(() => {
worker.terminate()
})
// METHODS
const toggleConnection = () => {
// If it is connecting:
if (connectionState.value === "DISCONNECTED") {
return socket.value.connect(url.value, username.value, password.value)
}
// Otherwise, it's disconnecting.
socket.value.disconnect()
}
const publish = () => {
socket.value?.publish(pubTopic.value, message.value)
}
const toggleSubscription = () => {
if (subscriptionState.value) {
socket.value.unsubscribe(subTopic.value)
} else { } else {
this.connect() socket.value.subscribe(subTopic.value)
} }
},
disconnect() {
this.manualDisconnect = true
this.client.disconnect()
addMQTTLogLine({
payload: this.$t("state.disconnected_from", { name: this.url }),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
onConnectionLost() {
this.connectingState = false
this.connectionState = false
if (this.manualDisconnect) {
this.$toast.error(this.$t("state.disconnected"))
} else {
this.$toast.error(this.$t("error.something_went_wrong"))
} }
this.manualDisconnect = false
this.subscriptionState = false const getI18nError = (error: MQTTError): string => {
}, if (typeof error === "string") return error
publish() {
try { switch (error.type) {
this.client.publish(this.pub_topic, this.msg, 0, false) case "CONNECTION_NOT_ESTABLISHED":
addMQTTLogLine({ return t("state.connection_lost").toString()
payload: `Published message: ${this.msg} to topic: ${this.pub_topic}`, case "SUBSCRIPTION_FAILED":
ts: new Date().toLocaleTimeString(), return t("state.mqtt_subscription_failed", {
source: "info", topic: error.topic,
color: "var(--accent-color)", }).toString()
}) case "PUBLISH_ERROR":
} catch (e) { return t("state.publish_error", { topic: error.topic }).toString()
addMQTTLogLine({ case "CONNECTION_LOST":
payload: return t("state.connection_lost").toString()
this.$t("error.something_went_wrong") + case "CONNECTION_FAILED":
`while publishing msg: ${this.msg} to topic: ${this.pub_topic}`, return t("state.connection_failed").toString()
source: "info", default:
color: "#ff5555", return t("state.disconnected_from", { name: url.value }).toString()
ts: new Date().toLocaleTimeString(),
})
} }
},
toggleSubscription() {
if (this.subscriptionState) {
this.unsubscribe()
} else {
this.subscribe()
} }
}, const clearLogEntries = () => {
subscribe() { log.value = []
try {
this.client.subscribe(this.sub_topic, {
onSuccess: this.usubSuccess,
onFailure: this.usubFailure,
})
} catch (e) {
addMQTTLogLine({
payload:
this.$t("error.something_went_wrong") +
`while subscribing to topic: ${this.sub_topic}`,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
} }
},
usubSuccess() {
this.subscriptionState = !this.subscriptionState
addMQTTLogLine({
payload:
`Successfully ` +
(this.subscriptionState ? "subscribed" : "unsubscribed") +
` to topic: ${this.sub_topic}`,
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
})
},
usubFailure() {
addMQTTLogLine({
payload:
`Failed to ` +
(this.subscriptionState ? "unsubscribe" : "subscribe") +
` to topic: ${this.sub_topic}`,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
unsubscribe() {
this.client.unsubscribe(this.sub_topic, {
onSuccess: this.usubSuccess,
onFailure: this.usubFailure,
})
},
},
})
</script> </script>

View File

@@ -23,13 +23,16 @@
class="flex px-4 py-2 font-semibold border rounded-l cursor-pointer bg-primaryLight border-divider text-secondaryDark w-26" class="flex px-4 py-2 font-semibold border rounded-l cursor-pointer bg-primaryLight border-divider text-secondaryDark w-26"
:value="`Client ${clientVersion}`" :value="`Client ${clientVersion}`"
readonly readonly
:disabled="connectionState" :disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
/> />
</span> </span>
</template> </template>
<div class="flex flex-col" role="menu"> <div class="flex flex-col" role="menu">
<SmartItem <SmartItem
v-for="(_, version) in socketIoClients" v-for="version in SIOVersions"
:key="`client-${version}`" :key="`client-${version}`"
:label="`Client ${version}`" :label="`Client ${version}`"
@click.native="onSelectVersion(version)" @click.native="onSelectVersion(version)"
@@ -43,40 +46,64 @@
type="url" type="url"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
:class="{ error: !urlValid }" :class="{ error: !isUrlValid }"
class="flex flex-1 w-full px-4 py-2 border bg-primaryLight border-divider text-secondaryDark" class="flex flex-1 w-full px-4 py-2 border bg-primaryLight border-divider text-secondaryDark"
:placeholder="$t('socketio.url')" :placeholder="`${t('socketio.url')}`"
:disabled="connectionState" :disabled="
@keyup.enter="urlValid ? toggleConnection() : null" connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/> />
<input <input
id="socketio-path" id="socketio-path"
v-model="path" v-model="path"
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark" class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
spellcheck="false" spellcheck="false"
:disabled="connectionState" :disabled="
@keyup.enter="urlValid ? toggleConnection() : null" connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/> />
</div> </div>
<ButtonPrimary <ButtonPrimary
id="connect" id="connect"
:disabled="!urlValid" :disabled="!isUrlValid"
name="connect" name="connect"
class="w-32" class="w-32"
:label=" :label="
!connectionState ? $t('action.connect') : $t('action.disconnect') connectionState === 'DISCONNECTED'
? t('action.connect')
: t('action.disconnect')
" "
:loading="connectingState" :loading="connectionState === 'CONNECTING'"
@click.native="toggleConnection" @click.native="toggleConnection"
/> />
</div> </div>
</div> </div>
<SmartTabs
v-model="selectedTab"
styles="sticky bg-primary top-upperMobilePrimaryStickyFold sm:top-upperPrimaryStickyFold z-10"
>
<SmartTab
:id="'communication'"
:label="`${t('websocket.communication')}`"
>
<RealtimeCommunication
:show-event-field="true"
:is-connected="connectionState === 'CONNECTED'"
@send-message="sendMessage($event)"
></RealtimeCommunication>
</SmartTab>
<SmartTab :id="'protocols'" :label="`${t('request.authorization')}`">
<div <div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold" class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
> >
<span class="flex items-center"> <span class="flex items-center">
<label class="font-semibold text-secondaryLight"> <label class="font-semibold text-secondaryLight">
{{ $t("authorization.type") }} {{ t("authorization.type") }}
</label> </label>
<tippy <tippy
ref="authTypeOptions" ref="authTypeOptions"
@@ -133,18 +160,18 @@
class="px-2" class="px-2"
@change="authActive = !authActive" @change="authActive = !authActive"
> >
{{ $t("state.enabled") }} {{ t("state.enabled") }}
</SmartCheckbox> </SmartCheckbox>
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/authorization" to="https://docs.hoppscotch.io/features/authorization"
blank blank
:title="$t('app.wiki')" :title="t('app.wiki')"
svg="help-circle" svg="help-circle"
/> />
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="$t('action.clear')" :title="t('action.clear')"
svg="trash-2" svg="trash-2"
@click.native="clearContent" @click.native="clearContent"
/> />
@@ -158,14 +185,14 @@
:src="`/images/states/${$colorMode.value}/login.svg`" :src="`/images/states/${$colorMode.value}/login.svg`"
loading="lazy" loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4" class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="$t('empty.authorization')" :alt="`${t('empty.authorization')}`"
/> />
<span class="pb-4 text-center"> <span class="pb-4 text-center">
This SocketIO connection does not use any authentication. {{ t("socketio.connection_not_authorized") }}
</span> </span>
<ButtonSecondary <ButtonSecondary
outline outline
:label="$t('app.documentation')" :label="t('app.documentation')"
to="https://docs.hoppscotch.io/features/authorization" to="https://docs.hoppscotch.io/features/authorization"
blank blank
svg="external-link" svg="external-link"
@@ -187,335 +214,222 @@
> >
<div class="p-2"> <div class="p-2">
<div class="pb-2 text-secondaryLight"> <div class="pb-2 text-secondaryLight">
{{ $t("helpers.authorization") }} {{ t("helpers.authorization") }}
</div> </div>
<SmartAnchor <SmartAnchor
class="link" class="link"
:label="`${$t('authorization.learn')} \xA0 →`" :label="`${t('authorization.learn')} \xA0 →`"
to="https://docs.hoppscotch.io/features/authorization" to="https://docs.hoppscotch.io/features/authorization"
blank blank
/> />
</div> </div>
</div> </div>
</div> </div>
</SmartTab>
</SmartTabs>
</template> </template>
<template #secondary> <template #secondary>
<RealtimeLog :title="$t('socketio.log')" :log="log" /> <RealtimeLog
</template> :title="t('socketio.log')"
<template #sidebar> :log="log"
<div class="flex items-center justify-between p-4"> @delete="clearLogEntries()"
<label for="events" class="font-semibold text-secondaryLight">
{{ $t("socketio.events") }}
</label>
</div>
<div class="flex px-4">
<input
id="event_name"
v-model="communication.eventName"
class="input"
name="event_name"
:placeholder="$t('socketio.event_name')"
type="text"
autocomplete="off"
:disabled="!connectionState"
/> />
</div>
<div class="flex items-center justify-between p-4">
<label class="font-semibold text-secondaryLight">
{{ $t("socketio.communication") }}
</label>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="$t('add.new')"
svg="plus"
@click.native="addCommunicationInput"
/>
</div>
</div>
<div class="flex flex-col px-4 pb-4 space-y-2">
<div
v-for="(input, index) of communication.inputs"
:key="`input-${index}`"
>
<div class="flex space-x-2">
<input
v-model="communication.inputs[index]"
class="input"
name="message"
:placeholder="$t('count.message', { count: index + 1 })"
type="text"
autocomplete="off"
:disabled="!connectionState"
@keyup.enter="connectionState ? sendMessage() : null"
/>
<ButtonSecondary
v-if="index + 1 !== communication.inputs.length"
v-tippy="{ theme: 'tooltip' }"
:title="$t('action.remove')"
svg="trash"
color="red"
outline
@click.native="removeCommunicationInput({ index })"
/>
<ButtonPrimary
v-if="index + 1 === communication.inputs.length"
id="send"
name="send"
:disabled="!connectionState"
:label="$t('action.send')"
@click.native="sendMessage"
/>
</div>
</div>
</div>
</template> </template>
</AppPaneLayout> </AppPaneLayout>
</template> </template>
<script> <script setup lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api" import { onMounted, onUnmounted, ref, watch } from "@nuxtjs/composition-api"
// All Socket.IO client version imports
import ClientV2 from "socket.io-client-v2"
import { io as ClientV3 } from "socket.io-client-v3"
import { io as ClientV4 } from "socket.io-client-v4"
import wildcard from "socketio-wildcard"
import debounce from "lodash/debounce" import debounce from "lodash/debounce"
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
import { import {
SIOEndpoint$, SIOConnection,
setSIOEndpoint, SIOError,
SIOVersion$, SIOMessage,
setSIOVersion, SOCKET_CLIENTS,
SIOPath$, } from "~/helpers/realtime/SIOConnection"
setSIOPath, import {
SIOConnectionState$, useI18n,
SIOConnectingState$, useNuxt,
setSIOConnectionState, useReadonlyStream,
setSIOConnectingState, useStream,
SIOSocket$, useStreamSubscriber,
setSIOSocket, useToast,
SIOLog$, } from "~/helpers/utils/composables"
setSIOLog, import {
addSIOLogLine, addSIOLogLine,
setSIOEndpoint,
setSIOLog,
setSIOPath,
setSIOVersion,
SIOClientVersion,
SIOEndpoint$,
SIOLog$,
SIOPath$,
SIOVersion$,
} from "~/newstore/SocketIOSession" } from "~/newstore/SocketIOSession"
import { useStream } from "~/helpers/utils/composables"
const socketIoClients = { const t = useI18n()
v4: ClientV4, const toast = useToast()
v3: ClientV3, const nuxt = useNuxt()
v2: ClientV2, const { subscribeToStream } = useStreamSubscriber()
type SIOTab = "communication" | "protocols"
const selectedTab = ref<SIOTab>("communication")
const SIOVersions = Object.keys(SOCKET_CLIENTS)
const url = useStream(SIOEndpoint$, "", setSIOEndpoint)
const clientVersion = useStream(SIOVersion$, "v4", setSIOVersion)
const path = useStream(SIOPath$, "", setSIOPath)
const socket = new SIOConnection()
const connectionState = useReadonlyStream(
socket.connectionState$,
"DISCONNECTED"
)
const log = useStream(SIOLog$, [], setSIOLog)
const authTypeOptions = ref<any>(null)
const versionOptions = ref<any | null>(null)
const isUrlValid = ref(true)
const authType = ref<"None" | "Bearer">("None")
const bearerToken = ref("")
const authActive = ref(true)
let worker: Worker
const workerResponseHandler = ({
data,
}: {
data: { url: string; result: boolean }
}) => {
if (data.url === url.value) isUrlValid.value = data.result
} }
export default defineComponent({ const getMessagePayload = (data: SIOMessage): string =>
setup() { typeof data.value === "object" ? JSON.stringify(data.value) : `${data.value}`
return {
socketIoClients, const getErrorPayload = (error: SIOError): string => {
url: useStream(SIOEndpoint$, "", setSIOEndpoint), switch (error.type) {
clientVersion: useStream(SIOVersion$, "", setSIOVersion), case "CONNECTION":
path: useStream(SIOPath$, "", setSIOPath), return t("state.connection_error").toString()
connectingState: useStream( case "RECONNECT_ERROR":
SIOConnectingState$, return t("state.reconnection_error").toString()
false, default:
setSIOConnectingState return t("state.disconnected_from", { name: url.value }).toString()
),
connectionState: useStream(
SIOConnectionState$,
false,
setSIOConnectionState
),
io: useStream(SIOSocket$, null, setSIOSocket),
log: useStream(SIOLog$, [], setSIOLog),
authTypeOptions: ref(null),
} }
},
data() {
return {
isUrlValid: true,
communication: {
eventName: "",
inputs: [""],
},
authType: "None",
bearerToken: "",
authActive: true,
} }
},
computed: { onMounted(() => {
urlValid() { worker = nuxt.value.$worker.createRejexWorker()
return this.isUrlValid worker.addEventListener("message", workerResponseHandler)
},
}, subscribeToStream(socket.event$, (event) => {
watch: { switch (event?.type) {
url() { case "CONNECTING":
this.debouncer() log.value = [
},
connectionState(connected) {
if (connected) this.$refs.versionOptions.tippy().disable()
else this.$refs.versionOptions.tippy().enable()
},
},
created() {
if (process.browser) {
this.worker = this.$worker.createRejexWorker()
this.worker.addEventListener("message", this.workerResponseHandler)
}
},
destroyed() {
this.worker.terminate()
},
methods: {
debouncer: debounce(function () {
this.worker.postMessage({ type: "socketio", url: this.url })
}, 1000),
workerResponseHandler({ data }) {
if (data.url === this.url) this.isUrlValid = data.result
},
removeCommunicationInput({ index }) {
this.$delete(this.communication.inputs, index)
},
addCommunicationInput() {
this.communication.inputs.push("")
},
toggleConnection() {
// If it is connecting:
if (!this.connectionState) return this.connect()
// Otherwise, it's disconnecting.
else return this.disconnect()
},
connect() {
this.connectingState = true
this.log = [
{ {
payload: this.$t("state.connecting_to", { name: this.url }), payload: `${t("state.connecting_to", { name: url.value })}`,
source: "info", source: "info",
color: "var(--accent-color)", color: "var(--accent-color)",
ts: undefined,
}, },
] ]
break
try { case "CONNECTED":
if (!this.path) { log.value = [
this.path = "/socket.io"
}
const Client = socketIoClients[this.clientVersion]
if (this.authActive && this.authType === "Bearer") {
this.io = new Client(this.url, {
path: this.path,
auth: {
token: this.bearerToken,
},
})
} else {
this.io = new Client(this.url, { path: this.path })
}
// Add ability to listen to all events
wildcard(Client.Manager)(this.io)
this.io.on("connect", () => {
this.connectingState = false
this.connectionState = true
this.log = [
{ {
payload: this.$t("state.connected_to", { name: this.url }), payload: `${t("state.connected_to", { name: url.value })}`,
source: "info", source: "info",
color: "var(--accent-color)", color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(), ts: event.time,
}, },
] ]
this.$toast.success(this.$t("state.connected")) toast.success(`${t("state.connected")}`)
}) break
this.io.on("*", ({ data }) => {
const [eventName, message] = data
addSIOLogLine({
payload: `[${eventName}] ${message ? JSON.stringify(message) : ""}`,
source: "server",
ts: new Date().toLocaleTimeString(),
})
})
this.io.on("connect_error", (error) => {
this.handleError(error)
})
this.io.on("reconnect_error", (error) => {
this.handleError(error)
})
this.io.on("error", () => {
this.handleError()
})
this.io.on("disconnect", () => {
this.connectingState = false
this.connectionState = false
addSIOLogLine({
payload: this.$t("state.disconnected_from", { name: this.url }),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
this.$toast.error(this.$t("state.disconnected"))
})
} catch (e) {
this.handleError(e)
this.$toast.error(this.$t("error.something_went_wrong"))
}
logHoppRequestRunToAnalytics({ case "MESSAGE_SENT":
platform: "socketio",
})
},
disconnect() {
this.io.close()
},
handleError(error) {
this.disconnect()
this.connectingState = false
this.connectionState = false
addSIOLogLine({ addSIOLogLine({
payload: this.$t("error.something_went_wrong"), prefix: `[${event.message.eventName}]`,
source: "info", payload: getMessagePayload(event.message),
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
if (error !== null)
addSIOLogLine({
payload: error,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
sendMessage() {
const eventName = this.communication.eventName
const messages = (this.communication.inputs || [])
.map((input) => {
try {
return JSON.parse(input)
} catch (e) {
return input
}
})
.filter((message) => !!message)
if (this.io) {
this.io.emit(eventName, ...messages, (data) => {
// receive response from server
addSIOLogLine({
payload: `[${eventName}] ${JSON.stringify(data)}`,
source: "server",
ts: new Date().toLocaleTimeString(),
})
})
addSIOLogLine({
payload: `[${eventName}] ${JSON.stringify(messages)}`,
source: "client", source: "client",
ts: new Date().toLocaleTimeString(), ts: event.time,
}) })
this.communication.inputs = [""] break
case "MESSAGE_RECEIVED":
addSIOLogLine({
prefix: `[${event.message.eventName}]`,
payload: getMessagePayload(event.message),
source: "server",
ts: event.time,
})
break
case "ERROR":
addSIOLogLine({
payload: getErrorPayload(event.error),
source: "info",
color: "#ff5555",
ts: event.time,
})
break
case "DISCONNECTED":
addSIOLogLine({
payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "info",
color: "#ff5555",
ts: event.time,
})
toast.error(`${t("state.disconnected")}`)
break
} }
},
onSelectVersion(version) {
this.clientVersion = version
this.$refs.versionOptions.tippy().hide()
},
},
}) })
})
watch(url, (newUrl) => {
if (newUrl) debouncer()
})
watch(connectionState, (connected) => {
if (connected) versionOptions.value.tippy().disable()
else versionOptions.value.tippy().enable()
})
onUnmounted(() => {
worker.terminate()
})
const debouncer = debounce(function () {
worker.postMessage({ type: "socketio", url: url.value })
}, 1000)
const toggleConnection = () => {
// If it is connecting:
if (connectionState.value === "DISCONNECTED") {
return socket.connect({
url: url.value,
path: path.value || "/socket.io",
clientVersion: clientVersion.value,
auth: authActive.value
? {
type: authType.value,
token: bearerToken.value,
}
: undefined,
})
}
// Otherwise, it's disconnecting.
socket.disconnect()
}
const sendMessage = (event: { message: string; eventName: string }) => {
socket.sendMessage(event)
}
const onSelectVersion = (version: SIOClientVersion) => {
clientVersion.value = version
versionOptions.value.tippy().hide()
}
const clearLogEntries = () => {
log.value = []
}
</script> </script>

View File

@@ -11,11 +11,13 @@
v-model="server" v-model="server"
type="url" type="url"
autocomplete="off" autocomplete="off"
:class="{ error: !serverValid }" :class="{ error: !isUrlValid }"
class="flex flex-1 w-full px-4 py-2 border rounded-l bg-primaryLight border-divider text-secondaryDark" class="flex flex-1 w-full px-4 py-2 border rounded-l bg-primaryLight border-divider text-secondaryDark"
:placeholder="$t('sse.url')" :placeholder="$t('sse.url')"
:disabled="connectionSSEState" :disabled="
@keyup.enter="serverValid ? toggleSSEConnection() : null" connectionState === 'STARTED' || connectionState === 'STARTING'
"
@keyup.enter="isUrlValid ? toggleSSEConnection() : null"
/> />
<label <label
for="event-type" for="event-type"
@@ -28,35 +30,42 @@
v-model="eventType" v-model="eventType"
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark" class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
spellcheck="false" spellcheck="false"
:disabled="connectionSSEState" :disabled="
@keyup.enter="serverValid ? toggleSSEConnection() : null" connectionState === 'STARTED' || connectionState === 'STARTING'
"
@keyup.enter="isUrlValid ? toggleSSEConnection() : null"
/> />
</div> </div>
<ButtonPrimary <ButtonPrimary
id="start" id="start"
:disabled="!serverValid" :disabled="!isUrlValid"
name="start" name="start"
class="w-32" class="w-32"
:label=" :label="
!connectionSSEState ? $t('action.start') : $t('action.stop') connectionState === 'STOPPED'
? t('action.start')
: t('action.stop')
" "
:loading="connectingState" :loading="connectionState === 'STARTING'"
@click.native="toggleSSEConnection" @click.native="toggleSSEConnection"
/> />
</div> </div>
</div> </div>
</template> </template>
<template #secondary> <template #secondary>
<RealtimeLog :title="$t('sse.log')" :log="log" /> <RealtimeLog
:title="$t('sse.log')"
:log="log"
@delete="clearLogEntries()"
/>
</template> </template>
</AppPaneLayout> </AppPaneLayout>
</template> </template>
<script> <script setup lang="ts">
import { defineComponent } from "@nuxtjs/composition-api" import { ref, watch, onUnmounted, onMounted } from "@nuxtjs/composition-api"
import "splitpanes/dist/splitpanes.css" import "splitpanes/dist/splitpanes.css"
import debounce from "lodash/debounce" import debounce from "lodash/debounce"
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
import { import {
SSEEndpoint$, SSEEndpoint$,
setSSEEndpoint, setSSEEndpoint,
@@ -64,159 +73,127 @@ import {
setSSEEventType, setSSEEventType,
SSESocket$, SSESocket$,
setSSESocket, setSSESocket,
SSEConnectingState$,
SSEConnectionState$,
setSSEConnectionState,
setSSEConnectingState,
SSELog$, SSELog$,
setSSELog, setSSELog,
addSSELogLine, addSSELogLine,
} from "~/newstore/SSESession" } from "~/newstore/SSESession"
import { useStream } from "~/helpers/utils/composables" import {
useNuxt,
useStream,
useToast,
useI18n,
useStreamSubscriber,
useReadonlyStream,
} from "~/helpers/utils/composables"
import { SSEConnection } from "~/helpers/realtime/SSEConnection"
export default defineComponent({ const t = useI18n()
setup() { const nuxt = useNuxt()
return { const toast = useToast()
connectionSSEState: useStream( const { subscribeToStream } = useStreamSubscriber()
SSEConnectionState$,
false, const sse = useStream(SSESocket$, new SSEConnection(), setSSESocket)
setSSEConnectionState const connectionState = useReadonlyStream(sse.value.connectionState$, "STOPPED")
), const server = useStream(SSEEndpoint$, "", setSSEEndpoint)
connectingState: useStream( const eventType = useStream(SSEEventType$, "", setSSEEventType)
SSEConnectingState$, const log = useStream(SSELog$, [], setSSELog)
false,
setSSEConnectingState const isUrlValid = ref(true)
),
server: useStream(SSEEndpoint$, "", setSSEEndpoint), let worker: Worker
eventType: useStream(SSEEventType$, "", setSSEEventType),
sse: useStream(SSESocket$, null, setSSESocket), const debouncer = debounce(function () {
log: useStream(SSELog$, [], setSSELog), worker.postMessage({ type: "sse", url: server.value })
} }, 1000)
},
data() { watch(server, (url) => {
return { if (url) debouncer()
isUrlValid: true,
}
},
computed: {
serverValid() {
return this.isUrlValid
},
},
watch: {
server() {
this.debouncer()
},
},
created() {
if (process.browser) {
this.worker = this.$worker.createRejexWorker()
this.worker.addEventListener("message", this.workerResponseHandler)
}
},
destroyed() {
this.worker.terminate()
},
methods: {
debouncer: debounce(function () {
this.worker.postMessage({ type: "sse", url: this.server })
}, 1000),
workerResponseHandler({ data }) {
if (data.url === this.url) this.isUrlValid = data.result
},
toggleSSEConnection() {
// If it is connecting:
if (!this.connectionSSEState) return this.start()
// Otherwise, it's disconnecting.
else return this.stop()
},
start() {
this.connectingState = true
this.log = [
{
payload: this.$t("state.connecting_to", { name: this.server }),
source: "info",
color: "var(--accent-color)",
},
]
if (typeof EventSource !== "undefined") {
try {
this.sse = new EventSource(this.server)
this.sse.onopen = () => {
this.connectingState = false
this.connectionSSEState = true
this.log = [
{
payload: this.$t("state.connected_to", { name: this.server }),
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
},
]
this.$toast.success(this.$t("state.connected"))
}
this.sse.onerror = () => {
this.handleSSEError()
}
this.sse.onclose = () => {
this.connectionSSEState = false
addSSELogLine({
payload: this.$t("state.disconnected_from", {
name: this.server,
}),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
}) })
this.$toast.error(this.$t("state.disconnected"))
const workerResponseHandler = ({
data,
}: {
data: { url: string; result: boolean }
}) => {
if (data.url === server.value) isUrlValid.value = data.result
} }
this.sse.addEventListener(this.eventType, ({ data }) => {
onMounted(() => {
worker = nuxt.value.$worker.createRejexWorker()
worker.addEventListener("message", workerResponseHandler)
subscribeToStream(sse.value.event$, (event) => {
switch (event?.type) {
case "STARTING":
log.value = [
{
payload: `${t("state.connecting_to", { name: server.value })}`,
source: "info",
color: "var(--accent-color)",
ts: undefined,
},
]
break
case "STARTED":
log.value = [
{
payload: `${t("state.connected_to", { name: server.value })}`,
source: "info",
color: "var(--accent-color)",
ts: Date.now(),
},
]
toast.success(`${t("state.connected")}`)
break
case "MESSAGE_RECEIVED":
addSSELogLine({ addSSELogLine({
payload: data, payload: event.message,
source: "server", source: "server",
ts: new Date().toLocaleTimeString(), ts: event.time,
}) })
}) break
} catch (e) {
this.handleSSEError(e) case "ERROR":
this.$toast.error(this.$t("error.something_went_wrong")) addSSELogLine({
} payload: t("error.browser_support_sse").toString(),
} else {
this.log = [
{
payload: this.$t("error.browser_support_sse"),
source: "info", source: "info",
color: "#ff5555", color: "#ff5555",
ts: new Date().toLocaleTimeString(), ts: event.time,
}, })
] break
case "STOPPED":
addSSELogLine({
payload: t("state.disconnected_from", {
name: server.value,
}).toString(),
source: "info",
color: "#ff5555",
ts: event.time,
})
toast.error(`${t("state.disconnected")}`)
break
}
})
})
// METHODS
const toggleSSEConnection = () => {
// If it is connecting:
if (connectionState.value === "STOPPED") {
return sse.value.start(server.value, eventType.value)
}
// Otherwise, it's disconnecting.
sse.value.stop()
} }
logHoppRequestRunToAnalytics({ onUnmounted(() => {
platform: "sse", worker.terminate()
})
},
handleSSEError(error) {
this.stop()
this.connectionSSEState = false
addSSELogLine({
payload: this.$t("error.something_went_wrong"),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
if (error !== null)
addSSELogLine({
payload: error,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
stop() {
this.sse.close()
this.sse.onclose()
},
},
}) })
const clearLogEntries = () => {
log.value = []
}
</script> </script>

View File

@@ -12,40 +12,59 @@
type="url" type="url"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
:class="{ error: !urlValid }" :class="{ error: !isUrlValid }"
:placeholder="$t('websocket.url')" :placeholder="`${t('websocket.url')}`"
:disabled="connectionState" :disabled="
@keyup.enter="urlValid ? toggleConnection() : null" connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/> />
<ButtonPrimary <ButtonPrimary
id="connect" id="connect"
:disabled="!urlValid" :disabled="!isUrlValid"
class="w-32" class="w-32"
name="connect" name="connect"
:label=" :label="
!connectionState ? $t('action.connect') : $t('action.disconnect') connectionState === 'DISCONNECTED'
? t('action.connect')
: t('action.disconnect')
" "
:loading="connectingState" :loading="connectionState === 'CONNECTING'"
@click.native="toggleConnection" @click.native="toggleConnection"
/> />
</div> </div>
</div> </div>
<SmartTabs
v-model="selectedTab"
styles="sticky bg-primary top-upperMobilePrimaryStickyFold sm:top-upperPrimaryStickyFold z-10"
>
<SmartTab
:id="'communication'"
:label="`${$t('websocket.communication')}`"
>
<RealtimeCommunication
:is-connected="connectionState === 'CONNECTED'"
@send-message="sendMessage($event)"
></RealtimeCommunication>
</SmartTab>
<SmartTab :id="'protocols'" :label="`${$t('websocket.protocols')}`">
<div <div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold" class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
> >
<label class="font-semibold text-secondaryLight"> <label class="font-semibold text-secondaryLight">
{{ $t("websocket.protocols") }} {{ t("websocket.protocols") }}
</label> </label>
<div class="flex"> <div class="flex">
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="$t('action.clear_all')" :title="t('action.clear_all')"
svg="trash-2" svg="trash-2"
@click.native="clearContent" @click.native="clearContent"
/> />
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="$t('add.new')" :title="t('add.new')"
svg="plus" svg="plus"
@click.native="addProtocol" @click.native="addProtocol"
/> />
@@ -79,7 +98,7 @@
<input <input
v-model="protocol.value" v-model="protocol.value"
class="flex flex-1 px-4 py-2 bg-transparent" class="flex flex-1 px-4 py-2 bg-transparent"
:placeholder="$t('count.protocol', { count: index + 1 })" :placeholder="`${t('count.protocol', { count: index + 1 })}`"
name="message" name="message"
type="text" type="text"
autocomplete="off" autocomplete="off"
@@ -96,9 +115,9 @@
:title=" :title="
protocol.hasOwnProperty('active') protocol.hasOwnProperty('active')
? protocol.active ? protocol.active
? $t('action.turn_off') ? t('action.turn_off')
: $t('action.turn_on') : t('action.turn_on')
: $t('action.turn_off') : t('action.turn_off')
" "
:svg=" :svg="
protocol.hasOwnProperty('active') protocol.hasOwnProperty('active')
@@ -119,10 +138,10 @@
<span> <span>
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="$t('action.remove')" :title="t('action.remove')"
svg="trash" svg="trash"
color="red" color="red"
@click.native="deleteProtocol({ index })" @click.native="deleteProtocol(index)"
/> />
</span> </span>
</div> </div>
@@ -135,56 +154,28 @@
:src="`/images/states/${$colorMode.value}/add_category.svg`" :src="`/images/states/${$colorMode.value}/add_category.svg`"
loading="lazy" loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4" class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="$t('empty.protocols')" :alt="`${t('empty.protocols')}`"
/> />
<span class="mb-4 text-center"> <span class="mb-4 text-center">
{{ $t("empty.protocols") }} {{ t("empty.protocols") }}
</span> </span>
</div> </div>
</SmartTab>
</SmartTabs>
</template> </template>
<template #secondary> <template #secondary>
<RealtimeLog :title="$t('websocket.log')" :log="log" /> <RealtimeLog
</template> :title="$t('websocket.log')"
<template #sidebar> :log="log"
<div class="flex items-center justify-between p-4"> @delete="clearLogEntries()"
<label
for="websocket-message"
class="font-semibold text-secondaryLight"
>
{{ $t("websocket.communication") }}
</label>
</div>
<div class="flex px-4 space-x-2">
<input
id="websocket-message"
v-model="communication.input"
name="message"
type="text"
autocomplete="off"
:disabled="!connectionState"
:placeholder="$t('websocket.message')"
class="input"
@keyup.enter="connectionState ? sendMessage() : null"
@keyup.up="connectionState ? walkHistory('up') : null"
@keyup.down="connectionState ? walkHistory('down') : null"
/> />
<ButtonPrimary
id="send"
name="send"
:disabled="!connectionState"
:label="$t('action.send')"
@click.native="sendMessage"
/>
</div>
</template> </template>
</AppPaneLayout> </AppPaneLayout>
</template> </template>
<script setup lang="ts">
<script> import { ref, watch, onUnmounted, onMounted } from "@nuxtjs/composition-api"
import { defineComponent } from "@nuxtjs/composition-api"
import debounce from "lodash/debounce" import debounce from "lodash/debounce"
import draggable from "vuedraggable" import draggable from "vuedraggable"
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
import { import {
setWSEndpoint, setWSEndpoint,
WSEndpoint$, WSEndpoint$,
@@ -194,62 +185,51 @@ import {
deleteWSProtocol, deleteWSProtocol,
updateWSProtocol, updateWSProtocol,
deleteAllWSProtocols, deleteAllWSProtocols,
WSSocket$,
setWSSocket,
setWSConnectionState,
setWSConnectingState,
WSConnectionState$,
WSConnectingState$,
addWSLogLine, addWSLogLine,
WSLog$, WSLog$,
setWSLog, setWSLog,
HoppWSProtocol,
setWSSocket,
WSSocket$,
} from "~/newstore/WebSocketSession" } from "~/newstore/WebSocketSession"
import { useStream } from "~/helpers/utils/composables" import {
useI18n,
useStream,
useToast,
useNuxt,
useStreamSubscriber,
useReadonlyStream,
} from "~/helpers/utils/composables"
import { WSConnection, WSErrorMessage } from "~/helpers/realtime/WSConnection"
export default defineComponent({ const nuxt = useNuxt()
components: { const t = useI18n()
draggable, const toast = useToast()
}, const { subscribeToStream } = useStreamSubscriber()
setup() {
return { const selectedTab = ref<"communication" | "protocols">("communication")
url: useStream(WSEndpoint$, "", setWSEndpoint), const url = useStream(WSEndpoint$, "", setWSEndpoint)
protocols: useStream(WSProtocols$, [], setWSProtocols), const protocols = useStream(WSProtocols$, [], setWSProtocols)
connectionState: useStream(
WSConnectionState$, const socket = useStream(WSSocket$, new WSConnection(), setWSSocket)
false,
setWSConnectionState const connectionState = useReadonlyStream(
), socket.value.connectionState$,
connectingState: useStream( "DISCONNECTED"
WSConnectingState$, )
false,
setWSConnectingState const log = useStream(WSLog$, [], setWSLog)
), // DATA
socket: useStream(WSSocket$, null, setWSSocket), const isUrlValid = ref(true)
log: useStream(WSLog$, [], setWSLog), const activeProtocols = ref<string[]>([])
} let worker: Worker
}, watch(url, (newUrl) => {
data() { if (newUrl) debouncer()
return { })
isUrlValid: true, watch(
communication: { protocols,
input: "", (newProtocols) => {
}, activeProtocols.value = newProtocols
currentIndex: -1, // index of the message log array to put in input box
activeProtocols: [],
}
},
computed: {
urlValid() {
return this.isUrlValid
},
},
watch: {
url() {
this.debouncer()
},
protocols: {
handler(newVal) {
this.activeProtocols = newVal
.filter((item) => .filter((item) =>
Object.prototype.hasOwnProperty.call(item, "active") Object.prototype.hasOwnProperty.call(item, "active")
? item.active === true ? item.active === true
@@ -257,177 +237,133 @@ export default defineComponent({
) )
.map(({ value }) => value) .map(({ value }) => value)
}, },
deep: true, { deep: true }
}, )
}, const workerResponseHandler = ({
created() { data,
if (process.browser) { }: {
this.worker = this.$worker.createRejexWorker() data: { url: string; result: boolean }
this.worker.addEventListener("message", this.workerResponseHandler) }) => {
} if (data.url === url.value) isUrlValid.value = data.result
},
destroyed() {
this.worker.terminate()
},
methods: {
clearContent() {
deleteAllWSProtocols()
},
debouncer: debounce(function () {
this.worker.postMessage({ type: "ws", url: this.url })
}, 1000),
workerResponseHandler({ data }) {
if (data.url === this.url) this.isUrlValid = data.result
},
toggleConnection() {
// If it is connecting:
if (!this.connectionState) return this.connect()
// Otherwise, it's disconnecting.
else return this.disconnect()
},
connect() {
this.log = [
{
payload: this.$t("state.connecting_to", { name: this.url }),
source: "info",
color: "var(--accent-color)",
},
]
try {
this.connectingState = true
this.socket = new WebSocket(this.url, this.activeProtocols)
this.socket.onopen = () => {
this.connectingState = false
this.connectionState = true
this.log = [
{
payload: this.$t("state.connected_to", { name: this.url }),
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
},
]
this.$toast.success(this.$t("state.connected"))
}
this.socket.onerror = () => {
this.handleError()
}
this.socket.onclose = () => {
this.connectionState = false
addWSLogLine({
payload: this.$t("state.disconnected_from", { name: this.url }),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
this.$toast.error(this.$t("state.disconnected"))
}
this.socket.onmessage = ({ data }) => {
addWSLogLine({
payload: data,
source: "server",
ts: new Date().toLocaleTimeString(),
})
}
} catch (e) {
this.handleError(e)
this.$toast.error(this.$t("error.something_went_wrong"))
} }
logHoppRequestRunToAnalytics({ const getErrorPayload = (error: WSErrorMessage): string => {
platform: "wss", if (error instanceof SyntaxError) {
}) return error.message
},
disconnect() {
if (this.socket) {
this.socket.close()
this.connectionState = false
this.connectingState = false
} }
}, return t("error.something_went_wrong").toString()
handleError(error) { }
this.disconnect()
this.connectionState = false onMounted(() => {
addWSLogLine({ worker = nuxt.value.$worker.createRejexWorker()
payload: this.$t("error.something_went_wrong"), worker.addEventListener("message", workerResponseHandler)
subscribeToStream(socket.value.event$, (event) => {
switch (event?.type) {
case "CONNECTING":
log.value = [
{
payload: `${t("state.connecting_to", { name: url.value })}`,
source: "info", source: "info",
color: "#ff5555", color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(), ts: undefined,
})
if (error !== null)
addWSLogLine({
payload: error,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
}, },
sendMessage() { ]
const message = this.communication.input break
this.socket.send(message)
case "CONNECTED":
log.value = [
{
payload: `${t("state.connected_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: Date.now(),
},
]
toast.success(`${t("state.connected")}`)
break
case "MESSAGE_SENT":
addWSLogLine({ addWSLogLine({
payload: message, payload: event.message,
source: "client", source: "client",
ts: new Date().toLocaleTimeString(), ts: Date.now(),
}) })
this.communication.input = ""
},
walkHistory(direction) {
const clientMessages = this.log.filter(
({ source }) => source === "client"
)
const length = clientMessages.length
switch (direction) {
case "up":
if (length > 0 && this.currentIndex !== 0) {
// does nothing if message log is empty or the currentIndex is 0 when up arrow is pressed
if (this.currentIndex === -1) {
this.currentIndex = length - 1
this.communication.input =
clientMessages[this.currentIndex].payload
} else if (this.currentIndex === 0) {
this.communication.input = clientMessages[0].payload
} else if (this.currentIndex > 0) {
this.currentIndex = this.currentIndex - 1
this.communication.input =
clientMessages[this.currentIndex].payload
}
}
break break
case "down":
if (length > 0 && this.currentIndex > -1) { case "MESSAGE_RECEIVED":
if (this.currentIndex === length - 1) { addWSLogLine({
this.currentIndex = -1 payload: event.message,
this.communication.input = "" source: "server",
} else if (this.currentIndex < length - 1) { ts: event.time,
this.currentIndex = this.currentIndex + 1 })
this.communication.input = break
clientMessages[this.currentIndex].payload
} case "ERROR":
} addWSLogLine({
payload: getErrorPayload(event.error),
source: "info",
color: "#ff5555",
ts: event.time,
})
break
case "DISCONNECTED":
addWSLogLine({
payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "info",
color: "#ff5555",
ts: event.time,
})
toast.error(`${t("state.disconnected")}`)
break break
} }
}, })
addProtocol() { })
onUnmounted(() => {
if (worker) worker.terminate()
})
const clearContent = () => {
deleteAllWSProtocols()
}
const debouncer = debounce(function () {
worker.postMessage({ type: "ws", url: url.value })
}, 1000)
const toggleConnection = () => {
// If it is connecting:
if (connectionState.value === "DISCONNECTED") {
return socket.value.connect(url.value, activeProtocols.value)
}
// Otherwise, it's disconnecting.
socket.value.disconnect()
}
const sendMessage = (event: { message: string; eventName: string }) => {
socket.value.sendMessage(event)
}
const addProtocol = () => {
addWSProtocol({ value: "", active: true }) addWSProtocol({ value: "", active: true })
}, }
deleteProtocol({ index }) { const deleteProtocol = (index: number) => {
const oldProtocols = this.protocols.slice() const oldProtocols = protocols.value.slice()
deleteWSProtocol(index) deleteWSProtocol(index)
this.$toast.success(this.$t("state.deleted"), { toast.success(`${t("state.deleted")}`, {
action: {
text: this.$t("action.undo"),
duration: 4000, duration: 4000,
action: {
text: `${t("action.undo")}`,
onClick: (_, toastObject) => { onClick: (_, toastObject) => {
this.protocols = oldProtocols protocols.value = oldProtocols
toastObject.remove() toastObject.goAway()
}, },
}, },
}) })
}, }
updateProtocol(index, updated) { const updateProtocol = (index: number, updated: HoppWSProtocol) => {
updateWSProtocol(index, updated) updateWSProtocol(index, updated)
}, }
}, const clearLogEntries = () => {
}) log.value = []
}
</script> </script>

View File

@@ -16,7 +16,12 @@
</template> </template>
<template #footer> <template #footer>
<span> <span>
<ButtonPrimary v-focus :label="yes" @click.native="resolve" /> <ButtonPrimary
v-focus
:label="yes"
:loading="!!loadingState"
@click.native="resolve"
/>
<ButtonSecondary :label="no" @click.native="hideModal" /> <ButtonSecondary :label="no" @click.native="hideModal" />
</span> </span>
</template> </template>
@@ -42,14 +47,15 @@ export default defineComponent({
return this.$t("action.no") return this.$t("action.no")
}, },
}, },
loadingState: { type: Boolean, default: null },
}, },
methods: { methods: {
hideModal() { hideModal() {
this.$emit("hide-modal") this.$emit("hide-modal")
}, },
resolve() { resolve() {
this.$emit("resolve") this.$emit("resolve", this.title)
this.$emit("hide-modal") if (this.loadingState === null) this.$emit("hide-modal")
}, },
}, },
}) })

View File

@@ -29,10 +29,12 @@ import {
placeholder as placeholderExt, placeholder as placeholderExt,
ViewPlugin, ViewPlugin,
ViewUpdate, ViewUpdate,
keymap,
} from "@codemirror/view" } from "@codemirror/view"
import { EditorState, Extension } from "@codemirror/state" import { EditorState, Extension } from "@codemirror/state"
import clone from "lodash/clone" import clone from "lodash/clone"
import { tooltips } from "@codemirror/tooltip" import { tooltips } from "@codemirror/tooltip"
import { history, historyKeymap } from "@codemirror/history"
import { inputTheme } from "~/helpers/editor/themes/baseTheme" import { inputTheme } from "~/helpers/editor/themes/baseTheme"
import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment" import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment"
import { useReadonlyStream } from "~/helpers/utils/composables" import { useReadonlyStream } from "~/helpers/utils/composables"
@@ -45,6 +47,7 @@ const props = withDefaults(
styles: string styles: string
envs: { key: string; value: string; source: string }[] | null envs: { key: string; value: string; source: string }[] | null
focus: boolean focus: boolean
readonly: boolean
}>(), }>(),
{ {
value: "", value: "",
@@ -52,6 +55,7 @@ const props = withDefaults(
styles: "", styles: "",
envs: null, envs: null,
focus: false, focus: false,
readonly: false,
} }
) )
@@ -120,7 +124,24 @@ const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
const initView = (el: any) => { const initView = (el: any) => {
const extensions: Extension = [ const extensions: Extension = [
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
EditorView.updateListener.of((update) => {
if (props.readonly) {
update.view.contentDOM.inputMode = "none"
}
}),
EditorState.changeFilter.of(() => !props.readonly),
inputTheme, inputTheme,
props.readonly
? EditorView.theme({
".cm-content": {
caretColor: "var(--secondary-dark-color) !important",
color: "var(--secondary-dark-color) !important",
backgroundColor: "var(--divider-color) !important",
opacity: 0.25,
},
})
: EditorView.theme({}),
tooltips({ tooltips({
position: "absolute", position: "absolute",
}), }),
@@ -138,6 +159,8 @@ const initView = (el: any) => {
ViewPlugin.fromClass( ViewPlugin.fromClass(
class { class {
update(update: ViewUpdate) { update(update: ViewUpdate) {
if (props.readonly) return
if (update.docChanged) { if (update.docChanged) {
const prevValue = clone(cachedValue.value) const prevValue = clone(cachedValue.value)
@@ -172,6 +195,8 @@ const initView = (el: any) => {
} }
} }
), ),
history(),
keymap.of([...historyKeymap]),
] ]
view.value = new EditorView({ view.value = new EditorView({

View File

@@ -1,21 +1,20 @@
<template> <template>
<SmartItem <SmartItem
:label="label" :label="label"
:icon=" :icon="selected ? 'radio_button_checked' : 'radio_button_unchecked'"
value === selected ? 'radio_button_checked' : 'radio_button_unchecked' :active="selected"
"
:active="value === selected"
role="radio" role="radio"
:aria-checked="value === selected" :aria-checked="selected"
@click.native="$emit('change', value)" @click.native="emit('change', value)"
/> />
</template> </template>
<script> <script setup lang="ts">
import { defineComponent } from "@nuxtjs/composition-api" const emit = defineEmits<{
(e: "change", value: string): void
}>()
export default defineComponent({ defineProps({
props: {
value: { value: {
type: String, type: String,
default: "", default: "",
@@ -25,9 +24,8 @@ export default defineComponent({
default: "", default: "",
}, },
selected: { selected: {
type: String, type: Boolean,
default: "", default: false,
},
}, },
}) })
</script> </script>

View File

@@ -5,18 +5,22 @@
:key="`radio-${index}`" :key="`radio-${index}`"
:value="radio.value" :value="radio.value"
:label="radio.label" :label="radio.label"
:selected="selected" :selected="value === radio.value"
@change="$emit('change', radio.value)" @change="emit('input', radio.value)"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const emit = defineEmits<{
(e: "input", value: string): void
}>()
defineProps<{ defineProps<{
radios: Array<{ radios: Array<{
value: string value: string // The key of the radio option
label: string label: string
}> }>
selected: string value: string // Should be a radio key given in the radios array
}>() }>()
</script> </script>

View File

@@ -149,7 +149,7 @@ export class GQLConnection {
.forEach((x) => (finalHeaders[x.key] = x.value)) .forEach((x) => (finalHeaders[x.key] = x.value))
const reqOptions = { const reqOptions = {
method: "post", method: "POST",
url, url,
headers: { headers: {
...finalHeaders, ...finalHeaders,
@@ -213,7 +213,7 @@ export class GQLConnection {
.forEach(({ key, value }) => (finalHeaders[key] = value)) .forEach(({ key, value }) => (finalHeaders[key] = value))
const reqOptions = { const reqOptions = {
method: "post", method: "POST",
url, url,
headers: { headers: {
...finalHeaders, ...finalHeaders,

View File

@@ -0,0 +1,3 @@
mutation DeleteShortcode($code: ID!) {
revokeShortcode(code: $code)
}

View File

@@ -0,0 +1,7 @@
query GetUserShortcodes($cursor: ID) {
myShortcodes(cursor: $cursor) {
id
request
createdOn
}
}

View File

@@ -0,0 +1,7 @@
subscription ShortcodeCreated {
myShortcodesCreated {
id
request
createdOn
}
}

View File

@@ -0,0 +1,5 @@
subscription ShortcodeDeleted {
myShortcodesRevoked {
id
}
}

View File

@@ -17,7 +17,7 @@ import {
GetCollectionTitleDocument, GetCollectionTitleDocument,
} from "./graphql" } from "./graphql"
const BACKEND_PAGE_SIZE = 10 export const BACKEND_PAGE_SIZE = 10
const getCollectionChildrenIDs = async (collID: string) => { const getCollectionChildrenIDs = async (collID: string) => {
const collsList: string[] = [] const collsList: string[] = []

View File

@@ -4,8 +4,13 @@ import {
CreateShortcodeDocument, CreateShortcodeDocument,
CreateShortcodeMutation, CreateShortcodeMutation,
CreateShortcodeMutationVariables, CreateShortcodeMutationVariables,
DeleteShortcodeDocument,
DeleteShortcodeMutation,
DeleteShortcodeMutationVariables,
} from "../graphql" } from "../graphql"
type DeleteShortcodeErrors = "shortcode/not_found"
export const createShortcode = (request: HoppRESTRequest) => export const createShortcode = (request: HoppRESTRequest) =>
runMutation<CreateShortcodeMutation, CreateShortcodeMutationVariables, "">( runMutation<CreateShortcodeMutation, CreateShortcodeMutationVariables, "">(
CreateShortcodeDocument, CreateShortcodeDocument,
@@ -13,3 +18,12 @@ export const createShortcode = (request: HoppRESTRequest) =>
request: JSON.stringify(request), request: JSON.stringify(request),
} }
) )
export const deleteShortcode = (code: string) =>
runMutation<
DeleteShortcodeMutation,
DeleteShortcodeMutationVariables,
DeleteShortcodeErrors
>(DeleteShortcodeDocument, {
code,
})

View File

@@ -768,11 +768,52 @@ const samples = [
testScript: "", testScript: "",
}), }),
}, },
{
command: `curl \`
google.com -H "content-type: application/json"`,
response: makeRESTRequest({
method: "GET",
name: "Untitled request",
endpoint: "https://google.com/",
auth: {
authType: "none",
authActive: true,
},
body: {
contentType: null,
body: null,
},
params: [],
headers: [],
preRequestScript: "",
testScript: "",
}),
},
{
command: `curl 192.168.0.24:8080/ping`,
response: makeRESTRequest({
method: "GET",
name: "Untitled request",
endpoint: "http://192.168.0.24:8080/ping",
auth: {
authType: "none",
authActive: true,
},
body: {
contentType: null,
body: null,
},
params: [],
headers: [],
preRequestScript: "",
testScript: "",
}),
},
] ]
describe("parseCurlToHoppRESTReq", () => { describe("Parse curl command to Hopp REST Request", () => {
for (const [i, { command, response }] of samples.entries()) { for (const [i, { command, response }] of samples.entries()) {
test(`matches expectation for sample #${i + 1}`, () => { test(`for sample #${i + 1}:\n\n${command}`, () => {
expect(parseCurlToHoppRESTReq(command)).toEqual(response) expect(parseCurlToHoppRESTReq(command)).toEqual(response)
}) })
} }

View File

@@ -12,7 +12,7 @@ import { getHeaders, recordToHoppHeaders } from "./sub_helpers/headers"
// import { getCookies } from "./sub_helpers/cookies" // import { getCookies } from "./sub_helpers/cookies"
import { getQueries } from "./sub_helpers/queries" import { getQueries } from "./sub_helpers/queries"
import { getMethod } from "./sub_helpers/method" import { getMethod } from "./sub_helpers/method"
import { concatParams, parseURL } from "./sub_helpers/url" import { concatParams, getURLObject } from "./sub_helpers/url"
import { preProcessCurlCommand } from "./sub_helpers/preproc" import { preProcessCurlCommand } from "./sub_helpers/preproc"
import { getBody, getFArgumentMultipartData } from "./sub_helpers/body" import { getBody, getFArgumentMultipartData } from "./sub_helpers/body"
import { getDefaultRESTRequest } from "~/newstore/RESTSession" import { getDefaultRESTRequest } from "~/newstore/RESTSession"
@@ -42,7 +42,7 @@ export const parseCurlCommand = (curlCommand: string) => {
const method = getMethod(parsedArguments) const method = getMethod(parsedArguments)
// const cookies = getCookies(parsedArguments) // const cookies = getCookies(parsedArguments)
const urlObject = parseURL(parsedArguments) const urlObject = getURLObject(parsedArguments)
const auth = getAuthObject(parsedArguments, headers, urlObject) const auth = getAuthObject(parsedArguments, headers, urlObject)
let rawData: string | string[] = pipe( let rawData: string | string[] = pipe(

View File

@@ -158,7 +158,9 @@ const getXMLBody = (rawData: string) =>
O.alt(() => O.some(rawData)) O.alt(() => O.some(rawData))
) )
const getFormattedJSON = flow( const getFormattedJSON = (jsonString: string) =>
pipe(
jsonString.replaceAll('\\"', '"'),
safeParseJSON, safeParseJSON,
O.map((parsedJSON) => JSON.stringify(parsedJSON, null, 2)), O.map((parsedJSON) => JSON.stringify(parsedJSON, null, 2)),
O.getOrElse(() => "{ }"), O.getOrElse(() => "{ }"),

View File

@@ -50,7 +50,7 @@ const getMethodByDeduction = (parsedArguments: parser.Arguments) => {
R.or(objHasArrayProperty("F", "string")) R.or(objHasArrayProperty("F", "string"))
)(parsedArguments) )(parsedArguments)
) )
return O.some("post") return O.some("POST")
else return O.none else return O.none
} }

View File

@@ -22,7 +22,8 @@ const paperCuts = flow(
S.replace(/\n/g, " "), S.replace(/\n/g, " "),
// remove all $ symbols from start of argument values // remove all $ symbols from start of argument values
S.replace(/\$'/g, "'"), S.replace(/\$'/g, "'"),
S.replace(/\$"/g, '"') S.replace(/\$"/g, '"'),
S.trim
) )
// replace --zargs option with -Z // replace --zargs option with -Z

View File

@@ -1,48 +1,80 @@
import parser from "yargs-parser" import parser from "yargs-parser"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option" import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { getDefaultRESTRequest } from "~/newstore/RESTSession" import { getDefaultRESTRequest } from "~/newstore/RESTSession"
import { stringArrayJoin } from "~/helpers/functional/array" import { stringArrayJoin } from "~/helpers/functional/array"
const defaultRESTReq = getDefaultRESTRequest() const defaultRESTReq = getDefaultRESTRequest()
const getProtocolForBaseURL = (baseURL: string) => const getProtocolFromURL = (url: string) =>
pipe( pipe(
// get the base URL // get the base URL
/^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(baseURL), /^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(url),
O.fromNullable, O.fromNullable,
O.filter((burl) => burl.length > 1), O.filter((burl) => burl.length > 1),
O.map((burl) => burl[2]), O.map((burl) => burl[2]),
// set protocol to http for local URLs // set protocol to http for local URLs
O.map((burl) => O.map((burl) =>
burl === "localhost" || burl === "127.0.0.1" burl === "localhost" ||
? "http://" + baseURL burl === "2130706433" ||
: "https://" + baseURL /127(\.0){0,2}\.1/.test(burl) ||
/0177(\.0){0,2}\.1/.test(burl) ||
/0x7f(\.0){0,2}\.1/.test(burl) ||
/192\.168(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){2}/.test(burl) ||
/10(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/.test(burl)
? "http://" + url
: "https://" + url
) )
) )
/**
* Checks if the URL is valid using the URL constructor
* @param urlString URL string (with protocol)
* @returns boolean whether the URL is valid using the inbuilt URL class
*/
const isURLValid = (urlString: string) =>
pipe(
O.tryCatch(() => new URL(urlString)),
O.isSome
)
/**
* Checks and returns URL object for the valid URL
* @param urlText Raw URL string provided by argument parser
* @returns Option of URL object
*/
const parseURL = (urlText: string | number) =>
pipe(
urlText,
O.fromNullable,
// preprocess url string
O.map((u) => u.toString().replaceAll(/[^a-zA-Z0-9_\-./?&=:@%+#,;\s]/g, "")),
O.filter((u) => u.length > 0),
O.chain((u) =>
pipe(
u,
// check if protocol is available
O.fromPredicate(
(url: string) => /^[^:\s]+(?=:\/\/)/.exec(url) !== null
),
O.alt(() => getProtocolFromURL(u))
)
),
O.filter(isURLValid),
O.map((u) => new URL(u))
)
/** /**
* Processes URL string and returns the URL object * Processes URL string and returns the URL object
* @param parsedArguments Parsed Arguments object * @param parsedArguments Parsed Arguments object
* @returns URL object * @returns URL object
*/ */
export function parseURL(parsedArguments: parser.Arguments) { export function getURLObject(parsedArguments: parser.Arguments) {
return pipe( return pipe(
// contains raw url string // contains raw url strings
parsedArguments._[1], parsedArguments._.slice(1),
O.fromNullable, A.findFirstMap(parseURL),
// preprocess url string
O.map((u) => u.toString().replace(/["']/g, "").trim()),
O.chain((u) =>
pipe(
// check if protocol is available
/^[^:\s]+(?=:\/\/)/.exec(u),
O.fromNullable,
O.map((_) => u),
O.alt(() => getProtocolForBaseURL(u))
)
),
O.map((u) => new URL(u)),
// no url found // no url found
O.getOrElse(() => new URL(defaultRESTReq.endpoint)) O.getOrElse(() => new URL(defaultRESTReq.endpoint))
) )

View File

@@ -28,8 +28,6 @@ import { javascriptLanguage } from "@codemirror/lang-javascript"
import { xmlLanguage } from "@codemirror/lang-xml" import { xmlLanguage } from "@codemirror/lang-xml"
import { jsonLanguage } from "@codemirror/lang-json" import { jsonLanguage } from "@codemirror/lang-json"
import { GQLLanguage } from "@hoppscotch/codemirror-lang-graphql" import { GQLLanguage } from "@hoppscotch/codemirror-lang-graphql"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import { StreamLanguage } from "@codemirror/stream-parser" import { StreamLanguage } from "@codemirror/stream-parser"
import { html } from "@codemirror/legacy-modes/mode/xml" import { html } from "@codemirror/legacy-modes/mode/xml"
import { shell } from "@codemirror/legacy-modes/mode/shell" import { shell } from "@codemirror/legacy-modes/mode/shell"
@@ -96,8 +94,10 @@ const hoppCompleterExt = (completer: Completer): Extension => {
}) })
} }
const hoppLinterExt = (hoppLinter: LinterDefinition): Extension => { const hoppLinterExt = (hoppLinter: LinterDefinition | undefined): Extension => {
return linter(async (view) => { return linter(async (view) => {
if (!hoppLinter) return []
// Requires full document scan, hence expensive on big files, force disable on big files ? // Requires full document scan, hence expensive on big files, force disable on big files ?
const linterResult = await hoppLinter( const linterResult = await hoppLinter(
view.state.doc.toJSON().join(view.state.lineBreak) view.state.doc.toJSON().join(view.state.lineBreak)
@@ -119,16 +119,16 @@ const hoppLinterExt = (hoppLinter: LinterDefinition): Extension => {
} }
const hoppLang = ( const hoppLang = (
language: Language, language: Language | undefined,
linter?: LinterDefinition | undefined, linter?: LinterDefinition | undefined,
completer?: Completer | undefined completer?: Completer | undefined
) => { ): Extension | LanguageSupport => {
const exts: Extension[] = [] const exts: Extension[] = []
if (linter) exts.push(hoppLinterExt(linter)) exts.push(hoppLinterExt(linter))
if (completer) exts.push(hoppCompleterExt(completer)) if (completer) exts.push(hoppCompleterExt(completer))
return new LanguageSupport(language, exts) return language ? new LanguageSupport(language, exts) : exts
} }
const getLanguage = (langMime: string): Language | null => { const getLanguage = (langMime: string): Language | null => {
@@ -156,12 +156,7 @@ const getEditorLanguage = (
langMime: string, langMime: string,
linter: LinterDefinition | undefined, linter: LinterDefinition | undefined,
completer: Completer | undefined completer: Completer | undefined
): Extension => ): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
pipe(
O.fromNullable(getLanguage(langMime)),
O.map((lang) => hoppLang(lang, linter, completer)),
O.getOrElseW(() => [])
)
export function useCodemirror( export function useCodemirror(
el: Ref<any | null>, el: Ref<any | null>,

View File

@@ -1,4 +1,5 @@
import * as O from "fp-ts/Option" import * as O from "fp-ts/Option"
import { flow } from "fp-ts/function"
/** /**
* Checks and Parses JSON string * Checks and Parses JSON string
@@ -7,3 +8,10 @@ import * as O from "fp-ts/Option"
*/ */
export const safeParseJSON = (str: string): O.Option<object> => export const safeParseJSON = (str: string): O.Option<object> =>
O.tryCatch(() => JSON.parse(str)) O.tryCatch(() => JSON.parse(str))
/**
* Checks if given string is a JSON string
* @param str Raw string to be checked
* @returns If string is a JSON string
*/
export const isJSON = flow(safeParseJSON, O.isSome)

View File

@@ -1,6 +1,7 @@
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import cloneDeep from "lodash/cloneDeep" import cloneDeep from "lodash/cloneDeep"
import isEqual from "lodash/isEqual" import isEqual from "lodash/isEqual"
import { JSPrimitive, TypeFromPrimitive } from "./primtive"
export const objRemoveKey = export const objRemoveKey =
<T, K extends keyof T>(key: K) => <T, K extends keyof T>(key: K) =>
@@ -19,34 +20,15 @@ export const objFieldMatches =
(obj: T): obj is T & { [_ in K]: V } => (obj: T): obj is T & { [_ in K]: V } =>
matches.findIndex((x) => isEqual(obj[fieldName], x)) !== -1 matches.findIndex((x) => isEqual(obj[fieldName], x)) !== -1
type JSPrimitive = export const objHasProperty =
| "undefined" <O extends object, K extends string, P extends JSPrimitive | undefined>(
| "object" prop: K,
| "boolean" type?: P
| "number" ) =>
| "bigint" // eslint-disable-next-line
| "string" (obj: O): obj is O & { [_ in K]: TypeFromPrimitive<P> } =>
| "symbol" // eslint-disable-next-line
| "function" prop in obj && (type === undefined || typeof (obj as any)[prop] === type)
type TypeFromPrimitive<P extends JSPrimitive | undefined> =
P extends "undefined"
? undefined
: P extends "object"
? object | null // typeof null === "object"
: P extends "boolean"
? boolean
: P extends "number"
? number
: P extends "bigint"
? BigInt
: P extends "string"
? string
: P extends "symbol"
? Symbol
: P extends "function"
? Function
: unknown
type TypeFromPrimitiveArray<P extends JSPrimitive | undefined> = type TypeFromPrimitiveArray<P extends JSPrimitive | undefined> =
P extends "undefined" P extends "undefined"
@@ -67,16 +49,6 @@ type TypeFromPrimitiveArray<P extends JSPrimitive | undefined> =
? Function[] ? Function[]
: unknown[] : unknown[]
export const objHasProperty =
<O extends object, K extends string, P extends JSPrimitive | undefined>(
prop: K,
type?: P
) =>
// eslint-disable-next-line
(obj: O): obj is O & { [_ in K]: TypeFromPrimitive<P> } =>
// eslint-disable-next-line
prop in obj && (type === undefined || typeof (obj as any)[prop] === type)
export const objHasArrayProperty = export const objHasArrayProperty =
<O extends object, K extends string, P extends JSPrimitive>( <O extends object, K extends string, P extends JSPrimitive>(
prop: K, prop: K,

View File

@@ -0,0 +1,34 @@
export type JSPrimitive =
| "undefined"
| "object"
| "boolean"
| "number"
| "bigint"
| "string"
| "symbol"
| "function"
export type TypeFromPrimitive<P extends JSPrimitive | undefined> =
P extends "undefined"
? undefined
: P extends "object"
? object | null // typeof null === "object"
: P extends "boolean"
? boolean
: P extends "number"
? number
: P extends "bigint"
? BigInt
: P extends "string"
? string
: P extends "symbol"
? Symbol
: P extends "function"
? Function
: unknown
export const isOfType =
<T extends JSPrimitive>(type: T) =>
(value: unknown): value is T =>
// eslint-disable-next-line valid-typeof
typeof value === type

View File

@@ -0,0 +1,33 @@
/**
* Escapes special regex characters in a string.
* @param text The string to transform
* @returns Escaped string
*/
export const regexEscape = (text: string) =>
text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&")
export type RegexMatch = {
matchString: string
startIndex: number
endIndex: number
}
/**
* Returns all the regex match ranges for a given input
* @param regex The Regular Expression to find from
* @param input The input string to get match ranges from
* @returns An array of `RegexMatch` objects giving info about the matches
*/
export const regexFindAllMatches = (regex: RegExp) => (input: string) => {
const matches: RegexMatch[] = []
// eslint-disable-next-line no-cond-assign, prettier/prettier
for (let match; match = regex.exec(input); match !== null)
matches.push({
matchString: match[0],
startIndex: match.index,
endIndex: match.index + match[0].length - 1,
})
return matches
}

View File

@@ -0,0 +1,13 @@
import * as TE from "fp-ts/TaskEither"
/**
* A utility type which gives you the type of the left value of a TaskEither
*/
export type TELeftType<T extends TE.TaskEither<any, any>> =
T extends TE.TaskEither<
infer U,
// eslint-disable-next-line
infer _
>
? U
: never

View File

@@ -10,7 +10,7 @@ import { defineImporter, IMPORTER_INVALID_FILE_FORMAT } from "."
// TODO: Add validation to output // TODO: Add validation to output
const fetchGist = ( const fetchGist = (
url: string url: string
): TO.TaskOption<HoppCollection<HoppRESTRequest>> => ): TO.TaskOption<HoppCollection<HoppRESTRequest>[]> =>
pipe( pipe(
TO.tryCatch(() => TO.tryCatch(() =>
axios.get(`https://api.github.com/gists/${url.split("/").pop()}`, { axios.get(`https://api.github.com/gists/${url.split("/").pop()}`, {
@@ -30,8 +30,10 @@ const fetchGist = (
) )
export default defineImporter({ export default defineImporter({
id: "gist",
name: "import.from_gist", name: "import.from_gist",
icon: "github", icon: "github",
applicableTo: ["my-collections", "team-collections"],
steps: [ steps: [
step({ step({
stepName: "URL_IMPORT", stepName: "URL_IMPORT",

View File

@@ -1,13 +1,22 @@
import { pipe } from "fp-ts/function" import { pipe, flow } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import * as E from "fp-ts/Either" import * as O from "fp-ts/Option"
import { translateToNewRESTCollection } from "@hoppscotch/data" import * as RA from "fp-ts/ReadonlyArray"
import {
translateToNewRESTCollection,
HoppCollection,
HoppRESTRequest,
} from "@hoppscotch/data"
import _isPlainObject from "lodash/isPlainObject"
import { step } from "../steps" import { step } from "../steps"
import { defineImporter, IMPORTER_INVALID_FILE_FORMAT } from "." import { defineImporter, IMPORTER_INVALID_FILE_FORMAT } from "."
import { safeParseJSON } from "~/helpers/functional/json"
export default defineImporter({ export default defineImporter({
id: "hoppscotch",
name: "import.from_json", name: "import.from_json",
icon: "folder-plus", icon: "folder-plus",
applicableTo: ["my-collections", "team-collections", "url-import"],
steps: [ steps: [
step({ step({
stepName: "FILE_IMPORT", stepName: "FILE_IMPORT",
@@ -19,16 +28,40 @@ export default defineImporter({
] as const, ] as const,
importer: ([content]) => importer: ([content]) =>
pipe( pipe(
E.tryCatch( safeParseJSON(content),
() => { O.chain(
const x = JSON.parse(content) flow(
makeCollectionsArray,
return Array.isArray(x) RA.map(
? x.map((coll: any) => translateToNewRESTCollection(coll)) flow(
: [translateToNewRESTCollection(x)] O.fromPredicate(isValidCollection),
}, O.map(translateToNewRESTCollection)
() => IMPORTER_INVALID_FILE_FORMAT )
), ),
TE.fromEither O.sequenceArray,
O.map(RA.toArray)
)
),
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT)
), ),
}) })
/**
* checks if a value is a plain object
*/
const isPlainObject = (value: any): value is object => _isPlainObject(value)
/**
* checks if a collection matches the schema for a hoppscotch collection.
* as of now we are only checking if the collection has a "v" key in it.
*/
const isValidCollection = (
collection: unknown
): collection is HoppCollection<HoppRESTRequest> =>
isPlainObject(collection) && "v" in collection
/**
* convert single collection object into an array so it can be handled the same as multiple collections
*/
const makeCollectionsArray = (collections: unknown | unknown[]): unknown[] =>
Array.isArray(collections) ? collections : [collections]

View File

@@ -13,3 +13,10 @@ export const RESTCollectionImporters = [
GistImporter, GistImporter,
MyCollectionsImporter, MyCollectionsImporter,
] as const ] as const
export const URLImporters = [
HoppRESTCollImporter,
OpenAPIImporter,
PostmanImporter,
InsomniaImporter,
] as const

View File

@@ -13,10 +13,18 @@ type HoppImporter<T, StepsType, Errors> = (
stepValues: StepsOutputList<StepsType> stepValues: StepsOutputList<StepsType>
) => TE.TaskEither<Errors, T> ) => TE.TaskEither<Errors, T>
type HoppImporterApplicableTo = Array<
"team-collections" | "my-collections" | "url-import"
>
/** /**
* Definition for importers * Definition for importers
*/ */
type HoppImporterDefinition<T, Y, E> = { type HoppImporterDefinition<T, Y, E> = {
/**
* the id
*/
id: string
/** /**
* Name of the importer, shown on the Select Importer dropdown * Name of the importer, shown on the Select Importer dropdown
*/ */
@@ -30,7 +38,7 @@ type HoppImporterDefinition<T, Y, E> = {
/** /**
* Identifier for the importer * Identifier for the importer
*/ */
applicableTo?: Array<"team-collections" | "my-collections"> applicableTo: HoppImporterApplicableTo
/** /**
* The importer function, It is a Promise because its supposed to be loaded in lazily (dynamic imports ?) * The importer function, It is a Promise because its supposed to be loaded in lazily (dynamic imports ?)
@@ -47,10 +55,11 @@ type HoppImporterDefinition<T, Y, E> = {
* Defines a Hoppscotch importer * Defines a Hoppscotch importer
*/ */
export const defineImporter = <ReturnType, StepType, Errors>(input: { export const defineImporter = <ReturnType, StepType, Errors>(input: {
id: string
name: string name: string
icon: string icon: string
importer: HoppImporter<ReturnType, StepType, Errors> importer: HoppImporter<ReturnType, StepType, Errors>
applicableTo?: Array<"team-collections" | "my-collections"> applicableTo: HoppImporterApplicableTo
steps: StepType steps: StepType
}) => { }) => {
return <HoppImporterDefinition<ReturnType, StepType, Errors>>{ return <HoppImporterDefinition<ReturnType, StepType, Errors>>{

View File

@@ -210,7 +210,9 @@ const getHoppCollections = (doc: InsomniaDoc) =>
) )
export default defineImporter({ export default defineImporter({
id: "insomnia",
name: "import.from_insomnia", name: "import.from_insomnia",
applicableTo: ["my-collections", "team-collections", "url-import"],
icon: "insomnia", icon: "insomnia",
steps: [ steps: [
step({ step({

View File

@@ -6,6 +6,7 @@ import { defineImporter } from "."
import { getRESTCollection } from "~/newstore/collections" import { getRESTCollection } from "~/newstore/collections"
export default defineImporter({ export default defineImporter({
id: "myCollections",
name: "import.from_my_collections", name: "import.from_my_collections",
icon: "user", icon: "user",
applicableTo: ["team-collections"], applicableTo: ["team-collections"],

View File

@@ -27,7 +27,7 @@ import * as RA from "fp-ts/ReadonlyArray"
import { step } from "../steps" import { step } from "../steps"
import { defineImporter, IMPORTER_INVALID_FILE_FORMAT } from "." import { defineImporter, IMPORTER_INVALID_FILE_FORMAT } from "."
const OPENAPI_DEREF_ERROR = "openapi/deref_error" as const export const OPENAPI_DEREF_ERROR = "openapi/deref_error" as const
// TODO: URL Import Support // TODO: URL Import Support
@@ -586,7 +586,9 @@ const parseOpenAPIDocContent = (str: string) =>
) )
export default defineImporter({ export default defineImporter({
id: "openapi",
name: "import.from_openapi", name: "import.from_openapi",
applicableTo: ["my-collections", "team-collections", "url-import"],
icon: "file", icon: "file",
steps: [ steps: [
step({ step({
@@ -603,7 +605,6 @@ export default defineImporter({
fileContent, fileContent,
parseOpenAPIDocContent, parseOpenAPIDocContent,
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT), TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT),
// Try validating, else the importer is invalid file format // Try validating, else the importer is invalid file format
TE.chainW((obj) => TE.chainW((obj) =>
pipe( pipe(
@@ -613,7 +614,6 @@ export default defineImporter({
) )
) )
), ),
// Deference the references // Deference the references
TE.chainW((obj) => TE.chainW((obj) =>
pipe( pipe(
@@ -623,7 +623,6 @@ export default defineImporter({
) )
) )
), ),
TE.chainW(convertOpenApiDocToHopp) TE.chainW(convertOpenApiDocToHopp)
), ),
}) })

View File

@@ -297,7 +297,9 @@ const getHoppFolder = (ig: ItemGroup<Item>): HoppCollection<HoppRESTRequest> =>
export const getHoppCollection = (coll: PMCollection) => getHoppFolder(coll) export const getHoppCollection = (coll: PMCollection) => getHoppFolder(coll)
export default defineImporter({ export default defineImporter({
id: "postman",
name: "import.from_postman", name: "import.from_postman",
applicableTo: ["my-collections", "team-collections", "url-import"],
icon: "postman", icon: "postman",
steps: [ steps: [
step({ step({

View File

@@ -132,13 +132,13 @@ export function createRESTNetworkRequestStream(
), ),
// Assembling params object // Assembling params object
TE.bind("params", ({ req }) => TE.bind("params", ({ req }) => {
TE.of( const params = new URLSearchParams()
req.effectiveFinalParams.reduce((acc, { key, value }) => { req.effectiveFinalParams.forEach((x) => {
return Object.assign(acc, { [key]: value }) params.append(x.key, x.value)
}, {}) })
) return TE.of(params)
), }),
// Keeping the backup start time // Keeping the backup start time
TE.bind("backupTimeStart", () => TE.of(Date.now())), TE.bind("backupTimeStart", () => TE.of(Date.now())),

View File

@@ -21,7 +21,7 @@ const sendPostRequest = async (url, params) => {
.map((key) => `${key}=${params[key]}`) .map((key) => `${key}=${params[key]}`)
.join("&") .join("&")
const options = { const options = {
method: "post", method: "POST",
headers: { headers: {
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8", "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
}, },

View File

@@ -0,0 +1,223 @@
import Paho, { ConnectionOptions } from "paho-mqtt"
import { BehaviorSubject, Subject } from "rxjs"
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
export type MQTTMessage = { topic: string; message: string }
export type MQTTError =
| { type: "CONNECTION_NOT_ESTABLISHED"; value: unknown }
| { type: "CONNECTION_LOST" }
| { type: "CONNECTION_FAILED" }
| { type: "SUBSCRIPTION_FAILED"; topic: string }
| { type: "PUBLISH_ERROR"; topic: string; message: string }
export type MQTTEvent = { time: number } & (
| { type: "CONNECTING" }
| { type: "CONNECTED" }
| { type: "MESSAGE_SENT"; message: MQTTMessage }
| { type: "SUBSCRIBED"; topic: string }
| { type: "SUBSCRIPTION_FAILED"; topic: string }
| { type: "MESSAGE_RECEIVED"; message: MQTTMessage }
| { type: "DISCONNECTED"; manual: boolean }
| { type: "ERROR"; error: MQTTError }
)
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
export class MQTTConnection {
subscriptionState$ = new BehaviorSubject<boolean>(false)
connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
event$: Subject<MQTTEvent> = new Subject()
private mqttClient: Paho.Client | undefined
private manualDisconnect = false
private addEvent(event: MQTTEvent) {
this.event$.next(event)
}
connect(url: string, username: string, password: string) {
try {
this.connectionState$.next("CONNECTING")
this.addEvent({
time: Date.now(),
type: "CONNECTING",
})
const parseUrl = new URL(url)
const { hostname, pathname, port } = parseUrl
this.mqttClient = new Paho.Client(
`${hostname + (pathname !== "/" ? pathname : "")}`,
port !== "" ? Number(port) : 8081,
"hoppscotch"
)
const connectOptions: ConnectionOptions = {
onSuccess: this.onConnectionSuccess.bind(this),
onFailure: this.onConnectionFailure.bind(this),
useSSL: parseUrl.protocol !== "ws:",
}
if (username !== "") {
connectOptions.userName = username
}
if (password !== "") {
connectOptions.password = password
}
this.mqttClient.connect(connectOptions)
this.mqttClient.onConnectionLost = this.onConnectionLost.bind(this)
this.mqttClient.onMessageArrived = this.onMessageArrived.bind(this)
} catch (e) {
this.handleError(e)
}
logHoppRequestRunToAnalytics({
platform: "mqtt",
})
}
onConnectionFailure() {
this.connectionState$.next("DISCONNECTED")
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "CONNECTION_FAILED",
},
})
}
onConnectionSuccess() {
this.connectionState$.next("CONNECTED")
this.addEvent({
type: "CONNECTED",
time: Date.now(),
})
}
onConnectionLost() {
this.connectionState$.next("DISCONNECTED")
if (this.manualDisconnect) {
this.addEvent({
time: Date.now(),
type: "DISCONNECTED",
manual: this.manualDisconnect,
})
} else {
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "CONNECTION_LOST",
},
})
}
this.manualDisconnect = false
this.subscriptionState$.next(false)
}
onMessageArrived({
payloadString: message,
destinationName: topic,
}: {
payloadString: string
destinationName: string
}) {
this.addEvent({
time: Date.now(),
type: "MESSAGE_RECEIVED",
message: {
topic,
message,
},
})
}
private handleError(error: unknown) {
this.disconnect()
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "CONNECTION_NOT_ESTABLISHED",
value: error,
},
})
}
publish(topic: string, message: string) {
if (this.connectionState$.value === "DISCONNECTED") return
try {
// it was publish
this.mqttClient?.send(topic, message, 0, false)
this.addEvent({
time: Date.now(),
type: "MESSAGE_SENT",
message: {
topic,
message,
},
})
} catch (e) {
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "PUBLISH_ERROR",
topic,
message,
},
})
}
}
subscribe(topic: string) {
try {
this.mqttClient?.subscribe(topic, {
onSuccess: this.usubSuccess.bind(this, topic),
onFailure: this.usubFailure.bind(this, topic),
})
} catch (e) {
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "SUBSCRIPTION_FAILED",
topic,
},
})
}
}
usubSuccess(topic: string) {
this.subscriptionState$.next(!this.subscriptionState$.value)
this.addEvent({
time: Date.now(),
type: "SUBSCRIBED",
topic,
})
}
usubFailure(topic: string) {
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "SUBSCRIPTION_FAILED",
topic,
},
})
}
unsubscribe(topic: string) {
this.mqttClient?.unsubscribe(topic, {
onSuccess: this.usubSuccess.bind(this, topic),
onFailure: this.usubFailure.bind(this, topic),
})
}
disconnect() {
this.manualDisconnect = true
this.mqttClient?.disconnect()
this.connectionState$.next("DISCONNECTED")
}
}

View File

@@ -0,0 +1,84 @@
import wildcard from "socketio-wildcard"
import ClientV2 from "socket.io-client-v2"
import { io as ClientV4, Socket as SocketV4 } from "socket.io-client-v4"
import { io as ClientV3, Socket as SocketV3 } from "socket.io-client-v3"
type Options = {
path: string
auth: {
token: string | undefined
}
}
type PossibleEvent =
| "connect"
| "connect_error"
| "reconnect_error"
| "error"
| "disconnect"
| "*"
export interface SIOClient {
connect(url: string, opts?: Options): void
on(event: PossibleEvent, cb: (data: any) => void): void
emit(event: string, data: any, cb: (data: any) => void): void
close(): void
}
export class SIOClientV4 implements SIOClient {
private client: SocketV4 | undefined
connect(url: string, opts?: Options) {
this.client = ClientV4(url, opts)
}
on(event: PossibleEvent, cb: (data: any) => void) {
this.client?.on(event, cb)
}
emit(event: string, data: any, cb: (data: any) => void): void {
this.client?.emit(event, data, cb)
}
close(): void {
this.client?.close()
}
}
export class SIOClientV3 implements SIOClient {
private client: SocketV3 | undefined
connect(url: string, opts?: Options) {
this.client = ClientV3(url, opts)
}
on(event: PossibleEvent, cb: (data: any) => void): void {
this.client?.on(event, cb)
}
emit(event: string, data: any, cb: (data: any) => void): void {
this.client?.emit(event, data, cb)
}
close(): void {
this.client?.close()
}
}
export class SIOClientV2 implements SIOClient {
private client: any | undefined
connect(url: string, opts?: Options) {
this.client = new ClientV2(url, opts)
wildcard(ClientV2.Manager)(this.client)
}
on(event: PossibleEvent, cb: (data: any) => void): void {
this.client?.on(event, cb)
}
emit(event: string, data: any, cb: (data: any) => void): void {
this.client?.emit(event, data, cb)
}
close(): void {
this.client?.close()
}
}

View File

@@ -0,0 +1,163 @@
import { BehaviorSubject, Subject } from "rxjs"
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
import { SIOClientV2, SIOClientV3, SIOClientV4, SIOClient } from "./SIOClients"
import { SIOClientVersion } from "~/newstore/SocketIOSession"
export const SOCKET_CLIENTS = {
v2: SIOClientV2,
v3: SIOClientV3,
v4: SIOClientV4,
} as const
type SIOAuth = { type: "None" } | { type: "Bearer"; token: string }
export type ConnectionOption = {
url: string
path: string
clientVersion: SIOClientVersion
auth: SIOAuth | undefined
}
export type SIOMessage = {
eventName: string
value: unknown
}
type SIOErrorType = "CONNECTION" | "RECONNECT_ERROR" | "UNKNOWN"
export type SIOError = {
type: SIOErrorType
value: unknown
}
export type SIOEvent = { time: number } & (
| { type: "CONNECTING" }
| { type: "CONNECTED" }
| { type: "MESSAGE_SENT"; message: SIOMessage }
| { type: "MESSAGE_RECEIVED"; message: SIOMessage }
| { type: "DISCONNECTED"; manual: boolean }
| { type: "ERROR"; error: SIOError }
)
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
export class SIOConnection {
connectionState$: BehaviorSubject<ConnectionState>
event$: Subject<SIOEvent> = new Subject()
socket: SIOClient | undefined
constructor() {
this.connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
}
private addEvent(event: SIOEvent) {
this.event$.next(event)
}
connect({ url, path, clientVersion, auth }: ConnectionOption) {
this.connectionState$.next("CONNECTING")
this.addEvent({
time: Date.now(),
type: "CONNECTING",
})
try {
this.socket = new SOCKET_CLIENTS[clientVersion]()
if (auth?.type === "Bearer") {
this.socket.connect(url, {
path,
auth: {
token: auth.token,
},
})
} else {
this.socket.connect(url)
}
this.socket.on("connect", () => {
this.connectionState$.next("CONNECTED")
this.addEvent({
type: "CONNECTED",
time: Date.now(),
})
})
this.socket.on("*", ({ data }: { data: string[] }) => {
const [eventName, message] = data
this.addEvent({
message: { eventName, value: message },
type: "MESSAGE_RECEIVED",
time: Date.now(),
})
})
this.socket.on("connect_error", (error: unknown) => {
this.handleError(error, "CONNECTION")
})
this.socket.on("reconnect_error", (error: unknown) => {
this.handleError(error, "RECONNECT_ERROR")
})
this.socket.on("error", (error: unknown) => {
this.handleError(error, "UNKNOWN")
})
this.socket.on("disconnect", () => {
this.connectionState$.next("DISCONNECTED")
this.addEvent({
type: "DISCONNECTED",
time: Date.now(),
manual: true,
})
})
} catch (error) {
this.handleError(error, "CONNECTION")
}
logHoppRequestRunToAnalytics({
platform: "socketio",
})
}
private handleError(error: unknown, type: SIOErrorType) {
this.disconnect()
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type,
value: error,
},
})
}
sendMessage(event: { message: string; eventName: string }) {
if (this.connectionState$.value === "DISCONNECTED") return
const { message, eventName } = event
this.socket?.emit(eventName, message, (data) => {
// receive response from server
this.addEvent({
time: Date.now(),
type: "MESSAGE_RECEIVED",
message: {
eventName,
value: data,
},
})
})
this.addEvent({
time: Date.now(),
type: "MESSAGE_SENT",
message: {
eventName,
value: message,
},
})
}
disconnect() {
this.socket?.close()
this.connectionState$.next("DISCONNECTED")
}
}

View File

@@ -0,0 +1,86 @@
import { BehaviorSubject, Subject } from "rxjs"
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
export type SSEEvent = { time: number } & (
| { type: "STARTING" }
| { type: "STARTED" }
| { type: "MESSAGE_RECEIVED"; message: string }
| { type: "STOPPED"; manual: boolean }
| { type: "ERROR"; error: Event | null }
)
export type ConnectionState = "STARTING" | "STARTED" | "STOPPED"
export class SSEConnection {
connectionState$: BehaviorSubject<ConnectionState>
event$: Subject<SSEEvent> = new Subject()
sse: EventSource | undefined
constructor() {
this.connectionState$ = new BehaviorSubject<ConnectionState>("STOPPED")
}
private addEvent(event: SSEEvent) {
this.event$.next(event)
}
start(url: string, eventType: string) {
this.connectionState$.next("STARTING")
this.addEvent({
time: Date.now(),
type: "STARTING",
})
if (typeof EventSource !== "undefined") {
try {
this.sse = new EventSource(url)
this.sse.onopen = () => {
this.connectionState$.next("STARTED")
this.addEvent({
type: "STARTED",
time: Date.now(),
})
}
this.sse.onerror = this.handleError
this.sse.addEventListener(eventType, ({ data }) => {
this.addEvent({
type: "MESSAGE_RECEIVED",
message: data,
time: Date.now(),
})
})
} catch (error) {
// A generic event type returned if anything goes wrong or browser doesn't support SSE
// https://developer.mozilla.org/en-US/docs/Web/API/EventSource/error_event#event_type
this.handleError(error as Event)
}
} else {
this.addEvent({
type: "ERROR",
time: Date.now(),
error: null,
})
}
logHoppRequestRunToAnalytics({
platform: "sse",
})
}
private handleError(error: Event) {
this.stop()
this.addEvent({
time: Date.now(),
type: "ERROR",
error,
})
}
stop() {
this.sse?.close()
this.connectionState$.next("STOPPED")
this.addEvent({
type: "STOPPED",
time: Date.now(),
manual: true,
})
}
}

View File

@@ -0,0 +1,102 @@
import { BehaviorSubject, Subject } from "rxjs"
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
export type WSErrorMessage = SyntaxError | Event
export type WSEvent = { time: number } & (
| { type: "CONNECTING" }
| { type: "CONNECTED" }
| { type: "MESSAGE_SENT"; message: string }
| { type: "MESSAGE_RECEIVED"; message: string }
| { type: "DISCONNECTED"; manual: boolean }
| { type: "ERROR"; error: WSErrorMessage }
)
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
export class WSConnection {
connectionState$: BehaviorSubject<ConnectionState>
event$: Subject<WSEvent> = new Subject()
socket: WebSocket | undefined
constructor() {
this.connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
}
private addEvent(event: WSEvent) {
this.event$.next(event)
}
connect(url: string, protocols: string[]) {
try {
this.connectionState$.next("CONNECTING")
this.socket = new WebSocket(url, protocols)
this.addEvent({
time: Date.now(),
type: "CONNECTING",
})
this.socket.onopen = () => {
this.connectionState$.next("CONNECTED")
this.addEvent({
type: "CONNECTED",
time: Date.now(),
})
}
this.socket.onerror = (error) => {
this.handleError(error)
}
this.socket.onclose = () => {
this.connectionState$.next("DISCONNECTED")
this.addEvent({
type: "DISCONNECTED",
time: Date.now(),
manual: true,
})
}
this.socket.onmessage = ({ data }) => {
this.addEvent({
time: Date.now(),
type: "MESSAGE_RECEIVED",
message: data,
})
}
} catch (error) {
// We will have SyntaxError if anything goes wrong with WebSocket constructor
// See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#exceptions
this.handleError(error as SyntaxError)
}
logHoppRequestRunToAnalytics({
platform: "wss",
})
}
private handleError(error: WSErrorMessage) {
this.disconnect()
this.addEvent({
time: Date.now(),
type: "ERROR",
error,
})
}
sendMessage(event: { message: string; eventName: string }) {
if (this.connectionState$.value === "DISCONNECTED") return
const { message } = event
this.socket?.send(message)
this.addEvent({
time: Date.now(),
type: "MESSAGE_SENT",
message,
})
}
disconnect() {
this.socket?.close()
}
}

View File

@@ -0,0 +1,8 @@
/**
* Defines how a Shortcode is represented in the ShortcodeListAdapter
*/
export interface Shortcode {
id: string
request: string
createdOn: Date
}

View File

@@ -0,0 +1,149 @@
import * as E from "fp-ts/Either"
import { BehaviorSubject, Subscription } from "rxjs"
import { GQLError, runGQLQuery, runGQLSubscription } from "../backend/GQLClient"
import {
GetUserShortcodesQuery,
GetUserShortcodesDocument,
ShortcodeCreatedDocument,
ShortcodeDeletedDocument,
} from "../backend/graphql"
import { BACKEND_PAGE_SIZE } from "../backend/helpers"
import { Shortcode } from "./Shortcode"
export default class ShortcodeListAdapter {
error$: BehaviorSubject<GQLError<string> | null>
loading$: BehaviorSubject<boolean>
shortcodes$: BehaviorSubject<GetUserShortcodesQuery["myShortcodes"]>
hasMoreShortcodes$: BehaviorSubject<boolean>
private timeoutHandle: ReturnType<typeof setTimeout> | null
private isDispose: boolean
private myShortcodesCreated: Subscription | null
private myShortcodesRevoked: Subscription | null
constructor(deferInit: boolean = false) {
this.error$ = new BehaviorSubject<GQLError<string> | null>(null)
this.loading$ = new BehaviorSubject<boolean>(false)
this.shortcodes$ = new BehaviorSubject<
GetUserShortcodesQuery["myShortcodes"]
>([])
this.hasMoreShortcodes$ = new BehaviorSubject<boolean>(true)
this.timeoutHandle = null
this.isDispose = false
this.myShortcodesCreated = null
this.myShortcodesRevoked = null
if (!deferInit) this.initialize()
}
unsubscribeSubscriptions() {
this.myShortcodesCreated?.unsubscribe()
this.myShortcodesRevoked?.unsubscribe()
}
initialize() {
if (this.timeoutHandle) throw new Error(`Adapter already initialized`)
if (this.isDispose) throw new Error(`Adapter has been disposed`)
this.fetchList()
this.registerSubscriptions()
}
public dispose() {
if (!this.timeoutHandle) throw new Error(`Adapter has not been initialized`)
if (!this.isDispose) throw new Error(`Adapter has been disposed`)
this.isDispose = true
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
this.unsubscribeSubscriptions()
}
fetchList() {
this.loadMore(true)
}
async loadMore(forcedAttempt = false) {
if (!this.hasMoreShortcodes$.value && !forcedAttempt) return
this.loading$.next(true)
const lastCodeID =
this.shortcodes$.value.length > 0
? this.shortcodes$.value[this.shortcodes$.value.length - 1].id
: undefined
const result = await runGQLQuery({
query: GetUserShortcodesDocument,
variables: {
cursor: lastCodeID,
},
})
if (E.isLeft(result)) {
this.error$.next(result.left)
console.error(result.left)
this.loading$.next(false)
throw new Error(`Failed fetching short codes list: ${result.left}`)
}
const fetchedResult = result.right.myShortcodes
this.pushNewShortcodes(fetchedResult)
if (fetchedResult.length !== BACKEND_PAGE_SIZE) {
this.hasMoreShortcodes$.next(false)
}
this.loading$.next(false)
}
private pushNewShortcodes(results: Shortcode[]) {
const userShortcodes = this.shortcodes$.value
userShortcodes.push(...results)
this.shortcodes$.next(userShortcodes)
}
private createShortcode(shortcode: Shortcode) {
const userShortcodes = this.shortcodes$.value
userShortcodes.unshift(shortcode)
this.shortcodes$.next(userShortcodes)
}
private deleteShortcode(codeId: string) {
const newShortcodes = this.shortcodes$.value.filter(
({ id }) => id !== codeId
)
this.shortcodes$.next(newShortcodes)
}
private registerSubscriptions() {
this.myShortcodesCreated = runGQLSubscription({
query: ShortcodeCreatedDocument,
}).subscribe((result) => {
if (E.isLeft(result)) {
console.error(result.left)
throw new Error(`Shortcode Create Error ${result.left}`)
}
this.createShortcode(result.right.myShortcodesCreated)
})
this.myShortcodesRevoked = runGQLSubscription({
query: ShortcodeDeletedDocument,
}).subscribe((result) => {
if (E.isLeft(result)) {
console.error(result.left)
throw new Error(`Shortcode Delete Error ${result.left}`)
}
this.deleteShortcode(result.right.myShortcodesRevoked.id)
})
}
}

View File

@@ -2,6 +2,7 @@ import axios, { AxiosRequestConfig } from "axios"
import { v4 } from "uuid" import { v4 } from "uuid"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import cloneDeep from "lodash/cloneDeep"
import { NetworkResponse, NetworkStrategy } from "../network" import { NetworkResponse, NetworkStrategy } from "../network"
import { decodeB64StringToArrayBuffer } from "../utils/b64" import { decodeB64StringToArrayBuffer } from "../utils/b64"
import { settingsStore } from "~/newstore/settings" import { settingsStore } from "~/newstore/settings"
@@ -40,19 +41,43 @@ const getProxyPayload = (
return payload return payload
} }
const preProcessRequest = (req: AxiosRequestConfig): AxiosRequestConfig => {
const reqClone = cloneDeep(req)
// If the parameters are URLSearchParams, inject them to URL instead
// This prevents issues of marshalling the URLSearchParams to the proxy
if (reqClone.params instanceof URLSearchParams) {
try {
const url = new URL(reqClone.url ?? "")
for (const [key, value] of reqClone.params.entries()) {
url.searchParams.append(key, value)
}
reqClone.url = url.toString()
} catch (e) {}
reqClone.params = {}
}
return reqClone
}
const axiosWithProxy: NetworkStrategy = (req) => const axiosWithProxy: NetworkStrategy = (req) =>
pipe( pipe(
TE.Do, TE.Do,
TE.bind("processedReq", () => TE.of(preProcessRequest(req))),
// If the request has FormData, the proxy needs a key // If the request has FormData, the proxy needs a key
TE.bind("multipartKey", () => TE.bind("multipartKey", ({ processedReq }) =>
TE.of(req.data instanceof FormData ? v4() : null) TE.of(processedReq.data instanceof FormData ? v4() : null)
), ),
// Build headers to send // Build headers to send
TE.bind("headers", ({ multipartKey }) => TE.bind("headers", ({ processedReq, multipartKey }) =>
TE.of( TE.of(
req.data instanceof FormData processedReq.data instanceof FormData
? <ProxyHeaders>{ ? <ProxyHeaders>{
"multipart-part-key": `proxyRequestData-${multipartKey}`, "multipart-part-key": `proxyRequestData-${multipartKey}`,
} }
@@ -61,8 +86,8 @@ const axiosWithProxy: NetworkStrategy = (req) =>
), ),
// Create payload // Create payload
TE.bind("payload", ({ multipartKey }) => TE.bind("payload", ({ processedReq, multipartKey }) =>
TE.of(getProxyPayload(req, multipartKey)) TE.of(getProxyPayload(processedReq, multipartKey))
), ),
// Run the proxy request // Run the proxy request

View File

@@ -1,5 +1,8 @@
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import { AxiosRequestConfig } from "axios"
import cloneDeep from "lodash/cloneDeep"
import { NetworkResponse, NetworkStrategy } from "../network" import { NetworkResponse, NetworkStrategy } from "../network"
import { browserIsChrome, browserIsFirefox } from "../utils/userAgent" import { browserIsChrome, browserIsFirefox } from "../utils/userAgent"
@@ -13,31 +16,92 @@ export const hasFirefoxExtensionInstalled = () =>
hasExtensionInstalled() && browserIsFirefox() hasExtensionInstalled() && browserIsFirefox()
export const cancelRunningExtensionRequest = () => { export const cancelRunningExtensionRequest = () => {
if ( window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRunningRequest()
hasExtensionInstalled() &&
window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest
) {
window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest()
} }
export const defineSubscribableObject = <T extends object>(obj: T) => {
const proxyObject = {
...obj,
_subscribers: {} as {
// eslint-disable-next-line no-unused-vars
[key in keyof T]?: ((...args: any[]) => any)[]
},
subscribe(prop: keyof T, func: (...args: any[]) => any): void {
if (Array.isArray(this._subscribers[prop])) {
this._subscribers[prop]?.push(func)
} else {
this._subscribers[prop] = [func]
}
},
}
type SubscribableProxyObject = typeof proxyObject
return new Proxy(proxyObject, {
set(obj, prop, newVal) {
obj[prop as keyof SubscribableProxyObject] = newVal
const currentSubscribers = obj._subscribers[prop as keyof T]
if (Array.isArray(currentSubscribers)) {
for (const subscriber of currentSubscribers) {
subscriber(newVal)
}
}
return true
},
})
}
const preProcessRequest = (req: AxiosRequestConfig): AxiosRequestConfig => {
const reqClone = cloneDeep(req)
// If the parameters are URLSearchParams, inject them to URL instead
// This prevents marshalling issues with structured cloning of URLSearchParams
if (reqClone.params instanceof URLSearchParams) {
try {
const url = new URL(reqClone.url ?? "")
for (const [key, value] of reqClone.params.entries()) {
url.searchParams.append(key, value)
}
reqClone.url = url.toString()
} catch (e) {}
reqClone.params = {}
}
return reqClone
} }
const extensionStrategy: NetworkStrategy = (req) => const extensionStrategy: NetworkStrategy = (req) =>
pipe( pipe(
TE.Do, TE.Do,
TE.bind("processedReq", () => TE.of(preProcessRequest(req))),
// Storeing backup timing data in case the extension does not have that info // Storeing backup timing data in case the extension does not have that info
TE.bind("backupTimeDataStart", () => TE.of(new Date().getTime())), TE.bind("backupTimeDataStart", () => TE.of(new Date().getTime())),
// Run the request // Run the request
TE.bind("response", () => TE.bind("response", ({ processedReq }) =>
pipe(
window.__POSTWOMAN_EXTENSION_HOOK__,
O.fromNullable,
TE.fromOption(() => "NO_PW_EXT_HOOK" as const),
TE.chain((extensionHook) =>
TE.tryCatch( TE.tryCatch(
() => () =>
window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({ extensionHook.sendRequest({
...req, ...processedReq,
wantsBinary: true, wantsBinary: true,
}) as Promise<NetworkResponse>, }),
(err) => err as any (err) => err as any
) )
)
)
), ),
// Inject backup time data if not present // Inject backup time data if not present

View File

@@ -122,13 +122,6 @@ describe("cancelRunningExtensionRequest", () => {
cancelRunningExtensionRequest() cancelRunningExtensionRequest()
expect(cancelFunc).not.toHaveBeenCalled() expect(cancelFunc).not.toHaveBeenCalled()
}) })
test("does not cancel request if extension installed but function not present", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = {}
cancelRunningExtensionRequest()
expect(cancelFunc).not.toHaveBeenCalled()
})
}) })
describe("extensionStrategy", () => { describe("extensionStrategy", () => {

View File

@@ -1,8 +1,9 @@
export type HoppRealtimeLogLine = { export type HoppRealtimeLogLine = {
prefix?: string
payload: string payload: string
source: string source: string
color?: string color?: string
ts: string ts: number | undefined
} }
export type HoppRealtimeLog = HoppRealtimeLogLine[] export type HoppRealtimeLog = HoppRealtimeLogLine[]

View File

@@ -11,6 +11,8 @@ import {
parseBodyEnvVariables, parseBodyEnvVariables,
parseRawKeyValueEntries, parseRawKeyValueEntries,
Environment, Environment,
HoppRESTHeader,
HoppRESTParam,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { arrayFlatMap, arraySort } from "../functional/array" import { arrayFlatMap, arraySort } from "../functional/array"
import { toFormData } from "../functional/formData" import { toFormData } from "../functional/formData"
@@ -29,6 +31,146 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
effectiveFinalBody: FormData | string | null effectiveFinalBody: FormData | string | null
} }
/**
* Get headers that can be generated by authorization config of the request
* @param req Request to check
* @param envVars Currently active environment variables
* @returns The list of headers
*/
const getComputedAuthHeaders = (
req: HoppRESTRequest,
envVars: Environment["variables"]
) => {
// If Authorization header is also being user-defined, that takes priority
if (req.headers.find((h) => h.key.toLowerCase() === "authorization"))
return []
if (!req.auth.authActive) return []
const headers: HoppRESTHeader[] = []
// TODO: Support a better b64 implementation than btoa ?
if (req.auth.authType === "basic") {
const username = parseTemplateString(req.auth.username, envVars)
const password = parseTemplateString(req.auth.password, envVars)
headers.push({
active: true,
key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`,
})
} else if (
req.auth.authType === "bearer" ||
req.auth.authType === "oauth-2"
) {
headers.push({
active: true,
key: "Authorization",
value: `Bearer ${parseTemplateString(req.auth.token, envVars)}`,
})
} else if (req.auth.authType === "api-key") {
const { key, value, addTo } = req.auth
if (addTo === "Headers") {
headers.push({
active: true,
key: parseTemplateString(key, envVars),
value: parseTemplateString(value, envVars),
})
}
}
return headers
}
/**
* Get headers that can be generated by body config of the request
* @param req Request to check
* @returns The list of headers
*/
export const getComputedBodyHeaders = (
req: HoppRESTRequest
): HoppRESTHeader[] => {
// If a content-type is already defined, that will override this
if (
req.headers.find(
(req) => req.active && req.key.toLowerCase() === "content-type"
)
)
return []
// Body should have a non-null content-type
if (req.body.contentType === null) return []
return [
{
active: true,
key: "content-type",
value: req.body.contentType,
},
]
}
export type ComputedHeader = {
source: "auth" | "body"
header: HoppRESTHeader
}
/**
* Returns a list of headers that will be added during execution of the request
* For e.g, Authorization headers maybe added if an Auth Mode is defined on REST
* @param req The request to check
* @param envVars The environment variables active
* @returns The headers that are generated along with the source of that header
*/
export const getComputedHeaders = (
req: HoppRESTRequest,
envVars: Environment["variables"]
): ComputedHeader[] => [
...getComputedAuthHeaders(req, envVars).map((header) => ({
source: "auth" as const,
header,
})),
...getComputedBodyHeaders(req).map((header) => ({
source: "body" as const,
header,
})),
]
export type ComputedParam = {
source: "auth"
param: HoppRESTParam
}
/**
* Returns a list of params that will be added during execution of the request
* For e.g, Authorization params (like API-key) maybe added if an Auth Mode is defined on REST
* @param req The request to check
* @param envVars The environment variables active
* @returns The params that are generated along with the source of that header
*/
export const getComputedParams = (
req: HoppRESTRequest,
envVars: Environment["variables"]
): ComputedParam[] => {
// When this gets complex, its best to split this function off (like with getComputedHeaders)
// API-key auth can be added to query params
if (!req.auth.authActive) return []
if (req.auth.authType !== "api-key") return []
if (req.auth.addTo !== "Query params") return []
return [
{
source: "auth",
param: {
active: true,
key: parseTemplateString(req.auth.key, envVars),
value: parseTemplateString(req.auth.value, envVars),
},
},
]
}
// Resolves environment variables in the body // Resolves environment variables in the body
export const resolvesEnvsInBody = ( export const resolvesEnvsInBody = (
body: HoppRESTReqBody, body: HoppRESTReqBody,
@@ -135,83 +277,29 @@ export function getEffectiveRESTRequest(
): EffectiveHoppRESTRequest { ): EffectiveHoppRESTRequest {
const envVariables = [...environment.variables, ...getGlobalVariables()] const envVariables = [...environment.variables, ...getGlobalVariables()]
const effectiveFinalHeaders = request.headers const effectiveFinalHeaders = pipe(
.filter( getComputedHeaders(request, envVariables).map((h) => h.header),
(x) => A.concat(request.headers),
x.key !== "" && // Remove empty keys A.filter((x) => x.active && x.key !== ""),
x.active // Only active A.map((x) => ({
)
.map((x) => ({
// Parse out environment template strings
active: true, active: true,
key: parseTemplateString(x.key, envVariables), key: parseTemplateString(x.key, envVariables),
value: parseTemplateString(x.value, envVariables), value: parseTemplateString(x.value, envVariables),
})) }))
const effectiveFinalParams = request.params
.filter(
(x) =>
x.key !== "" && // Remove empty keys
x.active // Only active
) )
.map((x) => ({
const effectiveFinalParams = pipe(
getComputedParams(request, envVariables).map((p) => p.param),
A.concat(request.params),
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true, active: true,
key: parseTemplateString(x.key, envVariables), key: parseTemplateString(x.key, envVariables),
value: parseTemplateString(x.value, envVariables), value: parseTemplateString(x.value, envVariables),
})) }))
)
// Authentication
if (request.auth.authActive) {
// TODO: Support a better b64 implementation than btoa ?
if (request.auth.authType === "basic") {
const username = parseTemplateString(request.auth.username, envVariables)
const password = parseTemplateString(request.auth.password, envVariables)
effectiveFinalHeaders.push({
active: true,
key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`,
})
} else if (
request.auth.authType === "bearer" ||
request.auth.authType === "oauth-2"
) {
effectiveFinalHeaders.push({
active: true,
key: "Authorization",
value: `Bearer ${parseTemplateString(
request.auth.token,
envVariables
)}`,
})
} else if (request.auth.authType === "api-key") {
const { key, value, addTo } = request.auth
if (addTo === "Headers") {
effectiveFinalHeaders.push({
active: true,
key: parseTemplateString(key, envVariables),
value: parseTemplateString(value, envVariables),
})
} else if (addTo === "Query params") {
effectiveFinalParams.push({
active: true,
key: parseTemplateString(key, envVariables),
value: parseTemplateString(value, envVariables),
})
}
}
}
const effectiveFinalBody = getFinalBodyFromRequest(request, envVariables) const effectiveFinalBody = getFinalBodyFromRequest(request, envVariables)
const contentTypeInHeader = effectiveFinalHeaders.find(
(x) => x.key.toLowerCase() === "content-type"
)
if (request.body.contentType && !contentTypeInHeader?.value)
effectiveFinalHeaders.push({
active: true,
key: "content-type",
value: request.body.contentType,
})
return { return {
...request, ...request,

View File

@@ -7,8 +7,8 @@ describe("wsValid", () => {
}) })
test("returns true for valid URL with Hostname", () => { test("returns true for valid URL with Hostname", () => {
expect(wsValid("wss://hoppscotch-websocket.herokuapp.com/")).toBe(true) expect(wsValid("wss://echo-websocket.hoppscotch.io/")).toBe(true)
expect(wsValid("wss://hoppscotch-websocket.herokuapp.com")).toBe(true) expect(wsValid("wss://echo-websocket.hoppscotch.io")).toBe(true)
}) })
test("returns false for invalid URL with IP address", () => { test("returns false for invalid URL with IP address", () => {
@@ -29,8 +29,8 @@ describe("wsValid", () => {
}) })
test("returns true for wss protocol URLs", () => { test("returns true for wss protocol URLs", () => {
expect(wsValid("wss://hoppscotch-websocket.herokuapp.com/")).toBe(true) expect(wsValid("wss://echo-websocket.hoppscotch.io/")).toBe(true)
expect(wsValid("wss://hoppscotch-websocket.herokuapp.com")).toBe(true) expect(wsValid("wss://echo-websocket.hoppscotch.io")).toBe(true)
expect(wsValid("wss://174.129.224.73/")).toBe(true) expect(wsValid("wss://174.129.224.73/")).toBe(true)
expect(wsValid("wss://174.129.224.73")).toBe(true) expect(wsValid("wss://174.129.224.73")).toBe(true)
}) })
@@ -65,8 +65,8 @@ describe("httpValid", () => {
}) })
test("returns false for non-http(s) protocol URLs", () => { test("returns false for non-http(s) protocol URLs", () => {
expect(httpValid("wss://hoppscotch-websocket.herokuapp.com/")).toBe(false) expect(httpValid("wss://echo-websocket.hoppscotch.io/")).toBe(false)
expect(httpValid("wss://hoppscotch-websocket.herokuapp.com")).toBe(false) expect(httpValid("wss://echo-websocket.hoppscotch.io")).toBe(false)
expect(httpValid("wss://174.129.224.73/")).toBe(false) expect(httpValid("wss://174.129.224.73/")).toBe(false)
expect(httpValid("wss://174.129.224.73")).toBe(false) expect(httpValid("wss://174.129.224.73")).toBe(false)
}) })
@@ -129,10 +129,8 @@ describe("socketioValid", () => {
}) })
test("returns true for wss protocol URLs", () => { test("returns true for wss protocol URLs", () => {
expect(socketioValid("wss://hoppscotch-websocket.herokuapp.com/")).toBe( expect(socketioValid("wss://echo-websocket.hoppscotch.io/")).toBe(true)
true expect(socketioValid("wss://echo-websocket.hoppscotch.io")).toBe(true)
)
expect(socketioValid("wss://hoppscotch-websocket.herokuapp.com")).toBe(true)
expect(socketioValid("wss://174.129.224.73/")).toBe(true) expect(socketioValid("wss://174.129.224.73/")).toBe(true)
expect(socketioValid("wss://174.129.224.73")).toBe(true) expect(socketioValid("wss://174.129.224.73")).toBe(true)
}) })

View File

@@ -1,12 +0,0 @@
const sourceEmojis = {
// Source used for info messages.
info: "\t [INFO]:\t",
// Source used for client to server messages.
client: "\t⬅ [SENT]:\t",
// Source used for server to client messages.
server: "\t➡ [RECEIVED]:\t",
}
export function getSourcePrefix(source: keyof typeof sourceEmojis) {
return sourceEmojis[source]
}

View File

@@ -64,6 +64,8 @@ import {
useRouter, useRouter,
watch, watch,
ref, ref,
onMounted,
onBeforeUnmount,
} from "@nuxtjs/composition-api" } from "@nuxtjs/composition-api"
import { Splitpanes, Pane } from "splitpanes" import { Splitpanes, Pane } from "splitpanes"
import "splitpanes/dist/splitpanes.css" import "splitpanes/dist/splitpanes.css"
@@ -77,6 +79,12 @@ import { hookKeybindingsListener } from "~/helpers/keybindings"
import { defineActionHandler } from "~/helpers/actions" import { defineActionHandler } from "~/helpers/actions"
import { useSentry } from "~/helpers/sentry" import { useSentry } from "~/helpers/sentry"
import { useColorMode } from "~/helpers/utils/composables" import { useColorMode } from "~/helpers/utils/composables"
import {
changeExtensionStatus,
ExtensionStatus,
} from "~/newstore/HoppExtension"
import { defineSubscribableObject } from "~/helpers/strategies/ExtensionStrategy"
function appLayout() { function appLayout() {
const rightSidebar = useSetting("SIDEBAR") const rightSidebar = useSetting("SIDEBAR")
@@ -202,6 +210,62 @@ function defineJumpActions() {
}) })
} }
function setupExtensionHooks() {
const extensionPollIntervalId = ref<ReturnType<typeof setInterval>>()
onMounted(() => {
if (window.__HOPP_EXTENSION_STATUS_PROXY__) {
changeExtensionStatus(window.__HOPP_EXTENSION_STATUS_PROXY__.status)
window.__HOPP_EXTENSION_STATUS_PROXY__.subscribe(
"status",
(status: ExtensionStatus) => changeExtensionStatus(status)
)
} else {
const statusProxy = defineSubscribableObject({
status: "waiting" as ExtensionStatus,
})
window.__HOPP_EXTENSION_STATUS_PROXY__ = statusProxy
statusProxy.subscribe("status", (status: ExtensionStatus) =>
changeExtensionStatus(status)
)
/**
* Keeping identifying extension backward compatible
* We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately,
* then we use a poll to find the version, this will get the version for 0.24 and any other version
* of the extension, but will have a slight lag.
* 0.24 users will get the benefits of 0.24, while the extension won't break for the old users
*/
extensionPollIntervalId.value = setInterval(() => {
if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") {
if (extensionPollIntervalId.value)
clearInterval(extensionPollIntervalId.value)
const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
// When the version is not 0.24 or higher, the extension wont do this. so we have to do it manually
if (
version.major === 0 &&
version.minor <= 23 &&
window.__HOPP_EXTENSION_STATUS_PROXY__
) {
window.__HOPP_EXTENSION_STATUS_PROXY__.status = "available"
}
}
}, 2000)
}
})
// Cleanup timer
onBeforeUnmount(() => {
if (extensionPollIntervalId.value) {
clearInterval(extensionPollIntervalId.value)
}
})
}
export default defineComponent({ export default defineComponent({
components: { Splitpanes, Pane }, components: { Splitpanes, Pane },
setup() { setup() {
@@ -229,6 +293,8 @@ export default defineComponent({
showSupport.value = !showSupport.value showSupport.value = !showSupport.value
}) })
setupExtensionHooks()
return { return {
mdAndLarger, mdAndLarger,
spacerClass, spacerClass,

Some files were not shown because too many files have changed in this diff Show More