Compare commits
42 Commits
fix/save-o
...
feat/realt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d13b381097 | ||
|
|
663da34e08 | ||
|
|
eb6c4f1a05 | ||
|
|
b132077cdd | ||
|
|
f6950bac0f | ||
|
|
83bdd03f43 | ||
|
|
b1a2c9e9d5 | ||
|
|
346ac8bde9 | ||
|
|
cfa89a6ded | ||
|
|
184914ba4f | ||
|
|
432337b801 | ||
|
|
d04520698d | ||
|
|
450af983e2 | ||
|
|
c36e421d13 | ||
|
|
fb1da491d8 | ||
|
|
127bd7318f | ||
|
|
c3b784c680 | ||
|
|
df55807fa4 | ||
|
|
4ef2844a22 | ||
|
|
c20339d222 | ||
|
|
514210e167 | ||
|
|
50744136d0 | ||
|
|
07735994e1 | ||
|
|
00acf06700 | ||
|
|
9486279b02 | ||
|
|
021908c8d1 | ||
|
|
bf97d8811c | ||
|
|
2452b1be4b | ||
|
|
7bf76a5812 | ||
|
|
d1b339df5d | ||
|
|
06937fe9e8 | ||
|
|
62a5beb52f | ||
|
|
100e562dcc | ||
|
|
09af858fbc | ||
|
|
01acbc8db6 | ||
|
|
f1c42f28de | ||
|
|
c99b224829 | ||
|
|
ede27e0600 | ||
|
|
99148a0a0e | ||
|
|
9232aad184 | ||
|
|
f7ca3f8bd1 | ||
|
|
ff51b7e5df |
34
.github/workflows/deploy-netlify.yml
vendored
Normal 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
|
||||
@@ -22,11 +22,11 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"husky": "^7.0.4",
|
||||
"lint-staged": "^12.3.7"
|
||||
"lint-staged": "^12.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^16.2.3",
|
||||
"@commitlint/config-conventional": "^16.2.1",
|
||||
"@types/node": "^17.0.23"
|
||||
"@types/node": "^17.0.24"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
"devDependencies": {
|
||||
"@lezer/generator": "^0.15.4",
|
||||
"mocha": "^9.2.2",
|
||||
"rollup": "^2.70.1",
|
||||
"rollup": "^2.70.2",
|
||||
"rollup-plugin-dts": "^4.2.1",
|
||||
"rollup-plugin-ts": "^2.0.5",
|
||||
"rollup-plugin-ts": "^2.0.7",
|
||||
"typescript": "^4.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,8 +299,8 @@ Description {
|
||||
}
|
||||
|
||||
OperationType {
|
||||
@specialize<Name, "query">
|
||||
| @specialize<Name, "mutation">
|
||||
@specialize<Name, "query">
|
||||
| @specialize<Name, "mutation">
|
||||
| @specialize<Name, "subscription">
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ ExecutableDirectiveLocation {
|
||||
@specialize<Name, "QUERY">
|
||||
| @specialize<Name, "MUTATION">
|
||||
| @specialize<Name, "SUBSCRIPTION">
|
||||
| @specialize<Name, "FIELD">
|
||||
| @specialize<Name, "FIELD">
|
||||
| @specialize<Name, "FRAGMENT_DEFINITION">
|
||||
| @specialize<Name, "FRAGMENT_SPREAD">
|
||||
| @specialize<Name, "INLINE_FRAGMENT">
|
||||
@@ -338,22 +338,24 @@ TypeSystemDirectiveLocation {
|
||||
| @specialize<Name, "INPUT_FIELD_DEFINITION">
|
||||
}
|
||||
|
||||
@skip { Whitespace | Comment }
|
||||
@skip { whitespace | Comment }
|
||||
|
||||
@tokens {
|
||||
Whitespace {
|
||||
whitespace {
|
||||
std.whitespace+
|
||||
}
|
||||
|
||||
StringValue {
|
||||
"\"\"\"" (!["] | "\\n" | "\"" "\""? !["])* "\"\"\"" | "\"" !["\\\n]* "\""
|
||||
}
|
||||
|
||||
IntValue {
|
||||
"-"? "0"
|
||||
| "-"? std.digit+
|
||||
}
|
||||
|
||||
FloatValue {
|
||||
IntValue ("." std.digit+ | ("e" | "E") IntValue+)
|
||||
IntValue ("." std.digit+ | ("e" | "E") IntValue+)
|
||||
}
|
||||
|
||||
@precedence { IntValue, FloatValue }
|
||||
@@ -367,6 +369,8 @@ TypeSystemDirectiveLocation {
|
||||
Comma {
|
||||
","
|
||||
}
|
||||
|
||||
"{" "}" "[" "]"
|
||||
}
|
||||
|
||||
@detectDelim
|
||||
@detectDelim
|
||||
|
||||
4
packages/hoppscotch-app/assets/icons/arrow-down-left.svg
Normal 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 |
4
packages/hoppscotch-app/assets/icons/arrow-down.svg
Normal 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 |
4
packages/hoppscotch-app/assets/icons/arrow-up-right.svg
Normal 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 |
4
packages/hoppscotch-app/assets/icons/arrow-up.svg
Normal 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 |
4
packages/hoppscotch-app/assets/icons/chevrons-down.svg
Normal 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 |
4
packages/hoppscotch-app/assets/icons/chevrons-up.svg
Normal 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 |
5
packages/hoppscotch-app/assets/icons/info-disconnect.svg
Normal 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 |
5
packages/hoppscotch-app/assets/icons/info-realtime.svg
Normal 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 |
4
packages/hoppscotch-app/assets/icons/send.svg
Normal 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 |
@@ -8,11 +8,7 @@
|
||||
{{ t("settings.interceptor_description") }}
|
||||
</p>
|
||||
</div>
|
||||
<SmartRadioGroup
|
||||
:radios="interceptors"
|
||||
:selected="interceptorSelection"
|
||||
@change="toggleSettingKey"
|
||||
/>
|
||||
<SmartRadioGroup v-model="interceptorSelection" :radios="interceptors" />
|
||||
<div
|
||||
v-if="interceptorSelection == 'EXTENSIONS_ENABLED' && !extensionVersion"
|
||||
class="flex space-x-2"
|
||||
@@ -38,58 +34,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from "@nuxtjs/composition-api"
|
||||
import { KeysMatching } from "~/types/ts-utils"
|
||||
import {
|
||||
applySetting,
|
||||
SettingsType,
|
||||
toggleSetting,
|
||||
useSetting,
|
||||
} from "~/newstore/settings"
|
||||
import { hasExtensionInstalled } from "~/helpers/strategies/ExtensionStrategy"
|
||||
import { useI18n, usePolled } from "~/helpers/utils/composables"
|
||||
import { computed } from "@nuxtjs/composition-api"
|
||||
import { applySetting, toggleSetting, useSetting } from "~/newstore/settings"
|
||||
import { useI18n, useReadonlyStream } from "~/helpers/utils/composables"
|
||||
import { extensionStatus$ } from "~/newstore/HoppExtension"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const PROXY_ENABLED = useSetting("PROXY_ENABLED")
|
||||
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
|
||||
|
||||
const toggleSettingKey = <
|
||||
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 currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
|
||||
|
||||
const extensionVersion = usePolled(5000, (stopPolling) => {
|
||||
const result = hasExtensionInstalled()
|
||||
? window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
|
||||
const extensionVersion = computed(() => {
|
||||
return currentExtensionStatus.value === "available"
|
||||
? window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() ?? null
|
||||
: null
|
||||
|
||||
// We don't need to poll anymore after we get value
|
||||
if (result) stopPolling()
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const interceptors = computed(() => [
|
||||
{ value: "BROWSER_ENABLED", label: t("state.none") },
|
||||
{ value: "PROXY_ENABLED", label: t("settings.proxy") },
|
||||
{ value: "BROWSER_ENABLED" as const, label: t("state.none") },
|
||||
{ value: "PROXY_ENABLED" as const, label: t("settings.proxy") },
|
||||
{
|
||||
value: "EXTENSIONS_ENABLED",
|
||||
value: "EXTENSIONS_ENABLED" as const,
|
||||
label:
|
||||
`${t("settings.extensions")}: ` +
|
||||
(extensionVersion.value !== null
|
||||
@@ -98,15 +65,27 @@ const interceptors = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const interceptorSelection = ref("")
|
||||
type InterceptorMode = typeof interceptors["value"][number]["value"]
|
||||
|
||||
watchEffect(() => {
|
||||
if (PROXY_ENABLED.value) {
|
||||
interceptorSelection.value = "PROXY_ENABLED"
|
||||
} else if (EXTENSIONS_ENABLED.value) {
|
||||
interceptorSelection.value = "EXTENSIONS_ENABLED"
|
||||
} else {
|
||||
interceptorSelection.value = "BROWSER_ENABLED"
|
||||
}
|
||||
const interceptorSelection = computed<InterceptorMode>({
|
||||
get() {
|
||||
if (PROXY_ENABLED.value) return "PROXY_ENABLED"
|
||||
if (EXTENSIONS_ENABLED.value) return "EXTENSIONS_ENABLED"
|
||||
return "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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</Splitpanes>
|
||||
</Pane>
|
||||
<Pane
|
||||
v-if="SIDEBAR"
|
||||
v-if="SIDEBAR && hasSidebar"
|
||||
size="25"
|
||||
min-size="20"
|
||||
class="hide-scrollbar !overflow-auto flex flex-col"
|
||||
@@ -42,6 +42,7 @@
|
||||
import { Splitpanes, Pane } from "splitpanes"
|
||||
import "splitpanes/dist/splitpanes.css"
|
||||
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
|
||||
import { computed, useSlots } from "@nuxtjs/composition-api"
|
||||
import { useSetting } from "~/newstore/settings"
|
||||
|
||||
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
|
||||
@@ -52,4 +53,8 @@ const mdAndLarger = breakpoints.greater("md")
|
||||
const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
|
||||
|
||||
const SIDEBAR = useSetting("SIDEBAR")
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const hasSidebar = computed(() => !!slots.sidebar)
|
||||
</script>
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
]"
|
||||
:disabled="disabled"
|
||||
:tabindex="loading ? '-1' : '0'"
|
||||
:type="type"
|
||||
role="button"
|
||||
>
|
||||
<span
|
||||
@@ -67,79 +66,41 @@
|
||||
</SmartLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
to: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
blank: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
svg: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
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 setup lang="ts">
|
||||
interface Props {
|
||||
to: string
|
||||
exact: boolean
|
||||
blank: boolean
|
||||
label: string
|
||||
icon: string
|
||||
svg: string
|
||||
color: string
|
||||
disabled: boolean
|
||||
loading: boolean
|
||||
large: boolean
|
||||
shadow: boolean
|
||||
reverse: boolean
|
||||
rounded: boolean
|
||||
gradient: boolean
|
||||
outline: boolean
|
||||
shortcut: string[]
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
to: "",
|
||||
exact: true,
|
||||
blank: false,
|
||||
label: "",
|
||||
icon: "",
|
||||
svg: "",
|
||||
color: "",
|
||||
disabled: false,
|
||||
loading: false,
|
||||
large: false,
|
||||
shadow: false,
|
||||
reverse: false,
|
||||
rounded: false,
|
||||
gradient: false,
|
||||
outline: false,
|
||||
shortcut: () => [],
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -66,71 +66,39 @@
|
||||
</SmartLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
to: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
blank: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
svg: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
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 setup lang="ts">
|
||||
interface Props {
|
||||
to: string
|
||||
exact: boolean
|
||||
blank: boolean
|
||||
label: string
|
||||
icon: string
|
||||
svg: string
|
||||
color: string
|
||||
disabled: boolean
|
||||
loading: boolean
|
||||
reverse: boolean
|
||||
rounded: boolean
|
||||
large: boolean
|
||||
outline: boolean
|
||||
shortcut: string[]
|
||||
filled: boolean
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
to: "",
|
||||
exact: true,
|
||||
blank: false,
|
||||
label: "",
|
||||
icon: "",
|
||||
svg: "",
|
||||
color: "",
|
||||
disabled: false,
|
||||
loading: false,
|
||||
reverse: false,
|
||||
rounded: false,
|
||||
large: false,
|
||||
outline: false,
|
||||
shortcut: () => [],
|
||||
filled: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<span>
|
||||
<ButtonPrimary
|
||||
:label="$t('action.save')"
|
||||
:loading="loadingState"
|
||||
@click.native="addNewCollection"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
@@ -43,6 +44,7 @@ import { defineComponent } from "@nuxtjs/composition-api"
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
loadingState: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -56,7 +58,6 @@ export default defineComponent({
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.name)
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
</template>
|
||||
<template #footer>
|
||||
<span>
|
||||
<ButtonPrimary :label="$t('action.save')" @click.native="addFolder" />
|
||||
<ButtonPrimary
|
||||
:label="$t('action.save')"
|
||||
:loading="loadingState"
|
||||
@click.native="addFolder"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="$t('action.cancel')"
|
||||
@click.native="hideModal"
|
||||
@@ -43,6 +47,7 @@ export default defineComponent({
|
||||
folder: { type: Object, default: () => {} },
|
||||
folderPath: { type: String, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -60,7 +65,6 @@ export default defineComponent({
|
||||
folder: this.folder,
|
||||
path: this.folderPath || `${this.collectionIndex}`,
|
||||
})
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
|
||||
@@ -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>
|
||||
@@ -26,6 +26,7 @@
|
||||
<span>
|
||||
<ButtonPrimary
|
||||
:label="$t('action.save')"
|
||||
:loading="loadingState"
|
||||
@click.native="saveCollection"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
@@ -44,6 +45,7 @@ export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingCollectionName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -62,7 +64,6 @@ export default defineComponent({
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.name)
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
</template>
|
||||
<template #footer>
|
||||
<span>
|
||||
<ButtonPrimary :label="$t('action.save')" @click.native="editFolder" />
|
||||
<ButtonPrimary
|
||||
:label="$t('action.save')"
|
||||
:loading="loadingState"
|
||||
@click.native="editFolder"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="$t('action.cancel')"
|
||||
@click.native="hideModal"
|
||||
@@ -41,6 +45,7 @@ export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingFolderName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -59,7 +64,6 @@ export default defineComponent({
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.name)
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
</template>
|
||||
<template #footer>
|
||||
<span>
|
||||
<ButtonPrimary :label="$t('action.save')" @click.native="saveRequest" />
|
||||
<ButtonPrimary
|
||||
:label="$t('action.save')"
|
||||
:loading="loadingState"
|
||||
@click.native="saveRequest"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="$t('action.cancel')"
|
||||
@click.native="hideModal"
|
||||
@@ -41,6 +45,7 @@ export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingRequestName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -61,7 +66,6 @@ export default defineComponent({
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.requestUpdateData)
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.requestUpdateData = { name: null }
|
||||
|
||||
@@ -233,6 +233,7 @@ const saveRequestAs = async () => {
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: picked.value.requestIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
@@ -249,6 +250,7 @@ const saveRequestAs = async () => {
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: insertionIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
@@ -265,6 +267,7 @@ const saveRequestAs = async () => {
|
||||
originLocation: "user-collection",
|
||||
folderPath: `${picked.value.collectionIndex}`,
|
||||
requestIndex: insertionIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
@@ -293,6 +296,7 @@ const saveRequestAs = async () => {
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: picked.value.requestID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
} else if (picked.value.pickedType === "teams-folder") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
@@ -319,6 +323,7 @@ const saveRequestAs = async () => {
|
||||
requestID: result.right.createRequestInCollection.id,
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
collectionID: picked.value.folderID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
@@ -348,6 +353,7 @@ const saveRequestAs = async () => {
|
||||
requestID: result.right.createRequestInCollection.id,
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
collectionID: picked.value.collectionID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
|
||||
@@ -50,12 +50,16 @@ export default defineComponent({
|
||||
},
|
||||
methods: {
|
||||
addFolder() {
|
||||
// TODO: Blocking when name is null ?
|
||||
if (!this.name) {
|
||||
this.$toast.error(`${this.$t("folder.name_length_insufficient")}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit("add-folder", {
|
||||
name: this.name,
|
||||
path: this.folderPath || `${this.collectionIndex}`,
|
||||
})
|
||||
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
|
||||
@@ -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>
|
||||
@@ -29,6 +29,17 @@
|
||||
</span>
|
||||
</span>
|
||||
<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
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
svg="folder-plus"
|
||||
@@ -61,11 +72,26 @@
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@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
|
||||
ref="folderAction"
|
||||
svg="folder-plus"
|
||||
@@ -126,6 +152,7 @@
|
||||
:collection-index="collectionIndex"
|
||||
:doc="doc"
|
||||
:is-filtered="isFiltered"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@@ -196,6 +223,7 @@ export default defineComponent({
|
||||
return {
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
</span>
|
||||
</span>
|
||||
<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
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
svg="folder-plus"
|
||||
@@ -57,11 +64,24 @@
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@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
|
||||
ref="folderAction"
|
||||
svg="folder-plus"
|
||||
@@ -120,6 +140,7 @@
|
||||
:collection-index="collectionIndex"
|
||||
:doc="doc"
|
||||
:is-filtered="isFiltered"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@@ -193,6 +214,7 @@ export default defineComponent({
|
||||
return {
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
:is-filtered="filterText.length > 0"
|
||||
:saving-mode="savingMode"
|
||||
@edit-collection="editCollection(collection, index)"
|
||||
@add-request="addRequest($event)"
|
||||
@add-folder="addFolder($event)"
|
||||
@edit-folder="editFolder($event)"
|
||||
@edit-request="editRequest($event)"
|
||||
@@ -96,6 +97,12 @@
|
||||
:editing-collection-name="editingCollection ? editingCollection.name : ''"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
/>
|
||||
<CollectionsGraphqlAddRequest
|
||||
:show="showModalAddRequest"
|
||||
:folder-path="editingFolderPath"
|
||||
@add-request="onAddRequest($event)"
|
||||
@hide-modal="displayModalAddRequest(false)"
|
||||
/>
|
||||
<CollectionsGraphqlAddFolder
|
||||
:show="showModalAddFolder"
|
||||
:folder-path="editingFolderPath"
|
||||
@@ -136,6 +143,7 @@ import {
|
||||
addGraphqlFolder,
|
||||
saveGraphqlRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -156,6 +164,7 @@ export default defineComponent({
|
||||
showModalAdd: false,
|
||||
showModalEdit: false,
|
||||
showModalImportExport: false,
|
||||
showModalAddRequest: false,
|
||||
showModalAddFolder: false,
|
||||
showModalEditFolder: false,
|
||||
showModalEditRequest: false,
|
||||
@@ -222,6 +231,11 @@ export default defineComponent({
|
||||
displayModalImportExport(shouldDisplay) {
|
||||
this.showModalImportExport = shouldDisplay
|
||||
},
|
||||
displayModalAddRequest(shouldDisplay) {
|
||||
this.showModalAddRequest = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalAddFolder(shouldDisplay) {
|
||||
this.showModalAddFolder = shouldDisplay
|
||||
|
||||
@@ -242,6 +256,26 @@ export default defineComponent({
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
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 }) {
|
||||
addGraphqlFolder(name, path)
|
||||
this.displayModalAddFolder(false)
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
:picked="picked"
|
||||
:loading-collection-i-ds="loadingCollectionIDs"
|
||||
@edit-collection="editCollection(collection, index)"
|
||||
@add-request="addRequest($event)"
|
||||
@add-folder="addFolder($event)"
|
||||
@edit-folder="editFolder($event)"
|
||||
@edit-request="editRequest($event)"
|
||||
@@ -93,6 +94,7 @@
|
||||
@expand-collection="expandCollection"
|
||||
@remove-collection="removeCollection"
|
||||
@remove-request="removeRequest"
|
||||
@remove-folder="removeFolder"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -146,6 +148,7 @@
|
||||
</div>
|
||||
<CollectionsAdd
|
||||
:show="showModalAdd"
|
||||
:loading-state="modalLoadingState"
|
||||
@submit="addNewRootCollection"
|
||||
@hide-modal="displayModalAdd(false)"
|
||||
/>
|
||||
@@ -156,13 +159,23 @@
|
||||
? editingCollection.name || editingCollection.title
|
||||
: ''
|
||||
"
|
||||
:loading-state="modalLoadingState"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
@submit="updateEditingCollection"
|
||||
/>
|
||||
<CollectionsAddRequest
|
||||
:show="showModalAddRequest"
|
||||
:folder="editingFolder"
|
||||
:folder-path="editingFolderPath"
|
||||
:loading-state="modalLoadingState"
|
||||
@add-request="onAddRequest($event)"
|
||||
@hide-modal="displayModalAddRequest(false)"
|
||||
/>
|
||||
<CollectionsAddFolder
|
||||
:show="showModalAddFolder"
|
||||
:folder="editingFolder"
|
||||
:folder-path="editingFolderPath"
|
||||
:loading-state="modalLoadingState"
|
||||
@add-folder="onAddFolder($event)"
|
||||
@hide-modal="displayModalAddFolder(false)"
|
||||
/>
|
||||
@@ -171,12 +184,14 @@
|
||||
:editing-folder-name="
|
||||
editingFolder ? editingFolder.name || editingFolder.title : ''
|
||||
"
|
||||
:loading-state="modalLoadingState"
|
||||
@submit="updateEditingFolder"
|
||||
@hide-modal="displayModalEditFolder(false)"
|
||||
/>
|
||||
<CollectionsEditRequest
|
||||
:show="showModalEditRequest"
|
||||
:editing-request-name="editingRequest ? editingRequest.name : ''"
|
||||
:loading-state="modalLoadingState"
|
||||
@submit="updateEditingRequest"
|
||||
@hide-modal="displayModalEditRequest(false)"
|
||||
/>
|
||||
@@ -186,6 +201,13 @@
|
||||
@hide-modal="displayModalImportExport(false)"
|
||||
@update-team-collections="updateTeamCollections"
|
||||
/>
|
||||
<SmartConfirmModal
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalTitle"
|
||||
:loading-state="modalLoadingState"
|
||||
@hide-modal="showConfirmModal = false"
|
||||
@resolve="resolveConfirmModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -204,11 +226,17 @@ import {
|
||||
editRESTCollection,
|
||||
addRESTFolder,
|
||||
removeRESTCollection,
|
||||
removeRESTFolder,
|
||||
editRESTFolder,
|
||||
removeRESTRequest,
|
||||
editRESTRequest,
|
||||
saveRESTRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import {
|
||||
setRESTRequest,
|
||||
getRESTRequest,
|
||||
getRESTSaveContext,
|
||||
} from "~/newstore/RESTSession"
|
||||
import {
|
||||
useReadonlyStream,
|
||||
useStreamSubscriber,
|
||||
@@ -250,17 +278,22 @@ export default defineComponent({
|
||||
showModalAdd: false,
|
||||
showModalEdit: false,
|
||||
showModalImportExport: false,
|
||||
showModalAddRequest: false,
|
||||
showModalAddFolder: false,
|
||||
showModalEditFolder: false,
|
||||
showModalEditRequest: false,
|
||||
showConfirmModal: false,
|
||||
modalLoadingState: false,
|
||||
editingCollection: undefined,
|
||||
editingCollectionIndex: undefined,
|
||||
editingCollectionID: undefined,
|
||||
editingFolder: undefined,
|
||||
editingFolderName: undefined,
|
||||
editingFolderIndex: undefined,
|
||||
editingFolderPath: undefined,
|
||||
editingRequest: undefined,
|
||||
editingRequestIndex: undefined,
|
||||
confirmModalTitle: undefined,
|
||||
filterText: "",
|
||||
collectionsType: {
|
||||
type: "my-collections",
|
||||
@@ -375,14 +408,18 @@ export default defineComponent({
|
||||
requests: [],
|
||||
})
|
||||
)
|
||||
|
||||
this.displayModalAdd(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
runMutation(CreateNewRootCollectionDocument, {
|
||||
title: name,
|
||||
teamID: this.collectionsType.selectedTeam.id,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
this.$toast.error(this.$t("collection.name_length_insufficient"))
|
||||
@@ -390,10 +427,10 @@ export default defineComponent({
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.$toast.success(this.$t("collection.created"))
|
||||
this.displayModalAdd(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
this.displayModalAdd(false)
|
||||
},
|
||||
// Intented to be called by CollectionEdit modal submit event
|
||||
updateEditingCollection(newName) {
|
||||
@@ -408,66 +445,107 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
editRESTCollection(this.editingCollectionIndex, collectionUpdated)
|
||||
this.displayModalEdit(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
|
||||
runMutation(RenameCollectionDocument, {
|
||||
collectionID: this.editingCollection.id,
|
||||
newTitle: newName,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.$toast.error(this.$t("error.something_went_wrong"))
|
||||
console.error(e)
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.$toast.success(this.$t("collection.renamed"))
|
||||
this.displayModalEdit(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
this.displayModalEdit(false)
|
||||
},
|
||||
// Intended to be called by CollectionEditFolder modal submit event
|
||||
updateEditingFolder(name) {
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
editRESTFolder(this.editingFolderPath, { ...this.editingFolder, name })
|
||||
this.displayModalEditFolder(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
|
||||
runMutation(RenameCollectionDocument, {
|
||||
collectionID: this.editingFolder.id,
|
||||
newTitle: name,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.$toast.error(this.$t("error.something_went_wrong"))
|
||||
console.error(e)
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
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 {
|
||||
this.$toast.success(this.$t("folder.renamed"))
|
||||
this.displayModalEditFolder(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.displayModalEditFolder(false)
|
||||
},
|
||||
// Intented to by called by CollectionsEditRequest modal submit event
|
||||
updateEditingRequest(requestUpdateData) {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
|
||||
const requestUpdated = {
|
||||
...this.editingRequest,
|
||||
name: requestUpdateData.name || this.editingRequest.name,
|
||||
}
|
||||
|
||||
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(
|
||||
this.editingFolderPath,
|
||||
this.editingRequestIndex,
|
||||
requestUpdated
|
||||
)
|
||||
this.displayModalEditRequest(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
|
||||
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, {
|
||||
data: {
|
||||
request: JSON.stringify(requestUpdated),
|
||||
@@ -475,17 +553,18 @@ export default defineComponent({
|
||||
},
|
||||
requestID: this.editingRequestIndex,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.$toast.error(this.$t("error.something_went_wrong"))
|
||||
console.error(e)
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.$toast.success(this.$t("request.renamed"))
|
||||
this.$emit("update-team-collections")
|
||||
this.displayModalEditRequest(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.displayModalEditRequest(false)
|
||||
},
|
||||
displayModalAdd(shouldDisplay) {
|
||||
this.showModalAdd = shouldDisplay
|
||||
@@ -498,6 +577,11 @@ export default defineComponent({
|
||||
displayModalImportExport(shouldDisplay) {
|
||||
this.showModalImportExport = shouldDisplay
|
||||
},
|
||||
displayModalAddRequest(shouldDisplay) {
|
||||
this.showModalAddRequest = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalAddFolder(shouldDisplay) {
|
||||
this.showModalAddFolder = shouldDisplay
|
||||
|
||||
@@ -513,6 +597,11 @@ export default defineComponent({
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayConfirmModal(shouldDisplay) {
|
||||
this.showConfirmModal = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
editCollection(collection, collectionIndex) {
|
||||
this.$data.editingCollection = collection
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
@@ -521,26 +610,29 @@ export default defineComponent({
|
||||
onAddFolder({ name, folder, path }) {
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
addRESTFolder(name, path)
|
||||
} else if (this.collectionsType.type === "team-collections") {
|
||||
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") {
|
||||
runMutation(CreateChildCollectionDocument, {
|
||||
childTitle: name,
|
||||
collectionID: folder.id,
|
||||
})().then((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
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 {
|
||||
this.$toast.success(this.$t("folder.created"))
|
||||
this.$emit("update-team-collections")
|
||||
}
|
||||
})
|
||||
}
|
||||
this.displayModalAddFolder(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
runMutation(CreateChildCollectionDocument, {
|
||||
childTitle: name,
|
||||
collectionID: folder.id,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
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 {
|
||||
this.$toast.success(this.$t("folder.created"))
|
||||
this.displayModalAddFolder(false)
|
||||
this.$emit("update-team-collections")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.displayModalAddFolder(false)
|
||||
},
|
||||
addFolder(payload) {
|
||||
const { folder, path } = payload
|
||||
@@ -578,16 +670,30 @@ export default defineComponent({
|
||||
resetSelectedData() {
|
||||
this.$data.editingCollection = undefined
|
||||
this.$data.editingCollectionIndex = undefined
|
||||
this.$data.editingCollectionID = undefined
|
||||
this.$data.editingFolder = undefined
|
||||
this.$data.editingFolderPath = undefined
|
||||
this.$data.editingFolderIndex = undefined
|
||||
this.$data.editingRequest = undefined
|
||||
this.$data.editingRequestIndex = undefined
|
||||
|
||||
this.$data.confirmModalTitle = undefined
|
||||
},
|
||||
expandCollection(collectionID) {
|
||||
this.teamCollectionAdapter.expandCollection(collectionID)
|
||||
},
|
||||
removeCollection({ collectionsType, collectionIndex, collectionID }) {
|
||||
if (collectionsType.type === "my-collections") {
|
||||
removeCollection({ collectionIndex, collectionID }) {
|
||||
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
|
||||
if (
|
||||
this.picked &&
|
||||
@@ -598,8 +704,12 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
removeRESTCollection(collectionIndex)
|
||||
|
||||
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
|
||||
if (
|
||||
this.picked &&
|
||||
@@ -609,21 +719,89 @@ export default defineComponent({
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
|
||||
if (collectionsType.selectedTeam.myRole !== "VIEWER") {
|
||||
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") {
|
||||
runMutation(DeleteCollectionDocument, {
|
||||
collectionID,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
this.$toast.error(this.$t("error.something_went_wrong"))
|
||||
console.error(e)
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
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 }) {
|
||||
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") {
|
||||
// Cancel pick if the picked item is being deleted
|
||||
if (
|
||||
@@ -635,8 +813,11 @@ export default defineComponent({
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
removeRESTRequest(folderPath, requestIndex)
|
||||
|
||||
this.$toast.success(this.$t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
} else if (this.collectionsType.type === "team-collections") {
|
||||
this.modalLoadingState = true
|
||||
// Cancel pick if the picked item is being deleted
|
||||
if (
|
||||
this.picked &&
|
||||
@@ -649,11 +830,68 @@ export default defineComponent({
|
||||
runMutation(DeleteRequestDocument, {
|
||||
requestID: requestIndex,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
this.$toast.error(this.$t("error.something_went_wrong"))
|
||||
console.error(e)
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
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>
|
||||
|
||||
@@ -45,6 +45,18 @@
|
||||
color="green"
|
||||
@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
|
||||
v-if="!doc"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
@@ -79,12 +91,27 @@
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@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
|
||||
ref="folderAction"
|
||||
svg="folder-plus"
|
||||
@@ -131,7 +158,7 @@
|
||||
:shortcut="['⌫']"
|
||||
@click.native="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
removeCollection()
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
@@ -159,12 +186,14 @@
|
||||
:collections-type="collectionsType"
|
||||
:is-filtered="isFiltered"
|
||||
:picked="picked"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
/>
|
||||
<CollectionsMyRequest
|
||||
v-for="(request, index) in collection.requests"
|
||||
@@ -205,12 +234,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="$t('confirm.remove_collection')"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeCollection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -233,6 +256,7 @@ export default defineComponent({
|
||||
return {
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
@@ -244,7 +268,6 @@ export default defineComponent({
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
selectedFolder: {},
|
||||
confirmRemove: false,
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
pageNo: 0,
|
||||
@@ -297,7 +320,6 @@ export default defineComponent({
|
||||
},
|
||||
removeCollection() {
|
||||
this.$emit("remove-collection", {
|
||||
collectionsType: this.collectionsType,
|
||||
collectionIndex: this.collectionIndex,
|
||||
collectionID: this.collection.id,
|
||||
})
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
</span>
|
||||
</span>
|
||||
<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
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
svg="folder-plus"
|
||||
@@ -57,12 +64,25 @@
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@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
|
||||
ref="folderAction"
|
||||
svg="folder-plus"
|
||||
@@ -111,7 +131,7 @@
|
||||
:shortcut="['⌫']"
|
||||
@click.native="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
removeFolder()
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
@@ -138,13 +158,15 @@
|
||||
:collections-type="collectionsType"
|
||||
:folder-path="`${folderPath}/${subFolderIndex}`"
|
||||
:picked="picked"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@update-team-collections="$emit('update-team-collections')"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="removeRequest"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
/>
|
||||
<CollectionsMyRequest
|
||||
v-for="(request, index) in folder.requests"
|
||||
@@ -162,7 +184,7 @@
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="removeRequest"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
@@ -185,23 +207,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="t('confirm.remove_folder')"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeFolder"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api"
|
||||
import { useI18n } from "~/helpers/utils/composables"
|
||||
import {
|
||||
removeRESTFolder,
|
||||
removeRESTRequest,
|
||||
moveRESTRequest,
|
||||
} from "~/newstore/collections"
|
||||
import { moveRESTRequest } from "~/newstore/collections"
|
||||
|
||||
export default defineComponent({
|
||||
name: "Folder",
|
||||
@@ -222,6 +234,7 @@ export default defineComponent({
|
||||
return {
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
@@ -233,7 +246,6 @@ export default defineComponent({
|
||||
return {
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
confirmRemove: false,
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
}
|
||||
@@ -284,17 +296,10 @@ export default defineComponent({
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeFolder() {
|
||||
// TODO: Bubble it up ?
|
||||
// Cancel pick if picked folder was deleted
|
||||
if (
|
||||
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")}`)
|
||||
this.$emit("remove-folder", {
|
||||
folder: this.folder,
|
||||
folderPath: this.folderPath,
|
||||
})
|
||||
},
|
||||
dropEvent({ dataTransfer }) {
|
||||
this.dragging = !this.dragging
|
||||
@@ -302,19 +307,6 @@ export default defineComponent({
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
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>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
:shortcut="['⌫']"
|
||||
@click.native="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
removeRequest()
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
@@ -136,12 +136,6 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="t('confirm.remove_request')"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeRequest"
|
||||
/>
|
||||
<HttpReqChangeConfirmModal
|
||||
:show="confirmChange"
|
||||
@hide-modal="confirmChange = false"
|
||||
@@ -165,6 +159,7 @@ import {
|
||||
isEqualHoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import * as E from "fp-ts/Either"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
import {
|
||||
useI18n,
|
||||
useToast,
|
||||
@@ -221,8 +216,6 @@ const emit = defineEmits<{
|
||||
(
|
||||
e: "remove-request",
|
||||
data: {
|
||||
collectionIndex: number
|
||||
folderName: string
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
@@ -264,7 +257,6 @@ const requestMethodLabels = {
|
||||
delete: "text-red-500",
|
||||
default: "text-gray-500",
|
||||
}
|
||||
const confirmRemove = ref(false)
|
||||
const confirmChange = ref(false)
|
||||
const showSaveRequestModal = ref(false)
|
||||
|
||||
@@ -303,8 +295,6 @@ const dragStart = ({ dataTransfer }: DragEvent) => {
|
||||
|
||||
const removeRequest = () => {
|
||||
emit("remove-request", {
|
||||
collectionIndex: props.collectionIndex,
|
||||
folderName: props.folderName,
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
})
|
||||
@@ -317,15 +307,17 @@ const getRequestLabelColor = (method: string) =>
|
||||
|
||||
const setRestReq = (request: any) => {
|
||||
setRESTRequest(
|
||||
safelyExtractRESTRequest(
|
||||
translateToNewRequest(request),
|
||||
getDefaultRESTRequest()
|
||||
cloneDeep(
|
||||
safelyExtractRESTRequest(
|
||||
translateToNewRequest(request),
|
||||
getDefaultRESTRequest()
|
||||
)
|
||||
),
|
||||
{
|
||||
originLocation: "user-collection",
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
req: request,
|
||||
req: cloneDeep(request),
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -397,7 +389,7 @@ const discardRequestChange = () => {
|
||||
originLocation: "user-collection",
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
req: props.request,
|
||||
req: cloneDeep(props.request),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,19 @@
|
||||
color="green"
|
||||
@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
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
@@ -80,12 +93,28 @@
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@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
|
||||
ref="folderAction"
|
||||
svg="folder-plus"
|
||||
@@ -128,7 +157,7 @@
|
||||
:shortcut="['⌫']"
|
||||
@click.native="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
removeCollection()
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
@@ -157,12 +186,14 @@
|
||||
:is-filtered="isFiltered"
|
||||
:picked="picked"
|
||||
:loading-collection-i-ds="loadingCollectionIDs"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@expand-collection="expandCollection"
|
||||
@remove-request="removeRequest"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<CollectionsTeamsRequest
|
||||
@@ -180,7 +211,7 @@
|
||||
:picked="picked"
|
||||
@edit-request="editRequest($event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="removeRequest"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<div
|
||||
@@ -211,12 +242,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="t('confirm.remove_collection')"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeCollection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -248,6 +273,7 @@ export default defineComponent({
|
||||
return {
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
@@ -261,7 +287,6 @@ export default defineComponent({
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
selectedFolder: {},
|
||||
confirmRemove: false,
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
pageNo: 0,
|
||||
@@ -344,7 +369,6 @@ export default defineComponent({
|
||||
},
|
||||
removeCollection() {
|
||||
this.$emit("remove-collection", {
|
||||
collectionsType: this.collectionsType,
|
||||
collectionIndex: this.collectionIndex,
|
||||
collectionID: this.collection.id,
|
||||
})
|
||||
@@ -362,13 +386,6 @@ export default defineComponent({
|
||||
if (E.isLeft(moveRequestResult))
|
||||
this.$toast.error(`${this.$t("error.something_went_wrong")}`)
|
||||
},
|
||||
removeRequest({ collectionIndex, folderName, requestIndex }: any) {
|
||||
this.$emit("remove-request", {
|
||||
collectionIndex,
|
||||
folderName,
|
||||
requestIndex,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
</span>
|
||||
</span>
|
||||
<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
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
@@ -59,12 +67,25 @@
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@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
|
||||
ref="folderAction"
|
||||
svg="folder-plus"
|
||||
@@ -109,7 +130,7 @@
|
||||
:shortcut="['⌫']"
|
||||
@click.native="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
removeFolder()
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
@@ -137,13 +158,15 @@
|
||||
:folder-path="`${folderPath}/${subFolderIndex}`"
|
||||
:picked="picked"
|
||||
:loading-collection-i-ds="loadingCollectionIDs"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@update-team-collections="$emit('update-team-collections')"
|
||||
@select="$emit('select', $event)"
|
||||
@expand-collection="expandCollection"
|
||||
@remove-request="removeRequest"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<CollectionsTeamsRequest
|
||||
@@ -161,7 +184,7 @@
|
||||
:collection-i-d="folder.id"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="removeRequest"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<div
|
||||
@@ -190,20 +213,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="$t('confirm.remove_folder')"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeFolder"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import { DeleteCollectionDocument } from "~/helpers/backend/graphql"
|
||||
import {
|
||||
getCompleteCollectionTree,
|
||||
teamCollToHoppRESTColl,
|
||||
@@ -228,6 +243,7 @@ export default defineComponent({
|
||||
return {
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
@@ -239,7 +255,6 @@ export default defineComponent({
|
||||
return {
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
confirmRemove: false,
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
}
|
||||
@@ -310,30 +325,10 @@ export default defineComponent({
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeFolder() {
|
||||
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") {
|
||||
// Cancel pick if picked collection folder was deleted
|
||||
if (
|
||||
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")
|
||||
}
|
||||
this.$emit("remove-folder", {
|
||||
collectionsType: this.collectionsType,
|
||||
folder: this.folder,
|
||||
})
|
||||
},
|
||||
expandCollection(collectionID: number) {
|
||||
this.$emit("expand-collection", collectionID)
|
||||
@@ -348,13 +343,6 @@ export default defineComponent({
|
||||
if (E.isLeft(moveRequestResult))
|
||||
this.$toast.error(`${this.$t("error.something_went_wrong")}`)
|
||||
},
|
||||
removeRequest({ collectionIndex, folderName, requestIndex }: any) {
|
||||
this.$emit("remove-request", {
|
||||
collectionIndex,
|
||||
folderName,
|
||||
requestIndex,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
:shortcut="['⌫']"
|
||||
@click.native="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
removeRequest()
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
@@ -133,12 +133,6 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="$t('confirm.remove_request')"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeRequest"
|
||||
/>
|
||||
<HttpReqChangeConfirmModal
|
||||
:show="confirmChange"
|
||||
@hide-modal="confirmChange = false"
|
||||
@@ -215,8 +209,7 @@ const emit = defineEmits<{
|
||||
(
|
||||
e: "remove-request",
|
||||
data: {
|
||||
collectionIndex: number
|
||||
folderName: string | undefined
|
||||
folderPath: string | undefined
|
||||
requestIndex: string
|
||||
}
|
||||
): void
|
||||
@@ -253,7 +246,6 @@ const requestMethodLabels = {
|
||||
delete: "text-red-500",
|
||||
default: "text-gray-500",
|
||||
}
|
||||
const confirmRemove = ref(false)
|
||||
const confirmChange = ref(false)
|
||||
const showSaveRequestModal = ref(false)
|
||||
|
||||
@@ -289,8 +281,7 @@ const dragStart = ({ dataTransfer }: DragEvent) => {
|
||||
|
||||
const removeRequest = () => {
|
||||
emit("remove-request", {
|
||||
collectionIndex: props.collectionIndex,
|
||||
folderName: props.folderName,
|
||||
folderPath: props.folderName,
|
||||
requestIndex: props.requestIndex,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="$t(`environment.${action}`)"
|
||||
:title="t(`environment.${action}`)"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
@@ -20,24 +20,24 @@
|
||||
@keyup.enter="saveEnvironment"
|
||||
/>
|
||||
<label for="selectLabelEnvEdit">
|
||||
{{ $t("action.label") }}
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<label for="variableList" class="p-4">
|
||||
{{ $t("environment.variable_list") }}
|
||||
{{ t("environment.variable_list") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t('action.clear_all')"
|
||||
:title="t('action.clear_all')"
|
||||
:svg="clearIcon"
|
||||
@click.native="clearContent()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
svg="plus"
|
||||
:title="$t('add.new')"
|
||||
:title="t('add.new')"
|
||||
@click.native="addEnvironmentVariable"
|
||||
/>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@
|
||||
v-if="evnExpandError"
|
||||
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 class="border rounded divide-y divide-dividerLight border-divider">
|
||||
<div
|
||||
@@ -57,12 +57,12 @@
|
||||
<input
|
||||
v-model="variable.key"
|
||||
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"
|
||||
/>
|
||||
<SmartEnvInput
|
||||
v-model="variable.value"
|
||||
:placeholder="`${$t('count.value', { count: index + 1 })}`"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
:envs="liveEnvs"
|
||||
:name="'value' + index"
|
||||
/>
|
||||
@@ -70,7 +70,7 @@
|
||||
<ButtonSecondary
|
||||
id="variable"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t('action.remove')"
|
||||
:title="t('action.remove')"
|
||||
svg="trash"
|
||||
color="red"
|
||||
@click.native="removeEnvironmentVariable(index)"
|
||||
@@ -85,13 +85,13 @@
|
||||
:src="`/images/states/${$colorMode.value}/blockchain.svg`"
|
||||
loading="lazy"
|
||||
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">
|
||||
{{ $t("empty.environments") }}
|
||||
{{ t("empty.environments") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="`${$t('add.new')}`"
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
class="mb-4"
|
||||
@click.native="addEnvironmentVariable"
|
||||
@@ -103,11 +103,11 @@
|
||||
<template #footer>
|
||||
<span>
|
||||
<ButtonPrimary
|
||||
:label="`${$t('action.save')}`"
|
||||
:label="`${t('action.save')}`"
|
||||
@click.native="saveEnvironment"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${$t('action.cancel')}`"
|
||||
:label="`${t('action.cancel')}`"
|
||||
@click.native="hideModal"
|
||||
/>
|
||||
</span>
|
||||
@@ -115,9 +115,9 @@
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
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 { Environment, parseTemplateStringE } from "@hoppscotch/data"
|
||||
import {
|
||||
@@ -130,136 +130,147 @@ import {
|
||||
setGlobalEnvVariables,
|
||||
updateEnvironment,
|
||||
} from "~/newstore/environments"
|
||||
import { useReadonlyStream } from "~/helpers/utils/composables"
|
||||
import {
|
||||
useReadonlyStream,
|
||||
useI18n,
|
||||
useToast,
|
||||
} from "~/helpers/utils/composables"
|
||||
|
||||
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 t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const workingEnv = computed(() => {
|
||||
if (props.editingEnvironmentIndex === "Global") {
|
||||
return {
|
||||
name: "Global",
|
||||
variables: getGlobalVariables(),
|
||||
} as Environment
|
||||
} else if (props.action === "new") {
|
||||
return {
|
||||
name: "",
|
||||
variables: props.envVars(),
|
||||
}
|
||||
} else if (props.editingEnvironmentIndex !== null) {
|
||||
return getEnviroment(props.editingEnvironmentIndex)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
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")
|
||||
|
||||
const globalVars = useReadonlyStream(globalEnv$, [])
|
||||
|
||||
const workingEnv = computed(() => {
|
||||
if (props.editingEnvironmentIndex === "Global") {
|
||||
return {
|
||||
globalVars,
|
||||
workingEnv,
|
||||
envList: useReadonlyStream(environments$, []) || props.envVars(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
name: "Global",
|
||||
variables: getGlobalVariables(),
|
||||
} as Environment
|
||||
} else if (props.action === "new") {
|
||||
return {
|
||||
name: null as string | null,
|
||||
vars: [] as { key: string; value: string }[],
|
||||
clearIcon: "trash-2",
|
||||
name: "",
|
||||
variables: props.envVars(),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
evnExpandError(): boolean {
|
||||
for (const variable of this.vars) {
|
||||
const result = parseTemplateStringE(variable.value, this.vars)
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
console.error("error", result.left)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
liveEnvs(): Array<{ key: string; value: string; source: string }> {
|
||||
if (this.evnExpandError) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (this.$props.editingEnvironmentIndex === "Global") {
|
||||
return [...this.vars.map((x) => ({ ...x, source: this.name! }))]
|
||||
} else {
|
||||
return [
|
||||
...this.vars.map((x) => ({ ...x, source: this.name! })),
|
||||
...this.globalVars.map((x) => ({ ...x, source: "Global" })),
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
show() {
|
||||
this.name = this.workingEnv?.name ?? null
|
||||
this.vars = clone(this.workingEnv?.variables ?? [])
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
clearContent() {
|
||||
this.vars = []
|
||||
this.clearIcon = "check"
|
||||
this.$toast.success(`${this.$t("state.cleared")}`)
|
||||
setTimeout(() => (this.clearIcon = "trash-2"), 1000)
|
||||
},
|
||||
addEnvironmentVariable() {
|
||||
this.vars.push({
|
||||
key: "",
|
||||
value: "",
|
||||
})
|
||||
},
|
||||
removeEnvironmentVariable(index: number) {
|
||||
this.vars.splice(index, 1)
|
||||
},
|
||||
saveEnvironment() {
|
||||
if (!this.name) {
|
||||
this.$toast.error(`${this.$t("environment.invalid_name")}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.action === "new") {
|
||||
createEnvironment(this.name)
|
||||
setCurrentEnvironment(this.envList.length - 1)
|
||||
}
|
||||
|
||||
const environmentUpdated: Environment = {
|
||||
name: this.name,
|
||||
variables: this.vars,
|
||||
}
|
||||
|
||||
if (this.editingEnvironmentIndex === "Global")
|
||||
setGlobalEnvVariables(environmentUpdated.variables)
|
||||
else if (this.action === "new") {
|
||||
updateEnvironment(this.envList.length - 1, environmentUpdated)
|
||||
} else {
|
||||
updateEnvironment(this.editingEnvironmentIndex!, environmentUpdated)
|
||||
}
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
} else if (props.editingEnvironmentIndex !== null) {
|
||||
return getEnviroment(props.editingEnvironmentIndex)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const envList = useReadonlyStream(environments$, []) || props.envVars()
|
||||
|
||||
const evnExpandError = computed(() => {
|
||||
for (const variable of vars.value) {
|
||||
const result = parseTemplateStringE(variable.value.toString(), vars.value)
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
console.error("error", result.left)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const liveEnvs = computed(() => {
|
||||
if (evnExpandError) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (props.editingEnvironmentIndex === "Global") {
|
||||
return [...vars.value.map((x) => ({ ...x, source: name.value! }))]
|
||||
} else {
|
||||
return [
|
||||
...vars.value.map((x) => ({ ...x, source: name.value! })),
|
||||
...globalVars.value.map((x) => ({ ...x, source: "Global" })),
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
name.value = workingEnv.value?.name ?? null
|
||||
vars.value = clone(workingEnv.value?.variables ?? [])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const clearContent = () => {
|
||||
vars.value = []
|
||||
clearIcon.value = "check"
|
||||
toast.success(`${t("state.cleared")}`)
|
||||
setTimeout(() => (clearIcon.value = "trash-2"), 1000)
|
||||
}
|
||||
|
||||
const addEnvironmentVariable = () => {
|
||||
vars.value.push({
|
||||
key: "",
|
||||
value: "",
|
||||
})
|
||||
}
|
||||
|
||||
const removeEnvironmentVariable = (index: number) => {
|
||||
vars.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveEnvironment = () => {
|
||||
if (!name.value) {
|
||||
toast.error(`${t("environment.invalid_name")}`)
|
||||
return
|
||||
}
|
||||
|
||||
const environmentUpdated: Environment = {
|
||||
name: name.value,
|
||||
variables: vars.value,
|
||||
}
|
||||
|
||||
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)
|
||||
toast.success(`${t("environment.updated")}`)
|
||||
} else if (props.editingEnvironmentIndex !== null) {
|
||||
// Editing an environment
|
||||
updateEnvironment(props.editingEnvironmentIndex, environmentUpdated)
|
||||
toast.success(`${t("environment.updated")}`)
|
||||
}
|
||||
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
name.value = null
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="$emit('edit-environment')"
|
||||
@click="emit('edit-environment')"
|
||||
>
|
||||
<SmartIcon class="svg-icons" name="layers" />
|
||||
</span>
|
||||
<span
|
||||
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">
|
||||
{{ environment.name }}
|
||||
@@ -29,7 +29,7 @@
|
||||
<template #trigger>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t('action.more')"
|
||||
:title="t('action.more')"
|
||||
svg="more-vertical"
|
||||
/>
|
||||
</template>
|
||||
@@ -48,11 +48,11 @@
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
svg="edit"
|
||||
:label="`${$t('action.edit')}`"
|
||||
:label="`${t('action.edit')}`"
|
||||
:shortcut="['E']"
|
||||
@click.native="
|
||||
() => {
|
||||
$emit('edit-environment')
|
||||
emit('edit-environment')
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
@@ -60,11 +60,11 @@
|
||||
<SmartItem
|
||||
ref="duplicate"
|
||||
svg="copy"
|
||||
:label="`${$t('action.duplicate')}`"
|
||||
:label="`${t('action.duplicate')}`"
|
||||
:shortcut="['D']"
|
||||
@click.native="
|
||||
() => {
|
||||
duplicateEnvironment()
|
||||
duplicateEnvironments()
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
@@ -73,7 +73,7 @@
|
||||
v-if="!(environmentIndex === 'Global')"
|
||||
ref="deleteAction"
|
||||
svg="trash-2"
|
||||
:label="`${$t('action.delete')}`"
|
||||
:label="`${t('action.delete')}`"
|
||||
:shortcut="['⌫']"
|
||||
@click.native="
|
||||
() => {
|
||||
@@ -87,15 +87,17 @@
|
||||
</span>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="`${$t('confirm.remove_environment')}`"
|
||||
:title="`${t('confirm.remove_environment')}`"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeEnvironment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref } from "@nuxtjs/composition-api"
|
||||
<script setup lang="ts">
|
||||
import { ref } from "@nuxtjs/composition-api"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
import {
|
||||
deleteEnvironment,
|
||||
duplicateEnvironment,
|
||||
@@ -104,47 +106,43 @@ import {
|
||||
getGlobalVariables,
|
||||
environmentsStore,
|
||||
} from "~/newstore/environments"
|
||||
import { useI18n, useToast } from "~/helpers/utils/composables"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
environment: { type: Object, default: () => {} },
|
||||
environmentIndex: {
|
||||
type: [Number, String] as PropType<number | "Global">,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
duplicate: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
confirmRemove: false,
|
||||
}
|
||||
},
|
||||
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(
|
||||
environmentsStore.value.environments.length - 1,
|
||||
getGlobalVariables().reduce((gVars, gVar) => {
|
||||
gVars.push({ key: gVar.key, value: gVar.value })
|
||||
return gVars
|
||||
}, [])
|
||||
)
|
||||
} else duplicateEnvironment(this.environmentIndex)
|
||||
},
|
||||
},
|
||||
})
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
environment: Environment
|
||||
environmentIndex: number | "Global" | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "edit-environment"): void
|
||||
}>()
|
||||
|
||||
const confirmRemove = ref(false)
|
||||
|
||||
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")}`)
|
||||
}
|
||||
|
||||
const duplicateEnvironments = () => {
|
||||
if (props.environmentIndex === null) return
|
||||
if (props.environmentIndex === "Global") {
|
||||
createEnvironment(`Global - ${t("action.duplicate")}`)
|
||||
setEnvironmentVariables(
|
||||
environmentsStore.value.environments.length - 1,
|
||||
cloneDeep(getGlobalVariables())
|
||||
)
|
||||
} else duplicateEnvironment(props.environmentIndex)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<template #trigger>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${$t('environment.select')}`"
|
||||
:title="`${t('environment.select')}`"
|
||||
class="flex-1 bg-transparent border-b border-dividerLight select-wrapper"
|
||||
>
|
||||
<ButtonSecondary
|
||||
@@ -15,20 +15,20 @@
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="`${$t('environment.select')}`"
|
||||
:label="`${t('environment.select')}`"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col" role="menu">
|
||||
<SmartItem
|
||||
:label="`${$t('environment.no_environment')}`"
|
||||
:label="`${t('environment.no_environment')}`"
|
||||
:info-icon="selectedEnvironmentIndex === -1 ? 'done' : ''"
|
||||
:active-info-icon="selectedEnvironmentIndex === -1"
|
||||
@click.native="
|
||||
() => {
|
||||
selectedEnvironmentIndex = -1
|
||||
$refs.options.tippy().hide()
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -42,7 +42,7 @@
|
||||
@click.native="
|
||||
() => {
|
||||
selectedEnvironmentIndex = index
|
||||
$refs.options.tippy().hide()
|
||||
options.tippy().hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="flex justify-between flex-1 border-b border-dividerLight">
|
||||
<ButtonSecondary
|
||||
svg="plus"
|
||||
:label="`${$t('action.new')}`"
|
||||
:label="`${t('action.new')}`"
|
||||
class="!rounded-none"
|
||||
@click.native="displayModalAdd(true)"
|
||||
/>
|
||||
@@ -60,13 +60,13 @@
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/environments"
|
||||
blank
|
||||
:title="$t('app.wiki')"
|
||||
:title="t('app.wiki')"
|
||||
svg="help-circle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
svg="archive"
|
||||
:title="$t('modal.import_export')"
|
||||
:title="t('modal.import_export')"
|
||||
@click.native="displayModalImportExport(true)"
|
||||
/>
|
||||
</div>
|
||||
@@ -95,13 +95,13 @@
|
||||
:src="`/images/states/${$colorMode.value}/blockchain.svg`"
|
||||
loading="lazy"
|
||||
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">
|
||||
{{ $t("empty.environments") }}
|
||||
{{ t("empty.environments") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="`${$t('add.new')}`"
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
class="mb-4"
|
||||
@click.native="displayModalAdd(true)"
|
||||
@@ -120,9 +120,13 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api"
|
||||
import { useReadonlyStream, useStream } from "~/helpers/utils/composables"
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "@nuxtjs/composition-api"
|
||||
import {
|
||||
useReadonlyStream,
|
||||
useStream,
|
||||
useI18n,
|
||||
} from "~/helpers/utils/composables"
|
||||
import {
|
||||
environments$,
|
||||
setCurrentEnvironment,
|
||||
@@ -130,55 +134,49 @@ import {
|
||||
globalEnv$,
|
||||
} from "~/newstore/environments"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const globalEnv = useReadonlyStream(globalEnv$, [])
|
||||
const t = useI18n()
|
||||
|
||||
const globalEnvironment = computed(() => ({
|
||||
name: "Global",
|
||||
variables: globalEnv.value,
|
||||
}))
|
||||
const options = ref<any | null>(null)
|
||||
|
||||
return {
|
||||
environments: useReadonlyStream(environments$, []),
|
||||
globalEnvironment,
|
||||
selectedEnvironmentIndex: useStream(
|
||||
selectedEnvIndex$,
|
||||
-1,
|
||||
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
|
||||
const globalEnv = useReadonlyStream(globalEnv$, [])
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalImportExport(shouldDisplay: boolean) {
|
||||
this.showModalImportExport = shouldDisplay
|
||||
},
|
||||
editEnvironment(environmentIndex: number | "Global") {
|
||||
this.$data.editingEnvironmentIndex = environmentIndex
|
||||
this.action = "edit"
|
||||
this.displayModalEdit(true)
|
||||
},
|
||||
resetSelectedData() {
|
||||
this.$data.editingEnvironmentIndex = undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
const globalEnvironment = computed(() => ({
|
||||
name: "Global",
|
||||
variables: globalEnv.value,
|
||||
}))
|
||||
|
||||
const environments = useReadonlyStream(environments$, [])
|
||||
|
||||
const selectedEnvironmentIndex = useStream(
|
||||
selectedEnvIndex$,
|
||||
-1,
|
||||
setCurrentEnvironment
|
||||
)
|
||||
|
||||
const showModalImportExport = ref(false)
|
||||
const showModalDetails = ref(false)
|
||||
const action = ref<"new" | "edit">("edit")
|
||||
const editingEnvironmentIndex = ref<number | "Global" | null>(null)
|
||||
|
||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||
action.value = "new"
|
||||
showModalDetails.value = shouldDisplay
|
||||
}
|
||||
const displayModalEdit = (shouldDisplay: boolean) => {
|
||||
action.value = "edit"
|
||||
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>
|
||||
|
||||
@@ -137,6 +137,47 @@
|
||||
/>
|
||||
</span>
|
||||
</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>
|
||||
<div
|
||||
v-if="workingHeaders.length === 0"
|
||||
@@ -162,7 +203,7 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
HoppRESTHeader,
|
||||
@@ -177,13 +218,29 @@ import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
import draggable from "vuedraggable"
|
||||
import { RequestOptionTabs } from "./RequestOptions.vue"
|
||||
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 { useI18n, useStream, useToast } from "~/helpers/utils/composables"
|
||||
import {
|
||||
useI18n,
|
||||
useReadonlyStream,
|
||||
useStream,
|
||||
useToast,
|
||||
} from "~/helpers/utils/composables"
|
||||
import linter from "~/helpers/editor/linting/rawKeyValue"
|
||||
import { throwError } from "~/helpers/functional/error"
|
||||
import { objRemoveKey } from "~/helpers/functional/object"
|
||||
import {
|
||||
ComputedHeader,
|
||||
getComputedHeaders,
|
||||
} from "~/helpers/utils/EffectiveURL"
|
||||
import { aggregateEnvs$, getAggregateEnvs } from "~/newstore/environments"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -196,6 +253,10 @@ const bulkEditor = ref<any | null>(null)
|
||||
|
||||
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "change-tab", value: RequestOptionTabs): void
|
||||
}>()
|
||||
|
||||
useCodemirror(bulkEditor, bulkHeaders, {
|
||||
extendedEditorConfig: {
|
||||
mode: "text/x-yaml",
|
||||
@@ -379,4 +440,28 @@ const clearContent = () => {
|
||||
|
||||
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>
|
||||
|
||||
@@ -171,6 +171,12 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
svg="link-2"
|
||||
:label="`${t('request.view_my_links')}`"
|
||||
to="/profile"
|
||||
/>
|
||||
<hr />
|
||||
<SmartItem
|
||||
ref="saveRequestAction"
|
||||
:label="`${t('request.save_as')}`"
|
||||
@@ -208,6 +214,7 @@
|
||||
import { computed, ref, watch } from "@nuxtjs/composition-api"
|
||||
import { isLeft, isRight } from "fp-ts/lib/Either"
|
||||
import * as E from "fp-ts/Either"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
import {
|
||||
updateRESTResponse,
|
||||
restEndpoint$,
|
||||
@@ -477,14 +484,21 @@ const saveRequest = () => {
|
||||
showSaveRequestModal.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (saveCtx.originLocation === "user-collection") {
|
||||
const req = getRESTRequest()
|
||||
|
||||
try {
|
||||
editRESTRequest(
|
||||
saveCtx.folderPath,
|
||||
saveCtx.requestIndex,
|
||||
getRESTRequest()
|
||||
)
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: saveCtx.folderPath,
|
||||
requestIndex: saveCtx.requestIndex,
|
||||
req: cloneDeep(req),
|
||||
})
|
||||
toast.success(`${t("request.saved")}`)
|
||||
} catch (e) {
|
||||
setRESTSaveContext(null)
|
||||
@@ -505,6 +519,11 @@ const saveRequest = () => {
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
} else {
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: saveCtx.requestID,
|
||||
req: cloneDeep(req),
|
||||
})
|
||||
toast.success(`${t("request.saved")}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
:label="`${$t('tab.headers')}`"
|
||||
:info="`${newActiveHeadersCount$}`"
|
||||
>
|
||||
<HttpHeaders />
|
||||
<HttpHeaders @change-tab="changeTab" />
|
||||
</SmartTab>
|
||||
<SmartTab :id="'authorization'" :label="`${$t('tab.authorization')}`">
|
||||
<HttpAuthorization />
|
||||
|
||||
@@ -17,26 +17,11 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
<LensesHeadersRendererEntry
|
||||
v-for="(header, index) in headers"
|
||||
:key="`header-${index}`"
|
||||
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 px-4 py-2 transition group-hover:text-secondaryDark"
|
||||
>
|
||||
<span class="truncate rounded-sm select-all">
|
||||
{{ header.value }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
:key="index"
|
||||
:header="header"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
135
packages/hoppscotch-app/components/profile/Shortcode.vue
Normal 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>
|
||||
221
packages/hoppscotch-app/components/realtime/Communication.vue
Normal 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>
|
||||
@@ -1,77 +1,164 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1">
|
||||
<div ref="container" class="flex flex-col flex-1 overflow-y-auto">
|
||||
<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">
|
||||
{{ title }}
|
||||
</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">
|
||||
<span v-if="log" class="space-y-2">
|
||||
<span
|
||||
v-for="(entry, index) in log"
|
||||
:key="`entry-${index}`"
|
||||
:style="{ color: entry.color }"
|
||||
class="font-mono"
|
||||
>{{ entry.ts }}{{ source(entry.source) }}{{ entry.payload }}</span
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="toggleSearch"
|
||||
class="w-full p-2 sticky top-0 z-10 text-center border-b border-dividerLight"
|
||||
>
|
||||
<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 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "@nuxtjs/composition-api"
|
||||
import { getSourcePrefix as source } from "~/helpers/utils/string"
|
||||
import { ref, computed, watch } from "@nuxtjs/composition-api"
|
||||
import { useThrottleFn, useScroll } from "@vueuse/core"
|
||||
import { regexEscape } from "~/helpers/functional/regex"
|
||||
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 props = defineProps({
|
||||
log: { type: Array, default: () => [] },
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const logs = ref<HTMLElement | null>(null)
|
||||
|
||||
const autoScrollEnabled = ref(true)
|
||||
|
||||
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 BOTTOM_SCROLL_DIST_INACCURACY = 5
|
||||
const scrollTo = (position: "top" | "bottom") => {
|
||||
if (position === "top") {
|
||||
logs.value?.scroll({
|
||||
behavior: "smooth",
|
||||
top: 0,
|
||||
})
|
||||
} else if (position === "bottom") {
|
||||
logs.value?.scroll({
|
||||
behavior: "smooth",
|
||||
top: logs.value?.scrollHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.log,
|
||||
() => {
|
||||
if (!logsRef.value) return
|
||||
const distToBottom =
|
||||
logsRef.value.scrollHeight -
|
||||
logsRef.value.scrollTop -
|
||||
logsRef.value.clientHeight
|
||||
if (distToBottom < BOTTOM_SCROLL_DIST_INACCURACY) {
|
||||
nextTick(() => (logsRef.value.scrollTop = logsRef.value.scrollHeight))
|
||||
}
|
||||
}
|
||||
useThrottleFn(() => {
|
||||
if (autoScrollEnabled.value) scrollTo("bottom")
|
||||
}, 200),
|
||||
{ flush: "post" }
|
||||
)
|
||||
|
||||
const toggleAutoscroll = () => {
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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>
|
||||
<style></style>
|
||||
|
||||
442
packages/hoppscotch-app/components/realtime/LogEntry.vue
Normal 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>
|
||||
@@ -13,17 +13,22 @@
|
||||
spellcheck="false"
|
||||
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
|
||||
:placeholder="$t('mqtt.url')"
|
||||
:disabled="connectionState"
|
||||
@keyup.enter="validUrl ? toggleConnection() : null"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
<ButtonPrimary
|
||||
id="connect"
|
||||
:disabled="!validUrl"
|
||||
:disabled="!isUrlValid"
|
||||
class="w-32"
|
||||
:label="
|
||||
connectionState ? $t('action.disconnect') : $t('action.connect')
|
||||
connectionState === 'DISCONNECTED'
|
||||
? t('action.connect')
|
||||
: t('action.disconnect')
|
||||
"
|
||||
:loading="connectingState"
|
||||
:loading="connectionState === 'CONNECTING'"
|
||||
@click.native="toggleConnection"
|
||||
/>
|
||||
</div>
|
||||
@@ -48,18 +53,22 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<RealtimeLog :title="$t('mqtt.log')" :log="log" />
|
||||
<RealtimeLog
|
||||
:title="$t('mqtt.log')"
|
||||
:log="log"
|
||||
@delete="clearLogEntries()"
|
||||
/>
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<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") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex px-4">
|
||||
<input
|
||||
id="pub_topic"
|
||||
v-model="pub_topic"
|
||||
id="pubTopic"
|
||||
v-model="pubTopic"
|
||||
class="input"
|
||||
:placeholder="$t('mqtt.topic_name')"
|
||||
type="text"
|
||||
@@ -75,7 +84,7 @@
|
||||
<div class="flex px-4 space-x-2">
|
||||
<input
|
||||
id="mqtt-message"
|
||||
v-model="msg"
|
||||
v-model="message"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@@ -85,7 +94,7 @@
|
||||
<ButtonPrimary
|
||||
id="publish"
|
||||
name="get"
|
||||
:disabled="!canpublish"
|
||||
:disabled="!canPublish"
|
||||
:label="$t('mqtt.publish')"
|
||||
@click.native="publish"
|
||||
/>
|
||||
@@ -93,14 +102,14 @@
|
||||
<div
|
||||
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") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex px-4 space-x-2">
|
||||
<input
|
||||
id="sub_topic"
|
||||
v-model="sub_topic"
|
||||
id="subTopic"
|
||||
v-model="subTopic"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('mqtt.topic_name')"
|
||||
@@ -110,7 +119,7 @@
|
||||
<ButtonPrimary
|
||||
id="subscribe"
|
||||
name="get"
|
||||
:disabled="!cansubscribe"
|
||||
:disabled="!canSubscribe"
|
||||
:label="
|
||||
subscriptionState ? $t('mqtt.unsubscribe') : $t('mqtt.subscribe')
|
||||
"
|
||||
@@ -122,261 +131,220 @@
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
import Paho from "paho-mqtt"
|
||||
import debounce from "lodash/debounce"
|
||||
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
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 {
|
||||
MQTTEndpoint$,
|
||||
setMQTTEndpoint,
|
||||
MQTTConnectingState$,
|
||||
MQTTConnectionState$,
|
||||
setMQTTConnectingState,
|
||||
setMQTTConnectionState,
|
||||
MQTTSubscriptionState$,
|
||||
setMQTTSubscriptionState,
|
||||
MQTTSocket$,
|
||||
setMQTTSocket,
|
||||
MQTTLog$,
|
||||
setMQTTLog,
|
||||
addMQTTLogLine,
|
||||
MQTTConn$,
|
||||
MQTTEndpoint$,
|
||||
MQTTLog$,
|
||||
setMQTTConn,
|
||||
setMQTTEndpoint,
|
||||
setMQTTLog,
|
||||
} from "~/newstore/MQTTSession"
|
||||
import { useStream } from "~/helpers/utils/composables"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
url: useStream(MQTTEndpoint$, "", setMQTTEndpoint),
|
||||
connectionState: useStream(
|
||||
MQTTConnectionState$,
|
||||
false,
|
||||
setMQTTConnectionState
|
||||
),
|
||||
connectingState: useStream(
|
||||
MQTTConnectingState$,
|
||||
false,
|
||||
setMQTTConnectingState
|
||||
),
|
||||
subscriptionState: useStream(
|
||||
MQTTSubscriptionState$,
|
||||
false,
|
||||
setMQTTSubscriptionState
|
||||
),
|
||||
log: useStream(MQTTLog$, null, setMQTTLog),
|
||||
client: useStream(MQTTSocket$, null, setMQTTSocket),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isUrlValid: true,
|
||||
pub_topic: "",
|
||||
sub_topic: "",
|
||||
msg: "",
|
||||
manualDisconnect: false,
|
||||
username: "",
|
||||
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 }),
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
},
|
||||
]
|
||||
const parseUrl = new URL(this.url)
|
||||
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
|
||||
const t = useI18n()
|
||||
const nuxt = useNuxt()
|
||||
const toast = useToast()
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "mqtt",
|
||||
})
|
||||
},
|
||||
onConnectionFailure() {
|
||||
this.connectingState = false
|
||||
this.connectionState = false
|
||||
addMQTTLogLine({
|
||||
payload: this.$t("error.something_went_wrong"),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
})
|
||||
},
|
||||
onConnectionSuccess() {
|
||||
this.connectingState = false
|
||||
this.connectionState = true
|
||||
addMQTTLogLine({
|
||||
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"))
|
||||
},
|
||||
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) {
|
||||
this.disconnect()
|
||||
} else {
|
||||
this.connect()
|
||||
}
|
||||
},
|
||||
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
|
||||
},
|
||||
publish() {
|
||||
try {
|
||||
this.client.publish(this.pub_topic, this.msg, 0, false)
|
||||
const url = useStream(MQTTEndpoint$, "", setMQTTEndpoint)
|
||||
const log = useStream(MQTTLog$, [], setMQTTLog)
|
||||
const socket = useStream(MQTTConn$, new MQTTConnection(), setMQTTConn)
|
||||
const connectionState = useReadonlyStream(
|
||||
socket.value.connectionState$,
|
||||
"DISCONNECTED"
|
||||
)
|
||||
const subscriptionState = useReadonlyStream(
|
||||
socket.value.subscriptionState$,
|
||||
false
|
||||
)
|
||||
|
||||
const isUrlValid = ref(true)
|
||||
const pubTopic = ref("")
|
||||
const subTopic = ref("")
|
||||
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
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
worker = nuxt.value.$worker.createRejexWorker()
|
||||
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",
|
||||
color: "var(--accent-color)",
|
||||
ts: undefined,
|
||||
},
|
||||
]
|
||||
break
|
||||
|
||||
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":
|
||||
addMQTTLogLine({
|
||||
payload: `Published message: ${this.msg} to topic: ${this.pub_topic}`,
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
prefix: `${event.message.topic}`,
|
||||
payload: event.message.message,
|
||||
source: "client",
|
||||
ts: Date.now(),
|
||||
})
|
||||
} catch (e) {
|
||||
break
|
||||
|
||||
case "MESSAGE_RECEIVED":
|
||||
addMQTTLogLine({
|
||||
payload:
|
||||
this.$t("error.something_went_wrong") +
|
||||
`while publishing msg: ${this.msg} to topic: ${this.pub_topic}`,
|
||||
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",
|
||||
color: "#ff5555",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
ts: event.time,
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleSubscription() {
|
||||
if (this.subscriptionState) {
|
||||
this.unsubscribe()
|
||||
} else {
|
||||
this.subscribe()
|
||||
}
|
||||
},
|
||||
subscribe() {
|
||||
try {
|
||||
this.client.subscribe(this.sub_topic, {
|
||||
onSuccess: this.usubSuccess,
|
||||
onFailure: this.usubFailure,
|
||||
})
|
||||
} catch (e) {
|
||||
break
|
||||
|
||||
case "DISCONNECTED":
|
||||
addMQTTLogLine({
|
||||
payload:
|
||||
this.$t("error.something_went_wrong") +
|
||||
`while subscribing to topic: ${this.sub_topic}`,
|
||||
payload: t("state.disconnected_from", { name: url.value }).toString(),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
ts: event.time,
|
||||
})
|
||||
}
|
||||
},
|
||||
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,
|
||||
})
|
||||
},
|
||||
},
|
||||
toast.error(`${t("state.disconnected")}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const debouncer = debounce(function () {
|
||||
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 {
|
||||
socket.value.subscribe(subTopic.value)
|
||||
}
|
||||
}
|
||||
|
||||
const getI18nError = (error: MQTTError): string => {
|
||||
if (typeof error === "string") return error
|
||||
|
||||
switch (error.type) {
|
||||
case "CONNECTION_NOT_ESTABLISHED":
|
||||
return t("state.connection_lost").toString()
|
||||
case "SUBSCRIPTION_FAILED":
|
||||
return t("state.mqtt_subscription_failed", {
|
||||
topic: error.topic,
|
||||
}).toString()
|
||||
case "PUBLISH_ERROR":
|
||||
return t("state.publish_error", { topic: error.topic }).toString()
|
||||
case "CONNECTION_LOST":
|
||||
return t("state.connection_lost").toString()
|
||||
case "CONNECTION_FAILED":
|
||||
return t("state.connection_failed").toString()
|
||||
default:
|
||||
return t("state.disconnected_from", { name: url.value }).toString()
|
||||
}
|
||||
}
|
||||
const clearLogEntries = () => {
|
||||
log.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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"
|
||||
:value="`Client ${clientVersion}`"
|
||||
readonly
|
||||
:disabled="connectionState"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col" role="menu">
|
||||
<SmartItem
|
||||
v-for="(_, version) in socketIoClients"
|
||||
v-for="version in SIOVersions"
|
||||
:key="`client-${version}`"
|
||||
:label="`Client ${version}`"
|
||||
@click.native="onSelectVersion(version)"
|
||||
@@ -43,479 +46,390 @@
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
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"
|
||||
:placeholder="$t('socketio.url')"
|
||||
:disabled="connectionState"
|
||||
@keyup.enter="urlValid ? toggleConnection() : null"
|
||||
:placeholder="`${t('socketio.url')}`"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
<input
|
||||
id="socketio-path"
|
||||
v-model="path"
|
||||
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
|
||||
spellcheck="false"
|
||||
:disabled="connectionState"
|
||||
@keyup.enter="urlValid ? toggleConnection() : null"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
</div>
|
||||
<ButtonPrimary
|
||||
id="connect"
|
||||
:disabled="!urlValid"
|
||||
:disabled="!isUrlValid"
|
||||
name="connect"
|
||||
class="w-32"
|
||||
:label="
|
||||
!connectionState ? $t('action.connect') : $t('action.disconnect')
|
||||
connectionState === 'DISCONNECTED'
|
||||
? t('action.connect')
|
||||
: t('action.disconnect')
|
||||
"
|
||||
:loading="connectingState"
|
||||
:loading="connectionState === 'CONNECTING'"
|
||||
@click.native="toggleConnection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
|
||||
|
||||
<SmartTabs
|
||||
v-model="selectedTab"
|
||||
styles="sticky bg-primary top-upperMobilePrimaryStickyFold sm:top-upperPrimaryStickyFold z-10"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
{{ $t("authorization.type") }}
|
||||
</label>
|
||||
<tippy
|
||||
ref="authTypeOptions"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
arrow
|
||||
>
|
||||
<template #trigger>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
class="pr-8 ml-2 rounded-none"
|
||||
:label="authType"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col" role="menu">
|
||||
<SmartItem
|
||||
label="None"
|
||||
:icon="
|
||||
authType === 'None'
|
||||
? 'radio_button_checked'
|
||||
: 'radio_button_unchecked'
|
||||
"
|
||||
:active="authType === 'None'"
|
||||
@click.native="
|
||||
() => {
|
||||
authType = 'None'
|
||||
authTypeOptions.tippy().hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="Bearer Token"
|
||||
:icon="
|
||||
authType === 'Bearer'
|
||||
? 'radio_button_checked'
|
||||
: 'radio_button_unchecked'
|
||||
"
|
||||
:active="authType === 'Bearer'"
|
||||
@click.native="
|
||||
() => {
|
||||
authType = 'Bearer'
|
||||
authTypeOptions.tippy().hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</tippy>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<SmartCheckbox
|
||||
:on="authActive"
|
||||
class="px-2"
|
||||
@change="authActive = !authActive"
|
||||
>
|
||||
{{ $t("state.enabled") }}
|
||||
</SmartCheckbox>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:title="$t('app.wiki')"
|
||||
svg="help-circle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t('action.clear')"
|
||||
svg="trash-2"
|
||||
@click.native="clearContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="authType === 'None'"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${$colorMode.value}/login.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="$t('empty.authorization')"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
This SocketIO connection does not use any authentication.
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
outline
|
||||
:label="$t('app.documentation')"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
svg="external-link"
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="authType === 'Bearer'"
|
||||
class="flex flex-1 border-b border-dividerLight"
|
||||
>
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="bearerToken" placeholder="Token" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky h-full p-4 overflow-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
|
||||
<SmartTab
|
||||
:id="'communication'"
|
||||
:label="`${t('websocket.communication')}`"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="pb-2 text-secondaryLight">
|
||||
{{ $t("helpers.authorization") }}
|
||||
<RealtimeCommunication
|
||||
:show-event-field="true"
|
||||
:is-connected="connectionState === 'CONNECTED'"
|
||||
@send-message="sendMessage($event)"
|
||||
></RealtimeCommunication>
|
||||
</SmartTab>
|
||||
<SmartTab :id="'protocols'" :label="`${t('request.authorization')}`">
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
{{ t("authorization.type") }}
|
||||
</label>
|
||||
<tippy
|
||||
ref="authTypeOptions"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
arrow
|
||||
>
|
||||
<template #trigger>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
class="pr-8 ml-2 rounded-none"
|
||||
:label="authType"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col" role="menu">
|
||||
<SmartItem
|
||||
label="None"
|
||||
:icon="
|
||||
authType === 'None'
|
||||
? 'radio_button_checked'
|
||||
: 'radio_button_unchecked'
|
||||
"
|
||||
:active="authType === 'None'"
|
||||
@click.native="
|
||||
() => {
|
||||
authType = 'None'
|
||||
authTypeOptions.tippy().hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="Bearer Token"
|
||||
:icon="
|
||||
authType === 'Bearer'
|
||||
? 'radio_button_checked'
|
||||
: 'radio_button_unchecked'
|
||||
"
|
||||
:active="authType === 'Bearer'"
|
||||
@click.native="
|
||||
() => {
|
||||
authType = 'Bearer'
|
||||
authTypeOptions.tippy().hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</tippy>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<SmartCheckbox
|
||||
:on="authActive"
|
||||
class="px-2"
|
||||
@change="authActive = !authActive"
|
||||
>
|
||||
{{ t("state.enabled") }}
|
||||
</SmartCheckbox>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
svg="help-circle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
svg="trash-2"
|
||||
@click.native="clearContent"
|
||||
/>
|
||||
</div>
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
:label="`${$t('authorization.learn')} \xA0 →`"
|
||||
</div>
|
||||
<div
|
||||
v-if="authType === 'None'"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${$colorMode.value}/login.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.authorization')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("socketio.connection_not_authorized") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
outline
|
||||
:label="t('app.documentation')"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
svg="external-link"
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="authType === 'Bearer'"
|
||||
class="flex flex-1 border-b border-dividerLight"
|
||||
>
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="bearerToken" placeholder="Token" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky h-full p-4 overflow-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="pb-2 text-secondaryLight">
|
||||
{{ t("helpers.authorization") }}
|
||||
</div>
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
:label="`${t('authorization.learn')} \xA0 →`"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<RealtimeLog :title="$t('socketio.log')" :log="log" />
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<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>
|
||||
<RealtimeLog
|
||||
:title="t('socketio.log')"
|
||||
:log="log"
|
||||
@delete="clearLogEntries()"
|
||||
/>
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, ref } 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"
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from "@nuxtjs/composition-api"
|
||||
import debounce from "lodash/debounce"
|
||||
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
|
||||
import {
|
||||
SIOEndpoint$,
|
||||
setSIOEndpoint,
|
||||
SIOVersion$,
|
||||
setSIOVersion,
|
||||
SIOPath$,
|
||||
setSIOPath,
|
||||
SIOConnectionState$,
|
||||
SIOConnectingState$,
|
||||
setSIOConnectionState,
|
||||
setSIOConnectingState,
|
||||
SIOSocket$,
|
||||
setSIOSocket,
|
||||
SIOLog$,
|
||||
setSIOLog,
|
||||
SIOConnection,
|
||||
SIOError,
|
||||
SIOMessage,
|
||||
SOCKET_CLIENTS,
|
||||
} from "~/helpers/realtime/SIOConnection"
|
||||
import {
|
||||
useI18n,
|
||||
useNuxt,
|
||||
useReadonlyStream,
|
||||
useStream,
|
||||
useStreamSubscriber,
|
||||
useToast,
|
||||
} from "~/helpers/utils/composables"
|
||||
import {
|
||||
addSIOLogLine,
|
||||
setSIOEndpoint,
|
||||
setSIOLog,
|
||||
setSIOPath,
|
||||
setSIOVersion,
|
||||
SIOClientVersion,
|
||||
SIOEndpoint$,
|
||||
SIOLog$,
|
||||
SIOPath$,
|
||||
SIOVersion$,
|
||||
} from "~/newstore/SocketIOSession"
|
||||
import { useStream } from "~/helpers/utils/composables"
|
||||
|
||||
const socketIoClients = {
|
||||
v4: ClientV4,
|
||||
v3: ClientV3,
|
||||
v2: ClientV2,
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const nuxt = useNuxt()
|
||||
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({
|
||||
setup() {
|
||||
return {
|
||||
socketIoClients,
|
||||
url: useStream(SIOEndpoint$, "", setSIOEndpoint),
|
||||
clientVersion: useStream(SIOVersion$, "", setSIOVersion),
|
||||
path: useStream(SIOPath$, "", setSIOPath),
|
||||
connectingState: useStream(
|
||||
SIOConnectingState$,
|
||||
false,
|
||||
setSIOConnectingState
|
||||
),
|
||||
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: {
|
||||
urlValid() {
|
||||
return this.isUrlValid
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
url() {
|
||||
this.debouncer()
|
||||
},
|
||||
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 }),
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
},
|
||||
]
|
||||
const getMessagePayload = (data: SIOMessage): string =>
|
||||
typeof data.value === "object" ? JSON.stringify(data.value) : `${data.value}`
|
||||
|
||||
try {
|
||||
if (!this.path) {
|
||||
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 })
|
||||
}
|
||||
const getErrorPayload = (error: SIOError): string => {
|
||||
switch (error.type) {
|
||||
case "CONNECTION":
|
||||
return t("state.connection_error").toString()
|
||||
case "RECONNECT_ERROR":
|
||||
return t("state.reconnection_error").toString()
|
||||
default:
|
||||
return t("state.disconnected_from", { name: url.value }).toString()
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }),
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
},
|
||||
]
|
||||
this.$toast.success(this.$t("state.connected"))
|
||||
})
|
||||
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 }),
|
||||
onMounted(() => {
|
||||
worker = nuxt.value.$worker.createRejexWorker()
|
||||
worker.addEventListener("message", workerResponseHandler)
|
||||
|
||||
subscribeToStream(socket.event$, (event) => {
|
||||
switch (event?.type) {
|
||||
case "CONNECTING":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connecting_to", { name: url.value })}`,
|
||||
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"))
|
||||
}
|
||||
color: "var(--accent-color)",
|
||||
ts: undefined,
|
||||
},
|
||||
]
|
||||
break
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "socketio",
|
||||
})
|
||||
},
|
||||
disconnect() {
|
||||
this.io.close()
|
||||
},
|
||||
handleError(error) {
|
||||
this.disconnect()
|
||||
this.connectingState = false
|
||||
this.connectionState = false
|
||||
addSIOLogLine({
|
||||
payload: this.$t("error.something_went_wrong"),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
})
|
||||
if (error !== null)
|
||||
case "CONNECTED":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connected_to", { name: url.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: event.time,
|
||||
},
|
||||
]
|
||||
toast.success(`${t("state.connected")}`)
|
||||
break
|
||||
|
||||
case "MESSAGE_SENT":
|
||||
addSIOLogLine({
|
||||
payload: error,
|
||||
prefix: `[${event.message.eventName}]`,
|
||||
payload: getMessagePayload(event.message),
|
||||
source: "client",
|
||||
ts: event.time,
|
||||
})
|
||||
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: 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(),
|
||||
})
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "DISCONNECTED":
|
||||
addSIOLogLine({
|
||||
payload: `[${eventName}] ${JSON.stringify(messages)}`,
|
||||
source: "client",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
payload: t("state.disconnected_from", { name: url.value }).toString(),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
this.communication.inputs = [""]
|
||||
}
|
||||
},
|
||||
onSelectVersion(version) {
|
||||
this.clientVersion = version
|
||||
this.$refs.versionOptions.tippy().hide()
|
||||
},
|
||||
},
|
||||
toast.error(`${t("state.disconnected")}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
v-model="server"
|
||||
type="url"
|
||||
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"
|
||||
:placeholder="$t('sse.url')"
|
||||
:disabled="connectionSSEState"
|
||||
@keyup.enter="serverValid ? toggleSSEConnection() : null"
|
||||
:disabled="
|
||||
connectionState === 'STARTED' || connectionState === 'STARTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleSSEConnection() : null"
|
||||
/>
|
||||
<label
|
||||
for="event-type"
|
||||
@@ -28,35 +30,42 @@
|
||||
v-model="eventType"
|
||||
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
|
||||
spellcheck="false"
|
||||
:disabled="connectionSSEState"
|
||||
@keyup.enter="serverValid ? toggleSSEConnection() : null"
|
||||
:disabled="
|
||||
connectionState === 'STARTED' || connectionState === 'STARTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleSSEConnection() : null"
|
||||
/>
|
||||
</div>
|
||||
<ButtonPrimary
|
||||
id="start"
|
||||
:disabled="!serverValid"
|
||||
:disabled="!isUrlValid"
|
||||
name="start"
|
||||
class="w-32"
|
||||
:label="
|
||||
!connectionSSEState ? $t('action.start') : $t('action.stop')
|
||||
connectionState === 'STOPPED'
|
||||
? t('action.start')
|
||||
: t('action.stop')
|
||||
"
|
||||
:loading="connectingState"
|
||||
:loading="connectionState === 'STARTING'"
|
||||
@click.native="toggleSSEConnection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<RealtimeLog :title="$t('sse.log')" :log="log" />
|
||||
<RealtimeLog
|
||||
:title="$t('sse.log')"
|
||||
:log="log"
|
||||
@delete="clearLogEntries()"
|
||||
/>
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted, onMounted } from "@nuxtjs/composition-api"
|
||||
import "splitpanes/dist/splitpanes.css"
|
||||
import debounce from "lodash/debounce"
|
||||
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
|
||||
import {
|
||||
SSEEndpoint$,
|
||||
setSSEEndpoint,
|
||||
@@ -64,159 +73,127 @@ import {
|
||||
setSSEEventType,
|
||||
SSESocket$,
|
||||
setSSESocket,
|
||||
SSEConnectingState$,
|
||||
SSEConnectionState$,
|
||||
setSSEConnectionState,
|
||||
setSSEConnectingState,
|
||||
SSELog$,
|
||||
setSSELog,
|
||||
addSSELogLine,
|
||||
} 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({
|
||||
setup() {
|
||||
return {
|
||||
connectionSSEState: useStream(
|
||||
SSEConnectionState$,
|
||||
false,
|
||||
setSSEConnectionState
|
||||
),
|
||||
connectingState: useStream(
|
||||
SSEConnectingState$,
|
||||
false,
|
||||
setSSEConnectingState
|
||||
),
|
||||
server: useStream(SSEEndpoint$, "", setSSEEndpoint),
|
||||
eventType: useStream(SSEEventType$, "", setSSEEventType),
|
||||
sse: useStream(SSESocket$, null, setSSESocket),
|
||||
log: useStream(SSELog$, [], setSSELog),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
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"))
|
||||
}
|
||||
this.sse.addEventListener(this.eventType, ({ data }) => {
|
||||
addSSELogLine({
|
||||
payload: data,
|
||||
source: "server",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
this.handleSSEError(e)
|
||||
this.$toast.error(this.$t("error.something_went_wrong"))
|
||||
}
|
||||
} else {
|
||||
this.log = [
|
||||
const t = useI18n()
|
||||
const nuxt = useNuxt()
|
||||
const toast = useToast()
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
const sse = useStream(SSESocket$, new SSEConnection(), setSSESocket)
|
||||
const connectionState = useReadonlyStream(sse.value.connectionState$, "STOPPED")
|
||||
const server = useStream(SSEEndpoint$, "", setSSEEndpoint)
|
||||
const eventType = useStream(SSEEventType$, "", setSSEEventType)
|
||||
const log = useStream(SSELog$, [], setSSELog)
|
||||
|
||||
const isUrlValid = ref(true)
|
||||
|
||||
let worker: Worker
|
||||
|
||||
const debouncer = debounce(function () {
|
||||
worker.postMessage({ type: "sse", url: server.value })
|
||||
}, 1000)
|
||||
|
||||
watch(server, (url) => {
|
||||
if (url) debouncer()
|
||||
})
|
||||
|
||||
const workerResponseHandler = ({
|
||||
data,
|
||||
}: {
|
||||
data: { url: string; result: boolean }
|
||||
}) => {
|
||||
if (data.url === server.value) isUrlValid.value = data.result
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
worker = nuxt.value.$worker.createRejexWorker()
|
||||
worker.addEventListener("message", workerResponseHandler)
|
||||
|
||||
subscribeToStream(sse.value.event$, (event) => {
|
||||
switch (event?.type) {
|
||||
case "STARTING":
|
||||
log.value = [
|
||||
{
|
||||
payload: this.$t("error.browser_support_sse"),
|
||||
payload: `${t("state.connecting_to", { name: server.value })}`,
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
color: "var(--accent-color)",
|
||||
ts: undefined,
|
||||
},
|
||||
]
|
||||
}
|
||||
break
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "sse",
|
||||
})
|
||||
},
|
||||
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)
|
||||
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({
|
||||
payload: error,
|
||||
payload: event.message,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "ERROR":
|
||||
addSSELogLine({
|
||||
payload: t("error.browser_support_sse").toString(),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
ts: event.time,
|
||||
})
|
||||
},
|
||||
stop() {
|
||||
this.sse.close()
|
||||
this.sse.onclose()
|
||||
},
|
||||
},
|
||||
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()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
worker.terminate()
|
||||
})
|
||||
const clearLogEntries = () => {
|
||||
log.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,179 +12,170 @@
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:class="{ error: !urlValid }"
|
||||
:placeholder="$t('websocket.url')"
|
||||
:disabled="connectionState"
|
||||
@keyup.enter="urlValid ? toggleConnection() : null"
|
||||
:class="{ error: !isUrlValid }"
|
||||
:placeholder="`${t('websocket.url')}`"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
<ButtonPrimary
|
||||
id="connect"
|
||||
:disabled="!urlValid"
|
||||
:disabled="!isUrlValid"
|
||||
class="w-32"
|
||||
name="connect"
|
||||
:label="
|
||||
!connectionState ? $t('action.connect') : $t('action.disconnect')
|
||||
connectionState === 'DISCONNECTED'
|
||||
? t('action.connect')
|
||||
: t('action.disconnect')
|
||||
"
|
||||
:loading="connectingState"
|
||||
:loading="connectionState === 'CONNECTING'"
|
||||
@click.native="toggleConnection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
|
||||
<SmartTabs
|
||||
v-model="selectedTab"
|
||||
styles="sticky bg-primary top-upperMobilePrimaryStickyFold sm:top-upperPrimaryStickyFold z-10"
|
||||
>
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
{{ $t("websocket.protocols") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t('action.clear_all')"
|
||||
svg="trash-2"
|
||||
@click.native="clearContent"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t('add.new')"
|
||||
svg="plus"
|
||||
@click.native="addProtocol"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<draggable
|
||||
v-model="protocols"
|
||||
animation="250"
|
||||
handle=".draggable-handle"
|
||||
draggable=".draggable-content"
|
||||
ghost-class="cursor-move"
|
||||
chosen-class="bg-primaryLight"
|
||||
drag-class="cursor-grabbing"
|
||||
>
|
||||
<div
|
||||
v-for="(protocol, index) of protocols"
|
||||
:key="`protocol-${index}`"
|
||||
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
|
||||
<SmartTab
|
||||
:id="'communication'"
|
||||
:label="`${$t('websocket.communication')}`"
|
||||
>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
svg="grip-vertical"
|
||||
class="cursor-auto text-primary hover:text-primary"
|
||||
:class="{
|
||||
'draggable-handle group-hover:text-secondaryLight !cursor-grab':
|
||||
index !== protocols?.length - 1,
|
||||
}"
|
||||
tabindex="-1"
|
||||
<RealtimeCommunication
|
||||
:is-connected="connectionState === 'CONNECTED'"
|
||||
@send-message="sendMessage($event)"
|
||||
></RealtimeCommunication>
|
||||
</SmartTab>
|
||||
<SmartTab :id="'protocols'" :label="`${$t('websocket.protocols')}`">
|
||||
<div
|
||||
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">
|
||||
{{ t("websocket.protocols") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
svg="trash-2"
|
||||
@click.native="clearContent"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('add.new')"
|
||||
svg="plus"
|
||||
@click.native="addProtocol"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<draggable
|
||||
v-model="protocols"
|
||||
animation="250"
|
||||
handle=".draggable-handle"
|
||||
draggable=".draggable-content"
|
||||
ghost-class="cursor-move"
|
||||
chosen-class="bg-primaryLight"
|
||||
drag-class="cursor-grabbing"
|
||||
>
|
||||
<div
|
||||
v-for="(protocol, index) of protocols"
|
||||
:key="`protocol-${index}`"
|
||||
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
|
||||
>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
svg="grip-vertical"
|
||||
class="cursor-auto text-primary hover:text-primary"
|
||||
:class="{
|
||||
'draggable-handle group-hover:text-secondaryLight !cursor-grab':
|
||||
index !== protocols?.length - 1,
|
||||
}"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
v-model="protocol.value"
|
||||
class="flex flex-1 px-4 py-2 bg-transparent"
|
||||
:placeholder="`${t('count.protocol', { count: index + 1 })}`"
|
||||
name="message"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@change="
|
||||
updateProtocol(index, {
|
||||
value: $event.target.value,
|
||||
active: protocol.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
protocol.hasOwnProperty('active')
|
||||
? protocol.active
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
: t('action.turn_off')
|
||||
"
|
||||
:svg="
|
||||
protocol.hasOwnProperty('active')
|
||||
? protocol.active
|
||||
? 'check-circle'
|
||||
: 'circle'
|
||||
: 'check-circle'
|
||||
"
|
||||
color="green"
|
||||
@click.native="
|
||||
updateProtocol(index, {
|
||||
value: protocol.value,
|
||||
active: !protocol.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
svg="trash"
|
||||
color="red"
|
||||
@click.native="deleteProtocol(index)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</draggable>
|
||||
<div
|
||||
v-if="protocols.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${$colorMode.value}/add_category.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.protocols')}`"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
v-model="protocol.value"
|
||||
class="flex flex-1 px-4 py-2 bg-transparent"
|
||||
:placeholder="$t('count.protocol', { count: index + 1 })"
|
||||
name="message"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@change="
|
||||
updateProtocol(index, {
|
||||
value: $event.target.value,
|
||||
active: protocol.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
protocol.hasOwnProperty('active')
|
||||
? protocol.active
|
||||
? $t('action.turn_off')
|
||||
: $t('action.turn_on')
|
||||
: $t('action.turn_off')
|
||||
"
|
||||
:svg="
|
||||
protocol.hasOwnProperty('active')
|
||||
? protocol.active
|
||||
? 'check-circle'
|
||||
: 'circle'
|
||||
: 'check-circle'
|
||||
"
|
||||
color="green"
|
||||
@click.native="
|
||||
updateProtocol(index, {
|
||||
value: protocol.value,
|
||||
active: !protocol.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t('action.remove')"
|
||||
svg="trash"
|
||||
color="red"
|
||||
@click.native="deleteProtocol({ index })"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</draggable>
|
||||
<div
|
||||
v-if="protocols.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${$colorMode.value}/add_category.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="$t('empty.protocols')"
|
||||
/>
|
||||
<span class="mb-4 text-center">
|
||||
{{ $t("empty.protocols") }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="mb-4 text-center">
|
||||
{{ t("empty.protocols") }}
|
||||
</span>
|
||||
</div>
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<RealtimeLog :title="$t('websocket.log')" :log="log" />
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<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>
|
||||
<RealtimeLog
|
||||
:title="$t('websocket.log')"
|
||||
:log="log"
|
||||
@delete="clearLogEntries()"
|
||||
/>
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted, onMounted } from "@nuxtjs/composition-api"
|
||||
import debounce from "lodash/debounce"
|
||||
import draggable from "vuedraggable"
|
||||
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
|
||||
import {
|
||||
setWSEndpoint,
|
||||
WSEndpoint$,
|
||||
@@ -194,240 +185,185 @@ import {
|
||||
deleteWSProtocol,
|
||||
updateWSProtocol,
|
||||
deleteAllWSProtocols,
|
||||
WSSocket$,
|
||||
setWSSocket,
|
||||
setWSConnectionState,
|
||||
setWSConnectingState,
|
||||
WSConnectionState$,
|
||||
WSConnectingState$,
|
||||
addWSLogLine,
|
||||
WSLog$,
|
||||
setWSLog,
|
||||
HoppWSProtocol,
|
||||
setWSSocket,
|
||||
WSSocket$,
|
||||
} 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({
|
||||
components: {
|
||||
draggable,
|
||||
const nuxt = useNuxt()
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
const selectedTab = ref<"communication" | "protocols">("communication")
|
||||
const url = useStream(WSEndpoint$, "", setWSEndpoint)
|
||||
const protocols = useStream(WSProtocols$, [], setWSProtocols)
|
||||
|
||||
const socket = useStream(WSSocket$, new WSConnection(), setWSSocket)
|
||||
|
||||
const connectionState = useReadonlyStream(
|
||||
socket.value.connectionState$,
|
||||
"DISCONNECTED"
|
||||
)
|
||||
|
||||
const log = useStream(WSLog$, [], setWSLog)
|
||||
// DATA
|
||||
const isUrlValid = ref(true)
|
||||
const activeProtocols = ref<string[]>([])
|
||||
let worker: Worker
|
||||
watch(url, (newUrl) => {
|
||||
if (newUrl) debouncer()
|
||||
})
|
||||
watch(
|
||||
protocols,
|
||||
(newProtocols) => {
|
||||
activeProtocols.value = newProtocols
|
||||
.filter((item) =>
|
||||
Object.prototype.hasOwnProperty.call(item, "active")
|
||||
? item.active === true
|
||||
: true
|
||||
)
|
||||
.map(({ value }) => value)
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
url: useStream(WSEndpoint$, "", setWSEndpoint),
|
||||
protocols: useStream(WSProtocols$, [], setWSProtocols),
|
||||
connectionState: useStream(
|
||||
WSConnectionState$,
|
||||
false,
|
||||
setWSConnectionState
|
||||
),
|
||||
connectingState: useStream(
|
||||
WSConnectingState$,
|
||||
false,
|
||||
setWSConnectingState
|
||||
),
|
||||
socket: useStream(WSSocket$, null, setWSSocket),
|
||||
log: useStream(WSLog$, [], setWSLog),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isUrlValid: true,
|
||||
communication: {
|
||||
input: "",
|
||||
},
|
||||
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) =>
|
||||
Object.prototype.hasOwnProperty.call(item, "active")
|
||||
? item.active === true
|
||||
: true
|
||||
)
|
||||
.map(({ value }) => value)
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (process.browser) {
|
||||
this.worker = this.$worker.createRejexWorker()
|
||||
this.worker.addEventListener("message", this.workerResponseHandler)
|
||||
}
|
||||
},
|
||||
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 }),
|
||||
{ deep: true }
|
||||
)
|
||||
const workerResponseHandler = ({
|
||||
data,
|
||||
}: {
|
||||
data: { url: string; result: boolean }
|
||||
}) => {
|
||||
if (data.url === url.value) isUrlValid.value = data.result
|
||||
}
|
||||
|
||||
const getErrorPayload = (error: WSErrorMessage): string => {
|
||||
if (error instanceof SyntaxError) {
|
||||
return error.message
|
||||
}
|
||||
return t("error.something_went_wrong").toString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
worker = nuxt.value.$worker.createRejexWorker()
|
||||
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",
|
||||
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"))
|
||||
}
|
||||
color: "var(--accent-color)",
|
||||
ts: undefined,
|
||||
},
|
||||
]
|
||||
break
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "wss",
|
||||
})
|
||||
},
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
this.connectionState = false
|
||||
this.connectingState = false
|
||||
}
|
||||
},
|
||||
handleError(error) {
|
||||
this.disconnect()
|
||||
this.connectionState = false
|
||||
addWSLogLine({
|
||||
payload: this.$t("error.something_went_wrong"),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
})
|
||||
if (error !== null)
|
||||
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({
|
||||
payload: error,
|
||||
payload: event.message,
|
||||
source: "client",
|
||||
ts: Date.now(),
|
||||
})
|
||||
break
|
||||
|
||||
case "MESSAGE_RECEIVED":
|
||||
addWSLogLine({
|
||||
payload: event.message,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "ERROR":
|
||||
addWSLogLine({
|
||||
payload: getErrorPayload(event.error),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
ts: event.time,
|
||||
})
|
||||
},
|
||||
sendMessage() {
|
||||
const message = this.communication.input
|
||||
this.socket.send(message)
|
||||
addWSLogLine({
|
||||
payload: message,
|
||||
source: "client",
|
||||
ts: new Date().toLocaleTimeString(),
|
||||
})
|
||||
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
|
||||
case "down":
|
||||
if (length > 0 && this.currentIndex > -1) {
|
||||
if (this.currentIndex === length - 1) {
|
||||
this.currentIndex = -1
|
||||
this.communication.input = ""
|
||||
} else if (this.currentIndex < length - 1) {
|
||||
this.currentIndex = this.currentIndex + 1
|
||||
this.communication.input =
|
||||
clientMessages[this.currentIndex].payload
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
addProtocol() {
|
||||
addWSProtocol({ value: "", active: true })
|
||||
},
|
||||
deleteProtocol({ index }) {
|
||||
const oldProtocols = this.protocols.slice()
|
||||
deleteWSProtocol(index)
|
||||
this.$toast.success(this.$t("state.deleted"), {
|
||||
action: {
|
||||
text: this.$t("action.undo"),
|
||||
duration: 4000,
|
||||
onClick: (_, toastObject) => {
|
||||
this.protocols = oldProtocols
|
||||
toastObject.remove()
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
updateProtocol(index, updated) {
|
||||
updateWSProtocol(index, updated)
|
||||
},
|
||||
},
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
const deleteProtocol = (index: number) => {
|
||||
const oldProtocols = protocols.value.slice()
|
||||
deleteWSProtocol(index)
|
||||
toast.success(`${t("state.deleted")}`, {
|
||||
duration: 4000,
|
||||
action: {
|
||||
text: `${t("action.undo")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
protocols.value = oldProtocols
|
||||
toastObject.goAway()
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
const updateProtocol = (index: number, updated: HoppWSProtocol) => {
|
||||
updateWSProtocol(index, updated)
|
||||
}
|
||||
const clearLogEntries = () => {
|
||||
log.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
</template>
|
||||
<template #footer>
|
||||
<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" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -42,14 +47,15 @@ export default defineComponent({
|
||||
return this.$t("action.no")
|
||||
},
|
||||
},
|
||||
loadingState: { type: Boolean, default: null },
|
||||
},
|
||||
methods: {
|
||||
hideModal() {
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
resolve() {
|
||||
this.$emit("resolve")
|
||||
this.$emit("hide-modal")
|
||||
this.$emit("resolve", this.title)
|
||||
if (this.loadingState === null) this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -29,10 +29,12 @@ import {
|
||||
placeholder as placeholderExt,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
keymap,
|
||||
} from "@codemirror/view"
|
||||
import { EditorState, Extension } from "@codemirror/state"
|
||||
import clone from "lodash/clone"
|
||||
import { tooltips } from "@codemirror/tooltip"
|
||||
import { history, historyKeymap } from "@codemirror/history"
|
||||
import { inputTheme } from "~/helpers/editor/themes/baseTheme"
|
||||
import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment"
|
||||
import { useReadonlyStream } from "~/helpers/utils/composables"
|
||||
@@ -45,6 +47,7 @@ const props = withDefaults(
|
||||
styles: string
|
||||
envs: { key: string; value: string; source: string }[] | null
|
||||
focus: boolean
|
||||
readonly: boolean
|
||||
}>(),
|
||||
{
|
||||
value: "",
|
||||
@@ -52,6 +55,7 @@ const props = withDefaults(
|
||||
styles: "",
|
||||
envs: null,
|
||||
focus: false,
|
||||
readonly: false,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -120,7 +124,24 @@ const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
|
||||
|
||||
const initView = (el: any) => {
|
||||
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,
|
||||
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({
|
||||
position: "absolute",
|
||||
}),
|
||||
@@ -138,6 +159,8 @@ const initView = (el: any) => {
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
update(update: ViewUpdate) {
|
||||
if (props.readonly) return
|
||||
|
||||
if (update.docChanged) {
|
||||
const prevValue = clone(cachedValue.value)
|
||||
|
||||
@@ -172,6 +195,8 @@ const initView = (el: any) => {
|
||||
}
|
||||
}
|
||||
),
|
||||
history(),
|
||||
keymap.of([...historyKeymap]),
|
||||
]
|
||||
|
||||
view.value = new EditorView({
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
<template>
|
||||
<SmartItem
|
||||
:label="label"
|
||||
:icon="
|
||||
value === selected ? 'radio_button_checked' : 'radio_button_unchecked'
|
||||
"
|
||||
:active="value === selected"
|
||||
:icon="selected ? 'radio_button_checked' : 'radio_button_unchecked'"
|
||||
:active="selected"
|
||||
role="radio"
|
||||
:aria-checked="value === selected"
|
||||
@click.native="$emit('change', value)"
|
||||
:aria-checked="selected"
|
||||
@click.native="emit('change', value)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
(e: "change", value: string): void
|
||||
}>()
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
selected: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,18 +5,22 @@
|
||||
:key="`radio-${index}`"
|
||||
:value="radio.value"
|
||||
:label="radio.label"
|
||||
:selected="selected"
|
||||
@change="$emit('change', radio.value)"
|
||||
:selected="value === radio.value"
|
||||
@change="emit('input', radio.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
(e: "input", value: string): void
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
radios: Array<{
|
||||
value: string
|
||||
value: string // The key of the radio option
|
||||
label: string
|
||||
}>
|
||||
selected: string
|
||||
value: string // Should be a radio key given in the radios array
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -149,7 +149,7 @@ export class GQLConnection {
|
||||
.forEach((x) => (finalHeaders[x.key] = x.value))
|
||||
|
||||
const reqOptions = {
|
||||
method: "post",
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
...finalHeaders,
|
||||
@@ -213,7 +213,7 @@ export class GQLConnection {
|
||||
.forEach(({ key, value }) => (finalHeaders[key] = value))
|
||||
|
||||
const reqOptions = {
|
||||
method: "post",
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
...finalHeaders,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation DeleteShortcode($code: ID!) {
|
||||
revokeShortcode(code: $code)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
query GetUserShortcodes($cursor: ID) {
|
||||
myShortcodes(cursor: $cursor) {
|
||||
id
|
||||
request
|
||||
createdOn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
subscription ShortcodeCreated {
|
||||
myShortcodesCreated {
|
||||
id
|
||||
request
|
||||
createdOn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
subscription ShortcodeDeleted {
|
||||
myShortcodesRevoked {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
GetCollectionTitleDocument,
|
||||
} from "./graphql"
|
||||
|
||||
const BACKEND_PAGE_SIZE = 10
|
||||
export const BACKEND_PAGE_SIZE = 10
|
||||
|
||||
const getCollectionChildrenIDs = async (collID: string) => {
|
||||
const collsList: string[] = []
|
||||
|
||||
@@ -4,8 +4,13 @@ import {
|
||||
CreateShortcodeDocument,
|
||||
CreateShortcodeMutation,
|
||||
CreateShortcodeMutationVariables,
|
||||
DeleteShortcodeDocument,
|
||||
DeleteShortcodeMutation,
|
||||
DeleteShortcodeMutationVariables,
|
||||
} from "../graphql"
|
||||
|
||||
type DeleteShortcodeErrors = "shortcode/not_found"
|
||||
|
||||
export const createShortcode = (request: HoppRESTRequest) =>
|
||||
runMutation<CreateShortcodeMutation, CreateShortcodeMutationVariables, "">(
|
||||
CreateShortcodeDocument,
|
||||
@@ -13,3 +18,12 @@ export const createShortcode = (request: HoppRESTRequest) =>
|
||||
request: JSON.stringify(request),
|
||||
}
|
||||
)
|
||||
|
||||
export const deleteShortcode = (code: string) =>
|
||||
runMutation<
|
||||
DeleteShortcodeMutation,
|
||||
DeleteShortcodeMutationVariables,
|
||||
DeleteShortcodeErrors
|
||||
>(DeleteShortcodeDocument, {
|
||||
code,
|
||||
})
|
||||
|
||||
@@ -768,11 +768,52 @@ const samples = [
|
||||
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()) {
|
||||
test(`matches expectation for sample #${i + 1}`, () => {
|
||||
test(`for sample #${i + 1}:\n\n${command}`, () => {
|
||||
expect(parseCurlToHoppRESTReq(command)).toEqual(response)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getHeaders, recordToHoppHeaders } from "./sub_helpers/headers"
|
||||
// import { getCookies } from "./sub_helpers/cookies"
|
||||
import { getQueries } from "./sub_helpers/queries"
|
||||
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 { getBody, getFArgumentMultipartData } from "./sub_helpers/body"
|
||||
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
|
||||
@@ -42,7 +42,7 @@ export const parseCurlCommand = (curlCommand: string) => {
|
||||
|
||||
const method = getMethod(parsedArguments)
|
||||
// const cookies = getCookies(parsedArguments)
|
||||
const urlObject = parseURL(parsedArguments)
|
||||
const urlObject = getURLObject(parsedArguments)
|
||||
const auth = getAuthObject(parsedArguments, headers, urlObject)
|
||||
|
||||
let rawData: string | string[] = pipe(
|
||||
|
||||
@@ -158,12 +158,14 @@ const getXMLBody = (rawData: string) =>
|
||||
O.alt(() => O.some(rawData))
|
||||
)
|
||||
|
||||
const getFormattedJSON = flow(
|
||||
safeParseJSON,
|
||||
O.map((parsedJSON) => JSON.stringify(parsedJSON, null, 2)),
|
||||
O.getOrElse(() => "{}"),
|
||||
O.of
|
||||
)
|
||||
const getFormattedJSON = (jsonString: string) =>
|
||||
pipe(
|
||||
jsonString.replaceAll('\\"', '"'),
|
||||
safeParseJSON,
|
||||
O.map((parsedJSON) => JSON.stringify(parsedJSON, null, 2)),
|
||||
O.getOrElse(() => "{ }"),
|
||||
O.of
|
||||
)
|
||||
|
||||
const getXWWWFormUrlEncodedBody = flow(
|
||||
decodeURIComponent,
|
||||
|
||||
@@ -50,7 +50,7 @@ const getMethodByDeduction = (parsedArguments: parser.Arguments) => {
|
||||
R.or(objHasArrayProperty("F", "string"))
|
||||
)(parsedArguments)
|
||||
)
|
||||
return O.some("post")
|
||||
return O.some("POST")
|
||||
else return O.none
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,11 @@ const replaceables: { [key: string]: string } = {
|
||||
const paperCuts = flow(
|
||||
// remove '\' and newlines
|
||||
S.replace(/ ?\\ ?$/gm, " "),
|
||||
S.replace(/\n/g, ""),
|
||||
S.replace(/\n/g, " "),
|
||||
// remove all $ symbols from start of argument values
|
||||
S.replace(/\$'/g, "'"),
|
||||
S.replace(/\$"/g, '"')
|
||||
S.replace(/\$"/g, '"'),
|
||||
S.trim
|
||||
)
|
||||
|
||||
// replace --zargs option with -Z
|
||||
|
||||
@@ -1,48 +1,80 @@
|
||||
import parser from "yargs-parser"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
|
||||
import { stringArrayJoin } from "~/helpers/functional/array"
|
||||
|
||||
const defaultRESTReq = getDefaultRESTRequest()
|
||||
|
||||
const getProtocolForBaseURL = (baseURL: string) =>
|
||||
const getProtocolFromURL = (url: string) =>
|
||||
pipe(
|
||||
// get the base URL
|
||||
/^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(baseURL),
|
||||
/^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(url),
|
||||
O.fromNullable,
|
||||
O.filter((burl) => burl.length > 1),
|
||||
O.map((burl) => burl[2]),
|
||||
// set protocol to http for local URLs
|
||||
O.map((burl) =>
|
||||
burl === "localhost" || burl === "127.0.0.1"
|
||||
? "http://" + baseURL
|
||||
: "https://" + baseURL
|
||||
burl === "localhost" ||
|
||||
burl === "2130706433" ||
|
||||
/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
|
||||
* @param parsedArguments Parsed Arguments object
|
||||
* @returns URL object
|
||||
*/
|
||||
export function parseURL(parsedArguments: parser.Arguments) {
|
||||
export function getURLObject(parsedArguments: parser.Arguments) {
|
||||
return pipe(
|
||||
// contains raw url string
|
||||
parsedArguments._[1],
|
||||
O.fromNullable,
|
||||
// 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)),
|
||||
// contains raw url strings
|
||||
parsedArguments._.slice(1),
|
||||
A.findFirstMap(parseURL),
|
||||
// no url found
|
||||
O.getOrElse(() => new URL(defaultRESTReq.endpoint))
|
||||
)
|
||||
|
||||
@@ -28,8 +28,6 @@ import { javascriptLanguage } from "@codemirror/lang-javascript"
|
||||
import { xmlLanguage } from "@codemirror/lang-xml"
|
||||
import { jsonLanguage } from "@codemirror/lang-json"
|
||||
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 { html } from "@codemirror/legacy-modes/mode/xml"
|
||||
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) => {
|
||||
if (!hoppLinter) return []
|
||||
|
||||
// Requires full document scan, hence expensive on big files, force disable on big files ?
|
||||
const linterResult = await hoppLinter(
|
||||
view.state.doc.toJSON().join(view.state.lineBreak)
|
||||
@@ -119,16 +119,16 @@ const hoppLinterExt = (hoppLinter: LinterDefinition): Extension => {
|
||||
}
|
||||
|
||||
const hoppLang = (
|
||||
language: Language,
|
||||
language: Language | undefined,
|
||||
linter?: LinterDefinition | undefined,
|
||||
completer?: Completer | undefined
|
||||
) => {
|
||||
): Extension | LanguageSupport => {
|
||||
const exts: Extension[] = []
|
||||
|
||||
if (linter) exts.push(hoppLinterExt(linter))
|
||||
exts.push(hoppLinterExt(linter))
|
||||
if (completer) exts.push(hoppCompleterExt(completer))
|
||||
|
||||
return new LanguageSupport(language, exts)
|
||||
return language ? new LanguageSupport(language, exts) : exts
|
||||
}
|
||||
|
||||
const getLanguage = (langMime: string): Language | null => {
|
||||
@@ -156,12 +156,7 @@ const getEditorLanguage = (
|
||||
langMime: string,
|
||||
linter: LinterDefinition | undefined,
|
||||
completer: Completer | undefined
|
||||
): Extension =>
|
||||
pipe(
|
||||
O.fromNullable(getLanguage(langMime)),
|
||||
O.map((lang) => hoppLang(lang, linter, completer)),
|
||||
O.getOrElseW(() => [])
|
||||
)
|
||||
): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
|
||||
|
||||
export function useCodemirror(
|
||||
el: Ref<any | null>,
|
||||
|
||||
@@ -17,6 +17,6 @@ export const trace = <T>(x: T) => {
|
||||
export const namedTrace =
|
||||
(name: string) =>
|
||||
<T>(x: T) => {
|
||||
console.log(`${name}: `, x)
|
||||
console.log(`${name}:`, x)
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as O from "fp-ts/Option"
|
||||
import { flow } from "fp-ts/function"
|
||||
|
||||
/**
|
||||
* Checks and Parses JSON string
|
||||
@@ -7,3 +8,10 @@ import * as O from "fp-ts/Option"
|
||||
*/
|
||||
export const safeParseJSON = (str: string): O.Option<object> =>
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pipe } from "fp-ts/function"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
import isEqual from "lodash/isEqual"
|
||||
import { JSPrimitive, TypeFromPrimitive } from "./primtive"
|
||||
|
||||
export const objRemoveKey =
|
||||
<T, K extends keyof T>(key: K) =>
|
||||
@@ -19,34 +20,15 @@ export const objFieldMatches =
|
||||
(obj: T): obj is T & { [_ in K]: V } =>
|
||||
matches.findIndex((x) => isEqual(obj[fieldName], x)) !== -1
|
||||
|
||||
type JSPrimitive =
|
||||
| "undefined"
|
||||
| "object"
|
||||
| "boolean"
|
||||
| "number"
|
||||
| "bigint"
|
||||
| "string"
|
||||
| "symbol"
|
||||
| "function"
|
||||
|
||||
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 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)
|
||||
|
||||
type TypeFromPrimitiveArray<P extends JSPrimitive | undefined> =
|
||||
P extends "undefined"
|
||||
@@ -67,16 +49,6 @@ type TypeFromPrimitiveArray<P extends JSPrimitive | undefined> =
|
||||
? Function[]
|
||||
: 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 =
|
||||
<O extends object, K extends string, P extends JSPrimitive>(
|
||||
prop: K,
|
||||
|
||||
34
packages/hoppscotch-app/helpers/functional/primtive.ts
Normal 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
|
||||
33
packages/hoppscotch-app/helpers/functional/regex.ts
Normal 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
|
||||
}
|
||||
13
packages/hoppscotch-app/helpers/functional/taskEither.ts
Normal 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
|
||||
@@ -10,7 +10,7 @@ import { defineImporter, IMPORTER_INVALID_FILE_FORMAT } from "."
|
||||
// TODO: Add validation to output
|
||||
const fetchGist = (
|
||||
url: string
|
||||
): TO.TaskOption<HoppCollection<HoppRESTRequest>> =>
|
||||
): TO.TaskOption<HoppCollection<HoppRESTRequest>[]> =>
|
||||
pipe(
|
||||
TO.tryCatch(() =>
|
||||
axios.get(`https://api.github.com/gists/${url.split("/").pop()}`, {
|
||||
@@ -30,8 +30,10 @@ const fetchGist = (
|
||||
)
|
||||
|
||||
export default defineImporter({
|
||||
id: "gist",
|
||||
name: "import.from_gist",
|
||||
icon: "github",
|
||||
applicableTo: ["my-collections", "team-collections"],
|
||||
steps: [
|
||||
step({
|
||||
stepName: "URL_IMPORT",
|
||||
|
||||
@@ -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 E from "fp-ts/Either"
|
||||
import { translateToNewRESTCollection } from "@hoppscotch/data"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import {
|
||||
translateToNewRESTCollection,
|
||||
HoppCollection,
|
||||
HoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import _isPlainObject from "lodash/isPlainObject"
|
||||
import { step } from "../steps"
|
||||
import { defineImporter, IMPORTER_INVALID_FILE_FORMAT } from "."
|
||||
import { safeParseJSON } from "~/helpers/functional/json"
|
||||
|
||||
export default defineImporter({
|
||||
id: "hoppscotch",
|
||||
name: "import.from_json",
|
||||
icon: "folder-plus",
|
||||
applicableTo: ["my-collections", "team-collections", "url-import"],
|
||||
steps: [
|
||||
step({
|
||||
stepName: "FILE_IMPORT",
|
||||
@@ -19,16 +28,40 @@ export default defineImporter({
|
||||
] as const,
|
||||
importer: ([content]) =>
|
||||
pipe(
|
||||
E.tryCatch(
|
||||
() => {
|
||||
const x = JSON.parse(content)
|
||||
|
||||
return Array.isArray(x)
|
||||
? x.map((coll: any) => translateToNewRESTCollection(coll))
|
||||
: [translateToNewRESTCollection(x)]
|
||||
},
|
||||
() => IMPORTER_INVALID_FILE_FORMAT
|
||||
safeParseJSON(content),
|
||||
O.chain(
|
||||
flow(
|
||||
makeCollectionsArray,
|
||||
RA.map(
|
||||
flow(
|
||||
O.fromPredicate(isValidCollection),
|
||||
O.map(translateToNewRESTCollection)
|
||||
)
|
||||
),
|
||||
O.sequenceArray,
|
||||
O.map(RA.toArray)
|
||||
)
|
||||
),
|
||||
TE.fromEither
|
||||
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]
|
||||
|
||||
@@ -13,3 +13,10 @@ export const RESTCollectionImporters = [
|
||||
GistImporter,
|
||||
MyCollectionsImporter,
|
||||
] as const
|
||||
|
||||
export const URLImporters = [
|
||||
HoppRESTCollImporter,
|
||||
OpenAPIImporter,
|
||||
PostmanImporter,
|
||||
InsomniaImporter,
|
||||
] as const
|
||||
|
||||
@@ -13,10 +13,18 @@ type HoppImporter<T, StepsType, Errors> = (
|
||||
stepValues: StepsOutputList<StepsType>
|
||||
) => TE.TaskEither<Errors, T>
|
||||
|
||||
type HoppImporterApplicableTo = Array<
|
||||
"team-collections" | "my-collections" | "url-import"
|
||||
>
|
||||
|
||||
/**
|
||||
* Definition for importers
|
||||
*/
|
||||
type HoppImporterDefinition<T, Y, E> = {
|
||||
/**
|
||||
* the id
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* Name of the importer, shown on the Select Importer dropdown
|
||||
*/
|
||||
@@ -30,7 +38,7 @@ type HoppImporterDefinition<T, Y, E> = {
|
||||
/**
|
||||
* 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 ?)
|
||||
@@ -47,10 +55,11 @@ type HoppImporterDefinition<T, Y, E> = {
|
||||
* Defines a Hoppscotch importer
|
||||
*/
|
||||
export const defineImporter = <ReturnType, StepType, Errors>(input: {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
importer: HoppImporter<ReturnType, StepType, Errors>
|
||||
applicableTo?: Array<"team-collections" | "my-collections">
|
||||
applicableTo: HoppImporterApplicableTo
|
||||
steps: StepType
|
||||
}) => {
|
||||
return <HoppImporterDefinition<ReturnType, StepType, Errors>>{
|
||||
|
||||
@@ -210,7 +210,9 @@ const getHoppCollections = (doc: InsomniaDoc) =>
|
||||
)
|
||||
|
||||
export default defineImporter({
|
||||
id: "insomnia",
|
||||
name: "import.from_insomnia",
|
||||
applicableTo: ["my-collections", "team-collections", "url-import"],
|
||||
icon: "insomnia",
|
||||
steps: [
|
||||
step({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defineImporter } from "."
|
||||
import { getRESTCollection } from "~/newstore/collections"
|
||||
|
||||
export default defineImporter({
|
||||
id: "myCollections",
|
||||
name: "import.from_my_collections",
|
||||
icon: "user",
|
||||
applicableTo: ["team-collections"],
|
||||
|
||||
@@ -27,7 +27,7 @@ import * as RA from "fp-ts/ReadonlyArray"
|
||||
import { step } from "../steps"
|
||||
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
|
||||
|
||||
@@ -586,7 +586,9 @@ const parseOpenAPIDocContent = (str: string) =>
|
||||
)
|
||||
|
||||
export default defineImporter({
|
||||
id: "openapi",
|
||||
name: "import.from_openapi",
|
||||
applicableTo: ["my-collections", "team-collections", "url-import"],
|
||||
icon: "file",
|
||||
steps: [
|
||||
step({
|
||||
@@ -603,7 +605,6 @@ export default defineImporter({
|
||||
fileContent,
|
||||
parseOpenAPIDocContent,
|
||||
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT),
|
||||
|
||||
// Try validating, else the importer is invalid file format
|
||||
TE.chainW((obj) =>
|
||||
pipe(
|
||||
@@ -613,7 +614,6 @@ export default defineImporter({
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Deference the references
|
||||
TE.chainW((obj) =>
|
||||
pipe(
|
||||
@@ -623,7 +623,6 @@ export default defineImporter({
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
TE.chainW(convertOpenApiDocToHopp)
|
||||
),
|
||||
})
|
||||
|
||||
@@ -297,7 +297,9 @@ const getHoppFolder = (ig: ItemGroup<Item>): HoppCollection<HoppRESTRequest> =>
|
||||
export const getHoppCollection = (coll: PMCollection) => getHoppFolder(coll)
|
||||
|
||||
export default defineImporter({
|
||||
id: "postman",
|
||||
name: "import.from_postman",
|
||||
applicableTo: ["my-collections", "team-collections", "url-import"],
|
||||
icon: "postman",
|
||||
steps: [
|
||||
step({
|
||||
|
||||
@@ -132,13 +132,13 @@ export function createRESTNetworkRequestStream(
|
||||
),
|
||||
|
||||
// Assembling params object
|
||||
TE.bind("params", ({ req }) =>
|
||||
TE.of(
|
||||
req.effectiveFinalParams.reduce((acc, { key, value }) => {
|
||||
return Object.assign(acc, { [key]: value })
|
||||
}, {})
|
||||
)
|
||||
),
|
||||
TE.bind("params", ({ req }) => {
|
||||
const params = new URLSearchParams()
|
||||
req.effectiveFinalParams.forEach((x) => {
|
||||
params.append(x.key, x.value)
|
||||
})
|
||||
return TE.of(params)
|
||||
}),
|
||||
|
||||
// Keeping the backup start time
|
||||
TE.bind("backupTimeStart", () => TE.of(Date.now())),
|
||||
|
||||
@@ -21,7 +21,7 @@ const sendPostRequest = async (url, params) => {
|
||||
.map((key) => `${key}=${params[key]}`)
|
||||
.join("&")
|
||||
const options = {
|
||||
method: "post",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
},
|
||||
|
||||
223
packages/hoppscotch-app/helpers/realtime/MQTTConnection.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
84
packages/hoppscotch-app/helpers/realtime/SIOClients.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
163
packages/hoppscotch-app/helpers/realtime/SIOConnection.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
86
packages/hoppscotch-app/helpers/realtime/SSEConnection.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
102
packages/hoppscotch-app/helpers/realtime/WSConnection.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
8
packages/hoppscotch-app/helpers/shortcodes/Shortcode.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Defines how a Shortcode is represented in the ShortcodeListAdapter
|
||||
*/
|
||||
export interface Shortcode {
|
||||
id: string
|
||||
request: string
|
||||
createdOn: Date
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import axios, { AxiosRequestConfig } from "axios"
|
||||
import { v4 } from "uuid"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
import { NetworkResponse, NetworkStrategy } from "../network"
|
||||
import { decodeB64StringToArrayBuffer } from "../utils/b64"
|
||||
import { settingsStore } from "~/newstore/settings"
|
||||
@@ -40,19 +41,43 @@ const getProxyPayload = (
|
||||
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) =>
|
||||
pipe(
|
||||
TE.Do,
|
||||
|
||||
TE.bind("processedReq", () => TE.of(preProcessRequest(req))),
|
||||
|
||||
// If the request has FormData, the proxy needs a key
|
||||
TE.bind("multipartKey", () =>
|
||||
TE.of(req.data instanceof FormData ? v4() : null)
|
||||
TE.bind("multipartKey", ({ processedReq }) =>
|
||||
TE.of(processedReq.data instanceof FormData ? v4() : null)
|
||||
),
|
||||
|
||||
// Build headers to send
|
||||
TE.bind("headers", ({ multipartKey }) =>
|
||||
TE.bind("headers", ({ processedReq, multipartKey }) =>
|
||||
TE.of(
|
||||
req.data instanceof FormData
|
||||
processedReq.data instanceof FormData
|
||||
? <ProxyHeaders>{
|
||||
"multipart-part-key": `proxyRequestData-${multipartKey}`,
|
||||
}
|
||||
@@ -61,8 +86,8 @@ const axiosWithProxy: NetworkStrategy = (req) =>
|
||||
),
|
||||
|
||||
// Create payload
|
||||
TE.bind("payload", ({ multipartKey }) =>
|
||||
TE.of(getProxyPayload(req, multipartKey))
|
||||
TE.bind("payload", ({ processedReq, multipartKey }) =>
|
||||
TE.of(getProxyPayload(processedReq, multipartKey))
|
||||
),
|
||||
|
||||
// Run the proxy request
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { AxiosRequestConfig } from "axios"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
import { NetworkResponse, NetworkStrategy } from "../network"
|
||||
import { browserIsChrome, browserIsFirefox } from "../utils/userAgent"
|
||||
|
||||
@@ -13,30 +16,91 @@ export const hasFirefoxExtensionInstalled = () =>
|
||||
hasExtensionInstalled() && browserIsFirefox()
|
||||
|
||||
export const cancelRunningExtensionRequest = () => {
|
||||
if (
|
||||
hasExtensionInstalled() &&
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest
|
||||
) {
|
||||
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) =>
|
||||
pipe(
|
||||
TE.Do,
|
||||
|
||||
TE.bind("processedReq", () => TE.of(preProcessRequest(req))),
|
||||
|
||||
// Storeing backup timing data in case the extension does not have that info
|
||||
TE.bind("backupTimeDataStart", () => TE.of(new Date().getTime())),
|
||||
|
||||
// Run the request
|
||||
TE.bind("response", () =>
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({
|
||||
...req,
|
||||
wantsBinary: true,
|
||||
}) as Promise<NetworkResponse>,
|
||||
(err) => err as any
|
||||
TE.bind("response", ({ processedReq }) =>
|
||||
pipe(
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__,
|
||||
O.fromNullable,
|
||||
TE.fromOption(() => "NO_PW_EXT_HOOK" as const),
|
||||
TE.chain((extensionHook) =>
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
extensionHook.sendRequest({
|
||||
...processedReq,
|
||||
wantsBinary: true,
|
||||
}),
|
||||
(err) => err as any
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
@@ -122,13 +122,6 @@ describe("cancelRunningExtensionRequest", () => {
|
||||
cancelRunningExtensionRequest()
|
||||
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", () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export type HoppRealtimeLogLine = {
|
||||
prefix?: string
|
||||
payload: string
|
||||
source: string
|
||||
color?: string
|
||||
ts: string
|
||||
ts: number | undefined
|
||||
}
|
||||
|
||||
export type HoppRealtimeLog = HoppRealtimeLogLine[]
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
parseBodyEnvVariables,
|
||||
parseRawKeyValueEntries,
|
||||
Environment,
|
||||
HoppRESTHeader,
|
||||
HoppRESTParam,
|
||||
} from "@hoppscotch/data"
|
||||
import { arrayFlatMap, arraySort } from "../functional/array"
|
||||
import { toFormData } from "../functional/formData"
|
||||
@@ -29,6 +31,146 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
|
||||
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
|
||||
export const resolvesEnvsInBody = (
|
||||
body: HoppRESTReqBody,
|
||||
@@ -135,83 +277,29 @@ export function getEffectiveRESTRequest(
|
||||
): EffectiveHoppRESTRequest {
|
||||
const envVariables = [...environment.variables, ...getGlobalVariables()]
|
||||
|
||||
const effectiveFinalHeaders = request.headers
|
||||
.filter(
|
||||
(x) =>
|
||||
x.key !== "" && // Remove empty keys
|
||||
x.active // Only active
|
||||
)
|
||||
.map((x) => ({
|
||||
// Parse out environment template strings
|
||||
const effectiveFinalHeaders = pipe(
|
||||
getComputedHeaders(request, envVariables).map((h) => h.header),
|
||||
A.concat(request.headers),
|
||||
A.filter((x) => x.active && x.key !== ""),
|
||||
A.map((x) => ({
|
||||
active: true,
|
||||
key: parseTemplateString(x.key, 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,
|
||||
key: parseTemplateString(x.key, 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 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 {
|
||||
...request,
|
||||
|
||||
@@ -7,8 +7,8 @@ describe("wsValid", () => {
|
||||
})
|
||||
|
||||
test("returns true for valid URL with Hostname", () => {
|
||||
expect(wsValid("wss://hoppscotch-websocket.herokuapp.com/")).toBe(true)
|
||||
expect(wsValid("wss://hoppscotch-websocket.herokuapp.com")).toBe(true)
|
||||
expect(wsValid("wss://echo-websocket.hoppscotch.io/")).toBe(true)
|
||||
expect(wsValid("wss://echo-websocket.hoppscotch.io")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for invalid URL with IP address", () => {
|
||||
@@ -29,8 +29,8 @@ describe("wsValid", () => {
|
||||
})
|
||||
|
||||
test("returns true for wss protocol URLs", () => {
|
||||
expect(wsValid("wss://hoppscotch-websocket.herokuapp.com/")).toBe(true)
|
||||
expect(wsValid("wss://hoppscotch-websocket.herokuapp.com")).toBe(true)
|
||||
expect(wsValid("wss://echo-websocket.hoppscotch.io/")).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)
|
||||
})
|
||||
@@ -65,8 +65,8 @@ describe("httpValid", () => {
|
||||
})
|
||||
|
||||
test("returns false for non-http(s) protocol URLs", () => {
|
||||
expect(httpValid("wss://hoppscotch-websocket.herokuapp.com/")).toBe(false)
|
||||
expect(httpValid("wss://hoppscotch-websocket.herokuapp.com")).toBe(false)
|
||||
expect(httpValid("wss://echo-websocket.hoppscotch.io/")).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)
|
||||
})
|
||||
@@ -129,10 +129,8 @@ describe("socketioValid", () => {
|
||||
})
|
||||
|
||||
test("returns true for wss protocol URLs", () => {
|
||||
expect(socketioValid("wss://hoppscotch-websocket.herokuapp.com/")).toBe(
|
||||
true
|
||||
)
|
||||
expect(socketioValid("wss://hoppscotch-websocket.herokuapp.com")).toBe(true)
|
||||
expect(socketioValid("wss://echo-websocket.hoppscotch.io/")).toBe(true)
|
||||
expect(socketioValid("wss://echo-websocket.hoppscotch.io")).toBe(true)
|
||||
expect(socketioValid("wss://174.129.224.73/")).toBe(true)
|
||||
expect(socketioValid("wss://174.129.224.73")).toBe(true)
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
}
|
||||