feat: context menu (#3180)

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Nivedin
2023-08-02 20:52:16 +05:30
committed by GitHub
parent d1a564d5b8
commit 8970ff5c68
22 changed files with 1447 additions and 77 deletions

View File

@@ -151,6 +151,11 @@
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "Do you want to save changes made in this tab?",
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress." "sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
}, },
"context_menu": {
"set_environment_variable": "Set as variable",
"add_parameter": "Add to parameter",
"open_link_in_new_tab": "Open link in new tab"
},
"count": { "count": {
"header": "Header {count}", "header": "Header {count}",
"message": "Message {count}", "message": "Message {count}",
@@ -195,16 +200,23 @@
"created": "Environment created", "created": "Environment created",
"deleted": "Environment deletion", "deleted": "Environment deletion",
"edit": "Edit Environment", "edit": "Edit Environment",
"global": "Global",
"invalid_name": "Please provide a name for the environment", "invalid_name": "Please provide a name for the environment",
"my_environments": "My Environments", "my_environments": "My Environments",
"name": "Name",
"nested_overflow": "nested environment variables are limited to 10 levels", "nested_overflow": "nested environment variables are limited to 10 levels",
"new": "New Environment", "new": "New Environment",
"no_environment": "No environment", "no_environment": "No environment",
"no_environment_description": "No environments were selected. Choose what to do with the following variables.", "no_environment_description": "No environments were selected. Choose what to do with the following variables.",
"replace_with_variable": "Replace with variable",
"scope": "Scope",
"select": "Select environment", "select": "Select environment",
"set_as_environment": "Set as environment",
"team_environments": "Team Environments", "team_environments": "Team Environments",
"title": "Environments", "title": "Environments",
"updated": "Environment updated", "updated": "Environment updated",
"value": "Value",
"variable": "Variable",
"variable_list": "Variable List" "variable_list": "Variable List"
}, },
"error": { "error": {

View File

@@ -110,7 +110,7 @@
"@graphql-codegen/typescript-urql-graphcache": "^2.3.1", "@graphql-codegen/typescript-urql-graphcache": "^2.3.1",
"@graphql-codegen/urql-introspection": "^2.2.0", "@graphql-codegen/urql-introspection": "^2.2.0",
"@graphql-typed-document-node/core": "^3.1.1", "@graphql-typed-document-node/core": "^3.1.1",
"@iconify-json/lucide": "^1.1.40", "@iconify-json/lucide": "^1.1.109",
"@intlify/vite-plugin-vue-i18n": "^7.0.0", "@intlify/vite-plugin-vue-i18n": "^7.0.0",
"@relmify/jest-fp-ts": "^2.1.1", "@relmify/jest-fp-ts": "^2.1.1",
"@rushstack/eslint-patch": "^1.1.4", "@rushstack/eslint-patch": "^1.1.4",

View File

@@ -9,6 +9,7 @@ declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default'] AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default'] AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default'] AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default'] AppFooter: typeof import('./components/app/Footer.vue')['default']
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default'] AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
@@ -54,6 +55,7 @@ declare module '@vue/runtime-core' {
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default'] CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default'] CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default'] Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default'] EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default'] EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default'] EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
@@ -94,7 +96,6 @@ declare module '@vue/runtime-core' {
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'] HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow'] HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows'] HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default'] HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
@@ -133,10 +134,7 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default'] IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default']
<<<<<<< HEAD
IconLucideRss: typeof import('~icons/lucide/rss')['default'] IconLucideRss: typeof import('~icons/lucide/rss')['default']
=======
>>>>>>> 6db825779 (fix: firefox browser scrollbar issue)
IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']

View File

@@ -0,0 +1,76 @@
<template>
<div
ref="contextMenuRef"
class="fixed bg-popover shadow-lg transform translate-y-8 border border-dividerDark p-2 rounded"
:style="`top: ${position.top}px; left: ${position.left}px; z-index: 1000;`"
>
<div v-if="contextMenuOptions" class="flex flex-col">
<div
v-for="option in contextMenuOptions"
:key="option.id"
class="flex flex-col space-y-2"
>
<HoppSmartItem
v-if="option.text.type === 'text' && option.text"
:icon="option.icon"
:label="option.text.text"
@click="handleClick(option)"
/>
<component
:is="option.text.component"
v-else-if="option.text.type === 'custom'"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onClickOutside } from "@vueuse/core"
import { useService } from "dioc/vue"
import { ref, watch } from "vue"
import { ContextMenuResult, ContextMenuService } from "~/services/context-menu"
import { EnvironmentMenuService } from "~/services/context-menu/menu/environment.menu"
import { ParameterMenuService } from "~/services/context-menu/menu/parameter.menu"
import { URLMenuService } from "~/services/context-menu/menu/url.menu"
const props = defineProps<{
show: boolean
position: { top: number; left: number }
text: string | null
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const contextMenuRef = ref<any | null>(null)
const contextMenuOptions = ref<ContextMenuResult[]>([])
onClickOutside(contextMenuRef, () => {
emit("hide-modal")
})
const contextMenuService = useService(ContextMenuService)
useService(EnvironmentMenuService)
useService(ParameterMenuService)
useService(URLMenuService)
const handleClick = (option: { action: () => void }) => {
option.action()
emit("hide-modal")
}
watch(
() => [props.show, props.text],
(val) => {
if (val && props.text) {
const options = contextMenuService.getMenuFor(props.text)
contextMenuOptions.value = options
}
},
{ immediate: true }
)
</script>

View File

@@ -15,7 +15,10 @@
</div> </div>
</div> </div>
<div class="flex flex-col divide-y divide-dividerLight"> <div class="flex flex-col divide-y divide-dividerLight">
<HoppSmartPlaceholder v-if="isEmpty(shortcutsResults)"> <HoppSmartPlaceholder
v-if="isEmpty(shortcutsResults)"
:text="`${t('state.nothing_found')} ‟${filterText}”`"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<details <details

View File

@@ -0,0 +1,208 @@
<template>
<HoppSmartModal
v-if="show"
:title="t('environment.set_as_environment')"
@close="hideModal"
>
<template #body>
<div class="flex space-y-4 flex-1 flex-col">
<div class="flex items-center space-x-8 ml-2">
<label for="name" class="font-semibold min-w-10">{{
t("environment.name")
}}</label>
<input
v-model="name"
type="text"
:placeholder="t('environment.variable')"
class="input"
/>
</div>
<div class="flex items-center space-x-8 ml-2">
<label for="value" class="font-semibold min-w-10">{{
t("environment.value")
}}</label>
<input type="text" :value="value" class="input" />
</div>
<div class="flex items-center space-x-8 ml-2">
<label for="scope" class="font-semibold min-w-10">
{{ t("environment.scope") }}
</label>
<div
class="relative flex flex-1 flex-col border border-divider rounded focus-visible:border-dividerDark"
>
<EnvironmentsSelector v-model="scope" :is-scope-selector="true" />
</div>
</div>
<div v-if="replaceWithVariable" class="flex space-x-2 mt-3">
<div class="min-w-18" />
<HoppSmartCheckbox
:on="replaceWithVariable"
title="t('environment.replace_with_variable'))"
@change="replaceWithVariable = !replaceWithVariable"
/>
<label for="replaceWithVariable">
{{ t("environment.replace_with_variable") }}</label
>
</div>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="t('action.save')"
outline
@click="addEnvironment"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script lang="ts" setup>
import { Environment } from "@hoppscotch/data"
import { ref, watch } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { GQLError } from "~/helpers/backend/GQLClient"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import {
addEnvironmentVariable,
addGlobalEnvVariable,
} from "~/newstore/environments"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { updateTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
import { currentActiveTab } from "~/helpers/rest/tab"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
show: boolean
position: { top: number; left: number }
name: string
value: string
replaceWithVariable: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const hideModal = () => {
emit("hide-modal")
}
watch(
() => props.show,
(newVal) => {
if (!newVal) {
scope.value = {
type: "global",
}
name.value = ""
replaceWithVariable.value = false
}
}
)
type Scope =
| {
type: "global"
}
| {
type: "my-environment"
environment: Environment
index: number
}
| {
type: "team-environment"
environment: TeamEnvironment
}
const scope = ref<Scope>({
type: "global",
})
const replaceWithVariable = ref(false)
const name = ref("")
const addEnvironment = async () => {
if (!name.value) {
toast.error(`${t("environment.invalid_name")}`)
return
}
if (scope.value.type === "global") {
addGlobalEnvVariable({
key: name.value,
value: props.value,
})
toast.success(`${t("environment.updated")}`)
} else if (scope.value.type === "my-environment") {
addEnvironmentVariable(scope.value.index, {
key: name.value,
value: props.value,
})
toast.success(`${t("environment.updated")}`)
} else {
const newVariables = [
...scope.value.environment.environment.variables,
{
key: name.value,
value: props.value,
},
]
await pipe(
updateTeamEnvironment(
JSON.stringify(newVariables),
scope.value.environment.id,
scope.value.environment.environment.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
hideModal()
toast.success(`${t("environment.updated")}`)
}
)
)()
}
if (replaceWithVariable.value) {
//replace the current tab endpoint with the variable name with << and >>
const variableName = `<<${name.value}>>`
//replace the currenttab endpoint containing the value in the text with variablename
currentActiveTab.value.document.request.endpoint =
currentActiveTab.value.document.request.endpoint.replace(
props.value,
variableName
)
}
hideModal()
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "team_environment/not_found":
return t("team_environment.not_found")
case "Forbidden resource":
return t("profile.no_permission")
default:
return t("error.something_went_wrong")
}
}
}
</script>

View File

@@ -8,7 +8,7 @@
<span <span
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="`${t('environment.select')}`" :title="`${t('environment.select')}`"
class="bg-transparent border-b border-dividerLight select-wrapper" class="bg-transparent select-wrapper"
> >
<HoppButtonSecondary <HoppButtonSecondary
:icon="IconLayers" :icon="IconLayers"
@@ -22,6 +22,7 @@
class="flex-1 !justify-start pr-8 rounded-none" class="flex-1 !justify-start pr-8 rounded-none"
/> />
</span> </span>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="tippyActions" ref="tippyActions"
@@ -31,6 +32,7 @@
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<HoppSmartItem <HoppSmartItem
v-if="!isScopeSelector"
:label="`${t('environment.no_environment')}`" :label="`${t('environment.no_environment')}`"
:info-icon=" :info-icon="
selectedEnvironmentIndex.type === 'NO_ENV_SELECTED' selectedEnvironmentIndex.type === 'NO_ENV_SELECTED'
@@ -47,6 +49,21 @@
} }
" "
/> />
<HoppSmartItem
v-else-if="isScopeSelector && modelValue"
:label="t('environment.global')"
:icon="IconGlobe"
:info-icon="modelValue.type === 'global' ? IconCheck : undefined"
:active-info-icon="modelValue.type === 'global'"
@click="
() => {
$emit('update:modelValue', {
type: 'global',
})
hide()
}
"
/>
<HoppSmartTabs <HoppSmartTabs
v-model="selectedEnvTab" v-model="selectedEnvTab"
styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary" styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary"
@@ -61,11 +78,14 @@
:key="`gen-${index}`" :key="`gen-${index}`"
:icon="IconLayers" :icon="IconLayers"
:label="gen.name" :label="gen.name"
:info-icon="index === selectedEnv.index ? IconCheck : undefined" :info-icon="isEnvActive(index) ? IconCheck : undefined"
:active-info-icon="index === selectedEnv.index" :active-info-icon="isEnvActive(index)"
@click=" @click="
() => { () => {
selectedEnvironmentIndex = { type: 'MY_ENV', index: index } handleEnvironmentChange(index, {
type: 'my-environment',
environment: gen,
})
hide() hide()
} }
" "
@@ -96,18 +116,14 @@
:key="`gen-team-${index}`" :key="`gen-team-${index}`"
:icon="IconLayers" :icon="IconLayers"
:label="gen.environment.name" :label="gen.environment.name"
:info-icon=" :info-icon="isEnvActive(gen.id) ? IconCheck : undefined"
gen.id === selectedEnv.teamEnvID ? IconCheck : undefined :active-info-icon="isEnvActive(gen.id)"
"
:active-info-icon="gen.id === selectedEnv.teamEnvID"
@click=" @click="
() => { () => {
selectedEnvironmentIndex = { handleEnvironmentChange(index, {
type: 'TEAM_ENV', type: 'team-environment',
teamEnvID: gen.id, environment: gen,
teamID: gen.teamID, })
environment: gen.environment,
}
hide() hide()
} }
" "
@@ -136,9 +152,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, watch } from "vue" import { computed, onMounted, ref, watch } from "vue"
import IconCheck from "~icons/lucide/check" import IconCheck from "~icons/lucide/check"
import IconLayers from "~icons/lucide/layers" import IconLayers from "~icons/lucide/layers"
import IconGlobe from "~icons/lucide/globe"
import { TippyComponent } from "vue-tippy" import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import { GQLError } from "~/helpers/backend/GQLClient" import { GQLError } from "~/helpers/backend/GQLClient"
@@ -156,6 +173,31 @@ import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useLocalState } from "~/newstore/localstate" import { useLocalState } from "~/newstore/localstate"
import { onLoggedIn } from "~/composables/auth" import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql" import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { Environment } from "@hoppscotch/data"
type Scope =
| {
type: "global"
}
| {
type: "my-environment"
environment: Environment
index: number
}
| {
type: "team-environment"
environment: TeamEnvironment
}
const props = defineProps<{
isScopeSelector?: boolean
modelValue?: Scope
}>()
const emit = defineEmits<{
(e: "update:modelValue", data: Scope): void
}>()
const breakpoints = useBreakpoints(breakpointsTailwind) const breakpoints = useBreakpoints(breakpointsTailwind)
const mdAndLarger = breakpoints.greater("md") const mdAndLarger = breakpoints.greater("md")
@@ -170,6 +212,39 @@ const myEnvironments = useReadonlyStream(environments$, [])
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" }) const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const teamListFetched = ref(false)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
onLoggedIn(() => {
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
REMEMBERED_TEAM_ID.value = team.id
changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
})
}
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value) {
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) switchToTeamWorkspace(team)
}
}
}
)
// TeamEnv List Adapter
const teamEnvListAdapter = new TeamEnvironmentAdapter(undefined) const teamEnvListAdapter = new TeamEnvironmentAdapter(undefined)
const teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false) const teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false)
const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null) const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null)
@@ -204,63 +279,152 @@ watch(
} }
) )
// TeamList-Adapter const handleEnvironmentChange = (
const teamListAdapter = new TeamListAdapter(true) index: number,
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null) env?:
const teamListFetched = ref(false) | {
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID") type: "my-environment"
environment: Environment
onLoggedIn(() => { }
!teamListAdapter.isInitialized && teamListAdapter.initialize() | {
}) type: "team-environment"
environment: TeamEnvironment
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => { }
REMEMBERED_TEAM_ID.value = team.id ) => {
changeWorkspace({ if (props.isScopeSelector && env) {
teamID: team.id, if (env.type === "my-environment") {
teamName: team.name, emit("update:modelValue", {
type: "team", type: "my-environment",
}) environment: env.environment,
} index,
})
watch( } else if (env.type === "team-environment") {
() => myTeams.value, emit("update:modelValue", {
(newTeams) => { type: "team-environment",
if (newTeams && !teamListFetched.value) { environment: env.environment,
teamListFetched.value = true })
if (REMEMBERED_TEAM_ID.value) { }
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value) } else {
if (team) switchToTeamWorkspace(team) if (env && env.type === "my-environment") {
selectedEnvironmentIndex.value = {
type: "MY_ENV",
index,
}
} else if (env && env.type === "team-environment") {
selectedEnvironmentIndex.value = {
type: "TEAM_ENV",
teamEnvID: env.environment.id,
teamID: env.environment.teamID,
environment: env.environment.environment,
} }
} }
} }
) }
const isEnvActive = (id: string | number) => {
if (props.isScopeSelector) {
if (props.modelValue?.type === "my-environment") {
return props.modelValue.index === id
} else if (props.modelValue?.type === "team-environment") {
return (
props.modelValue?.type === "team-environment" &&
props.modelValue.environment &&
props.modelValue.environment.id === id
)
}
} else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return selectedEnv.value.index === id
} else {
return (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnv.value.teamEnvID === id
)
}
}
}
const selectedEnv = computed(() => { const selectedEnv = computed(() => {
if (selectedEnvironmentIndex.value.type === "MY_ENV") { if (props.isScopeSelector) {
return { if (props.modelValue?.type === "my-environment") {
type: "MY_ENV", return {
index: selectedEnvironmentIndex.value.index, type: "MY_ENV",
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name, index: props.modelValue.index,
} name: props.modelValue.environment?.name,
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") { }
const teamEnv = teamEnvironmentList.value.find( } else if (props.modelValue?.type === "team-environment") {
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
return { return {
type: "TEAM_ENV", type: "TEAM_ENV",
name: teamEnv.environment.name, name: props.modelValue.environment.environment.name,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID, teamEnvID: props.modelValue.environment.id,
}
} else {
return { type: "global", name: "Global" }
}
} else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return {
type: "MY_ENV",
index: selectedEnvironmentIndex.value.index,
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name,
}
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
return {
type: "TEAM_ENV",
name: teamEnv.environment.name,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
}
} else {
return { type: "NO_ENV_SELECTED" }
} }
} else { } else {
return { type: "NO_ENV_SELECTED" } return { type: "NO_ENV_SELECTED" }
} }
} else { }
return { type: "NO_ENV_SELECTED" } })
// Set the selected environment as initial scope value
onMounted(() => {
if (props.isScopeSelector) {
if (
selectedEnvironmentIndex.value.type === "MY_ENV" &&
selectedEnvironmentIndex.value.index !== undefined
) {
emit("update:modelValue", {
type: "my-environment",
environment: myEnvironments.value[selectedEnvironmentIndex.value.index],
index: selectedEnvironmentIndex.value.index,
})
} else if (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID &&
teamEnvironmentList.value &&
teamEnvironmentList.value.length > 0
) {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
emit("update:modelValue", {
type: "team-environment",
environment: teamEnv,
})
}
} else {
emit("update:modelValue", {
type: "global",
})
}
} }
}) })

View File

@@ -26,6 +26,13 @@
:editing-variable-name="editingVariableName" :editing-variable-name="editingVariableName"
@hide-modal="displayModalEdit(false)" @hide-modal="displayModalEdit(false)"
/> />
<EnvironmentsAdd
:show="showModalNew"
:name="editingVariableName"
:value="editingVariableValue"
:position="position"
@hide-modal="displayModalNew(false)"
/>
</div> </div>
</template> </template>
@@ -161,10 +168,18 @@ watch(
} }
) )
const showModalNew = ref(false)
const showModalDetails = ref(false) const showModalDetails = ref(false)
const action = ref<"new" | "edit">("edit") const action = ref<"new" | "edit">("edit")
const editingEnvironmentIndex = ref<"Global" | null>(null) const editingEnvironmentIndex = ref<"Global" | null>(null)
const editingVariableName = ref("") const editingVariableName = ref("")
const editingVariableValue = ref("")
const position = ref({ top: 0, left: 0 })
const displayModalNew = (shouldDisplay: boolean) => {
showModalNew.value = shouldDisplay
}
const displayModalEdit = (shouldDisplay: boolean) => { const displayModalEdit = (shouldDisplay: boolean) => {
action.value = "edit" action.value = "edit"
@@ -233,4 +248,10 @@ watch(
}, },
{ deep: true } { deep: true }
) )
defineActionHandler("modals.environment.add", ({ envName, variableName }) => {
editingVariableName.value = envName
editingVariableValue.value = variableName
displayModalNew(true)
})
</script> </script>

View File

@@ -61,7 +61,8 @@ import { useReadonlyStream } from "@composables/stream"
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments" import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
import { platform } from "~/platform" import { platform } from "~/platform"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import { onClickOutside } from "@vueuse/core" import { onClickOutside, useDebounceFn } from "@vueuse/core"
import { invokeAction } from "~/helpers/actions"
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -149,6 +150,11 @@ const handleKeystroke = (ev: KeyboardEvent) => {
ev.preventDefault() ev.preventDefault()
} }
if (ev.shiftKey) {
showSuggestionPopover.value = false
return
}
showSuggestionPopover.value = true showSuggestionPopover.value = true
if ( if (
@@ -299,8 +305,46 @@ const envVars = computed(() =>
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view) const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
const initView = (el: any) => { const initView = (el: any) => {
function handleTextSelection() {
const selection = view.value?.state.selection.main
if (selection) {
const from = selection.from
const to = selection.to
const text = view.value?.state.doc.sliceString(from, to)
const { top, left } = view.value?.coordsAtPos(from)
if (text) {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text,
})
showSuggestionPopover.value = false
} else {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text: null,
})
}
}
}
// Debounce to prevent double click from selecting the word
const debounceFn = useDebounceFn(() => {
handleTextSelection()
}, 140)
el.addEventListener("mouseup", debounceFn)
el.addEventListener("keyup", debounceFn)
const extensions: Extension = [ const extensions: Extension = [
EditorView.lineWrapping,
EditorView.contentAttributes.of({ "aria-label": props.placeholder }), EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (props.readonly) { if (props.readonly) {
update.view.contentDOM.inputMode = "none" update.view.contentDOM.inputMode = "none"
@@ -431,7 +475,7 @@ watch(editor, () => {
@apply border-b border-x border-divider; @apply border-b border-x border-divider;
@apply overflow-y-auto; @apply overflow-y-auto;
@apply -left-[1px]; @apply -left-[1px];
@apply right-0; @apply -right-[1px];
top: calc(100% + 1px); top: calc(100% + 1px);
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;

View File

@@ -40,6 +40,8 @@ import {
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment" import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
import xmlFormat from "xml-formatter" import xmlFormat from "xml-formatter"
import { platform } from "~/platform" import { platform } from "~/platform"
import { invokeAction } from "~/helpers/actions"
import { useDebounceFn } from "@vueuse/core"
// TODO: Migrate from legacy mode // TODO: Migrate from legacy mode
type ExtendedEditorConfig = { type ExtendedEditorConfig = {
@@ -218,6 +220,40 @@ export function useCodemirror(
ViewPlugin.fromClass( ViewPlugin.fromClass(
class { class {
update(update: ViewUpdate) { update(update: ViewUpdate) {
function handleTextSelection() {
const selection = view.value?.state.selection.main
if (selection) {
const from = selection.from
const to = selection.to
const text = view.value?.state.doc.sliceString(from, to)
const { top, left } = view.value?.coordsAtPos(from)
if (text) {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text,
})
} else {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text: null,
})
}
}
}
// Debounce to prevent double click from selecting the word
const debounceFn = useDebounceFn(() => {
handleTextSelection()
}, 140)
el.addEventListener("mouseup", debounceFn)
el.addEventListener("keyup", debounceFn)
const cursorPos = update.state.selection.main.head const cursorPos = update.state.selection.main.head
const line = update.state.doc.lineAt(cursorPos) const line = update.state.doc.lineAt(cursorPos)
@@ -276,6 +312,7 @@ export function useCodemirror(
run: indentLess, run: indentLess,
}, },
]), ]),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
] ]
if (environmentTooltip) extensions.push(environmentTooltip.extension) if (environmentTooltip) extensions.push(environmentTooltip.extension)

View File

@@ -8,6 +8,7 @@ import { HoppRESTDocument } from "./rest/document"
import { HoppGQLRequest } from "@hoppscotch/data" import { HoppGQLRequest } from "@hoppscotch/data"
export type HoppAction = export type HoppAction =
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
| "request.send-cancel" // Send/Cancel a Hoppscotch Request | "request.send-cancel" // Send/Cancel a Hoppscotch Request
| "request.reset" // Clear request data | "request.reset" // Clear request data
| "request.copy-link" // Copy Request Link | "request.copy-link" // Copy Request Link
@@ -24,6 +25,9 @@ export type HoppAction =
| "modals.search.toggle" // Shows the search modal | "modals.search.toggle" // Shows the search modal
| "modals.support.toggle" // Shows the support modal | "modals.support.toggle" // Shows the support modal
| "modals.share.toggle" // Shows the share modal | "modals.share.toggle" // Shows the share modal
| "modals.environment.add" // Show add environment modal via context menu
| "modals.my.environment.edit" // Edit current personal environment
| "modals.team.environment.edit" // Edit current team environment
| "navigation.jump.rest" // Jump to REST page | "navigation.jump.rest" // Jump to REST page
| "navigation.jump.graphql" // Jump to GraphQL page | "navigation.jump.graphql" // Jump to GraphQL page
| "navigation.jump.realtime" // Jump to realtime page | "navigation.jump.realtime" // Jump to realtime page
@@ -54,6 +58,13 @@ export type HoppAction =
* will know if you got something wrong if there is a type error in this file * will know if you got something wrong if there is a type error in this file
*/ */
type HoppActionArgsMap = { type HoppActionArgsMap = {
"contextmenu.open": {
position: {
top: number
left: number
}
text: string | null
}
"modals.my.environment.edit": { "modals.my.environment.edit": {
envName: string envName: string
variableName: string variableName: string
@@ -68,6 +79,10 @@ type HoppActionArgsMap = {
"gql.request.open": { "gql.request.open": {
request: HoppGQLRequest request: HoppGQLRequest
} }
"modals.environment.add": {
envName: string
variableName: string
}
} }
/** /**

View File

@@ -84,6 +84,13 @@
:show="savingRequest" :show="savingRequest"
@hide-modal="onSaveModalClose" @hide-modal="onSaveModalClose"
/> />
<AppContextMenu
v-if="contextMenu.show"
:show="contextMenu.show"
:position="contextMenu.position"
:text="contextMenu.text"
@hide-modal="contextMenu.show = false"
/>
</div> </div>
</template> </template>
@@ -138,6 +145,24 @@ const reqName = ref<string>("")
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
type PopupDetails = {
show: boolean
position: {
top: number
left: number
}
text: string | null
}
const contextMenu = ref<PopupDetails>({
show: false,
position: {
top: 0,
left: 0,
},
text: null,
})
const tabs = getActiveTabs() const tabs = getActiveTabs()
const confirmSync = useReadonlyStream(currentSyncingStatus$, { const confirmSync = useReadonlyStream(currentSyncingStatus$, {
@@ -365,6 +390,22 @@ function oAuthURL() {
}) })
} }
defineActionHandler("contextmenu.open", ({ position, text }) => {
if (text) {
contextMenu.value = {
show: true,
position,
text,
}
} else {
contextMenu.value = {
show: false,
position,
text,
}
}
})
setupTabStateSync() setupTabStateSync()
bindRequestToURLParams() bindRequestToURLParams()
oAuthURL() oAuthURL()

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi } from "vitest"
import { ContextMenu, ContextMenuResult, ContextMenuService } from "../"
import { TestContainer } from "dioc/testing"
const contextMenuResult: ContextMenuResult[] = [
{
id: "result1",
text: { type: "text", text: "Sample Text" },
icon: {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
action: () => {},
},
]
const testMenu: ContextMenu = {
menuID: "menu1",
getMenuFor: () => {
return {
results: contextMenuResult,
}
},
}
describe("ContextMenuService", () => {
describe("registerMenu", () => {
it("should register a menu", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
service.registerMenu(testMenu)
const result = service.getMenuFor("text")
expect(result).toContainEqual(expect.objectContaining({ id: "result1" }))
})
it("should not register a menu twice", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
service.registerMenu(testMenu)
service.registerMenu(testMenu)
const result = service.getMenuFor("text")
expect(result).toHaveLength(1)
})
it("should register multiple menus", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
const testMenu2: ContextMenu = {
menuID: "menu2",
getMenuFor: () => {
return {
results: contextMenuResult,
}
},
}
service.registerMenu(testMenu)
service.registerMenu(testMenu2)
const result = service.getMenuFor("text")
expect(result).toHaveLength(2)
})
})
describe("getMenuFor", () => {
it("should get the menu", () => {
const sampleMenus = {
results: contextMenuResult,
}
const container = new TestContainer()
const service = container.bind(ContextMenuService)
service.registerMenu(testMenu)
const results = service.getMenuFor("sometext")
expect(results).toEqual(sampleMenus.results)
})
it("calls registered menus with correct value", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
const testMenu2: ContextMenu = {
menuID: "some-id",
getMenuFor: vi.fn(() => ({
results: contextMenuResult,
})),
}
service.registerMenu(testMenu2)
service.getMenuFor("sometext")
expect(testMenu2.getMenuFor).toHaveBeenCalledWith("sometext")
})
it("should return empty array if no menus are registered", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
const results = service.getMenuFor("sometext")
expect(results).toEqual([])
})
})
})

View File

@@ -0,0 +1,109 @@
import { Service } from "dioc"
import { Component } from "vue"
/**
* Defines how to render the text in a Context Menu Search Result
*/
export type ContextMenuTextType<T extends object | Component = never> =
| {
type: "text"
text: string
}
| {
type: "custom"
/**
* The component to render in place of the text
*/
component: T
/**
* The props to pass to the component
*/
componentProps: T extends Component<infer Props> ? Props : never
}
/**
* Defines info about a context menu result so the UI can render it
*/
export interface ContextMenuResult {
/**
* The unique ID of the result
*/
id: string
/**
* The text to render in the result
*/
text: ContextMenuTextType<any>
/**
* The icon to render as the signifier of the result
*/
icon: object | Component
/**
* The action to perform when the result is selected
*/
action: () => void
/**
* Additional metadata about the result
*/
meta?: {
/**
* The keyboard shortcut to trigger the result
*/
keyboardShortcut?: string[]
}
}
/**
* Defines the state of a context menu
*/
export type ContextMenuState = {
results: ContextMenuResult[]
}
/**
* Defines a context menu
*/
export interface ContextMenu {
/**
* The unique ID of the context menu
* This is used to identify the context menu
*/
menuID: string
/**
* Gets the context menu for the given text
* @param text The text to get the context menu for
* @returns The context menu state
*/
getMenuFor: (text: string) => ContextMenuState
}
/**
* Defines the context menu service
* This service is used to register context menus and get context menus for text
* This service is used by the context menu UI
*/
export class ContextMenuService extends Service {
public static readonly ID = "CONTEXT_MENU_SERVICE"
private menus: Map<string, ContextMenu> = new Map()
/**
* Registers a menu with the context menu service
* @param menu The menu to register
*/
public registerMenu(menu: ContextMenu) {
this.menus.set(menu.menuID, menu)
}
/**
* Gets the context menu for the given text
* @param text The text to get the context menu for
*/
public getMenuFor(text: string): ContextMenuResult[] {
const menus = Array.from(this.menus.values()).map((x) => x.getMenuFor(text))
const result = menus.flatMap((x) => x.results)
return result
}
}

View File

@@ -0,0 +1,70 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { EnvironmentMenuService } from "../environment.menu"
import { ContextMenuService } from "../.."
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
const actionsMock = vi.hoisted(() => ({
invokeAction: vi.fn(),
}))
vi.mock("~/helpers/actions", async () => {
return {
__esModule: true,
invokeAction: actionsMock.invokeAction,
}
})
describe("EnvironmentMenuService", () => {
it("registers with the contextmenu service upon initialization", () => {
const container = new TestContainer()
const registerContextMenuFn = vi.fn()
container.bindMock(ContextMenuService, {
registerMenu: registerContextMenuFn,
})
const environment = container.bind(EnvironmentMenuService)
expect(registerContextMenuFn).toHaveBeenCalledOnce()
expect(registerContextMenuFn).toHaveBeenCalledWith(environment)
})
describe("getMenuFor", () => {
it("should return a menu for adding environment", () => {
const container = new TestContainer()
const environment = container.bind(EnvironmentMenuService)
const test = "some-text"
const result = environment.getMenuFor(test)
expect(result.results).toContainEqual(
expect.objectContaining({ id: "environment" })
)
})
it("should invoke the add environment modal", () => {
const container = new TestContainer()
const environment = container.bind(EnvironmentMenuService)
const test = "some-text"
const result = environment.getMenuFor(test)
const action = result.results[0].action
action()
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
expect(actionsMock.invokeAction).toHaveBeenCalledWith(
"modals.environment.add",
{
envName: "test",
variableName: test,
}
)
})
})
})

View File

@@ -0,0 +1,94 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { ContextMenuService } from "../.."
import { ParameterMenuService } from "../parameter.menu"
//regex containing both url and parameter
const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*")
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
const tabMock = vi.hoisted(() => ({
currentActiveTab: vi.fn(),
}))
vi.mock("~/helpers/rest/tab", () => ({
__esModule: true,
currentActiveTab: tabMock.currentActiveTab,
}))
describe("ParameterMenuService", () => {
it("registers with the contextmenu service upon initialization", () => {
const container = new TestContainer()
const registerContextMenuFn = vi.fn()
container.bindMock(ContextMenuService, {
registerMenu: registerContextMenuFn,
})
const parameter = container.bind(ParameterMenuService)
expect(registerContextMenuFn).toHaveBeenCalledOnce()
expect(registerContextMenuFn).toHaveBeenCalledWith(parameter)
describe("getMenuFor", () => {
it("validating if the text passes the regex and return the menu", () => {
const container = new TestContainer()
const parameter = container.bind(ParameterMenuService)
const test = "https://hoppscotch.io?id=some-text"
const result = parameter.getMenuFor(test)
if (test.match(urlAndParameterRegex)) {
expect(result.results).toContainEqual(
expect.objectContaining({ id: "parameter" })
)
} else {
expect(result.results).not.toContainEqual(
expect.objectContaining({ id: "parameter" })
)
}
})
it("should call the addParameter function when action is called", () => {
const addParameterFn = vi.fn()
const container = new TestContainer()
const environment = container.bind(ParameterMenuService)
const test = "https://hoppscotch.io"
const result = environment.getMenuFor(test)
const action = result.results[0].action
action()
expect(addParameterFn).toHaveBeenCalledOnce()
expect(addParameterFn).toHaveBeenCalledWith(action)
})
it("should call the extractParams function when addParameter function is called", () => {
const extractParamsFn = vi.fn()
const container = new TestContainer()
const environment = container.bind(ParameterMenuService)
const test = "https://hoppscotch.io"
const result = environment.getMenuFor(test)
const action = result.results[0].action
action()
expect(extractParamsFn).toHaveBeenCalledOnce()
expect(extractParamsFn).toHaveBeenCalledWith(action)
})
})
})
})

View File

@@ -0,0 +1,86 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { ContextMenuService } from "../.."
import { URLMenuService } from "../url.menu"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
const tabMock = vi.hoisted(() => ({
createNewTab: vi.fn(),
}))
vi.mock("~/helpers/rest/tab", () => ({
__esModule: true,
createNewTab: tabMock.createNewTab,
}))
describe("URLMenuService", () => {
it("registers with the contextmenu service upon initialization", () => {
const container = new TestContainer()
const registerContextMenuFn = vi.fn()
container.bindMock(ContextMenuService, {
registerMenu: registerContextMenuFn,
})
const environment = container.bind(URLMenuService)
expect(registerContextMenuFn).toHaveBeenCalledOnce()
expect(registerContextMenuFn).toHaveBeenCalledWith(environment)
})
describe("getMenuFor", () => {
it("validating if the text passes the regex and return the menu", () => {
function isValidURL(url: string) {
try {
new URL(url)
return true
} catch (error) {
// Fallback to regular expression check
const pattern = /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/
return pattern.test(url)
}
}
const container = new TestContainer()
const url = container.bind(URLMenuService)
const test = ""
const result = url.getMenuFor(test)
if (isValidURL(test)) {
expect(result.results).toContainEqual(
expect.objectContaining({ id: "link-tab" })
)
} else {
expect(result).toEqual({ results: [] })
}
})
it("should call the openNewTab function when action is called and a new hoppscotch tab is opened", () => {
const container = new TestContainer()
const url = container.bind(URLMenuService)
const test = "https://hoppscotch.io"
const result = url.getMenuFor(test)
result.results[0].action()
const request = {
...getDefaultRESTRequest(),
endpoint: test,
}
expect(tabMock.createNewTab).toHaveBeenCalledOnce()
expect(tabMock.createNewTab).toHaveBeenCalledWith({
request: request,
isDirty: false,
})
})
})
})

View File

@@ -0,0 +1,56 @@
import { Service } from "dioc"
import {
ContextMenu,
ContextMenuResult,
ContextMenuService,
ContextMenuState,
} from "../"
import { markRaw, ref } from "vue"
import { invokeAction } from "~/helpers/actions"
import IconPlusCircle from "~icons/lucide/plus-circle"
import { getI18n } from "~/modules/i18n"
/**
* This menu returns a single result that allows the user
* to add the selected text as an environment variable
* This menus is shown on all text selections
*/
export class EnvironmentMenuService extends Service implements ContextMenu {
public static readonly ID = "ENVIRONMENT_CONTEXT_MENU_SERVICE"
private t = getI18n()
public readonly menuID = "environment"
private readonly contextMenu = this.bind(ContextMenuService)
constructor() {
super()
this.contextMenu.registerMenu(this)
}
getMenuFor(text: Readonly<string>): ContextMenuState {
const results = ref<ContextMenuResult[]>([])
results.value = [
{
id: "environment",
text: {
type: "text",
text: this.t("context_menu.set_environment_variable"),
},
icon: markRaw(IconPlusCircle),
action: () => {
invokeAction("modals.environment.add", {
envName: "test",
variableName: text,
})
},
},
]
const resultObj = <ContextMenuState>{
results: results.value,
}
return resultObj
}
}

View File

@@ -0,0 +1,133 @@
import { Service } from "dioc"
import {
ContextMenu,
ContextMenuResult,
ContextMenuService,
ContextMenuState,
} from "../"
import { markRaw, ref } from "vue"
import IconArrowDownRight from "~icons/lucide/arrow-down-right"
import { currentActiveTab } from "~/helpers/rest/tab"
import { getI18n } from "~/modules/i18n"
//regex containing both url and parameter
const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*")
interface Param {
[key: string]: string
}
/**
* The extracted parameters from the input
* with the new URL if it was provided
*/
interface ExtractedParams {
params: Param
newURL?: string
}
/**
* This menu returns a single result that allows the user
* to add the selected text as a parameter
* if the selected text is a valid URL
*/
export class ParameterMenuService extends Service implements ContextMenu {
public static readonly ID = "PARAMETER_CONTEXT_MENU_SERVICE"
private t = getI18n()
public readonly menuID = "parameter"
private readonly contextMenu = this.bind(ContextMenuService)
constructor() {
super()
this.contextMenu.registerMenu(this)
}
/**
*
* @param input The input to extract the parameters from
* @returns The extracted parameters and the new URL if it was provided
*/
private extractParams(input: string): ExtractedParams {
let text = input
let newURL: string | undefined
// if the input is a URL, extract the parameters
if (text.startsWith("http")) {
const url = new URL(text)
newURL = url.origin + url.pathname
text = url.search.slice(1)
}
const regex = /(\w+)=(\w+)/g
const matches = text.matchAll(regex)
const params: Param = {}
// extract the parameters from the input
for (const match of matches) {
const [, key, value] = match
params[key] = value
}
return { params, newURL }
}
/**
* Adds the parameters from the input to the current request
* parameters and updates the endpoint if a new URL was provided
* @param text The input to extract the parameters from
*/
private addParameter(text: string) {
const { params, newURL } = this.extractParams(text)
const queryParams = []
for (const [key, value] of Object.entries(params)) {
queryParams.push({ key, value, active: true })
}
// add the parameters to the current request parameters
currentActiveTab.value.document.request.params = [
...currentActiveTab.value.document.request.params,
...queryParams,
]
if (newURL) {
currentActiveTab.value.document.request.endpoint = newURL
} else {
// remove the parameter from the URL
const textRegex = new RegExp(`\\b${text.replace(/\?/g, "")}\\b`, "gi")
const sanitizedWord = currentActiveTab.value.document.request.endpoint
const newURL = sanitizedWord.replace(textRegex, "")
currentActiveTab.value.document.request.endpoint = newURL
}
}
getMenuFor(text: Readonly<string>): ContextMenuState {
const results = ref<ContextMenuResult[]>([])
if (urlAndParameterRegex.test(text)) {
results.value = [
{
id: "environment",
text: {
type: "text",
text: this.t("context_menu.add_parameter"),
},
icon: markRaw(IconArrowDownRight),
action: () => {
this.addParameter(text)
},
},
]
}
const resultObj = <ContextMenuState>{
results: results.value,
}
return resultObj
}
}

View File

@@ -0,0 +1,89 @@
import { Service } from "dioc"
import {
ContextMenu,
ContextMenuResult,
ContextMenuService,
ContextMenuState,
} from ".."
import { markRaw, ref } from "vue"
import IconCopyPlus from "~icons/lucide/copy-plus"
import { createNewTab } from "~/helpers/rest/tab"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { getI18n } from "~/modules/i18n"
/**
* Used to check if a string is a valid URL
* @param url The string to check
* @returns Whether the string is a valid URL
*/
function isValidURL(url: string) {
try {
// Try to create a URL object
// this will fail for endpoints like "localhost:3000", ie without a protocol
new URL(url)
return true
} catch (error) {
// Fallback to regular expression check
const pattern = /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/
return pattern.test(url)
}
}
export class URLMenuService extends Service implements ContextMenu {
public static readonly ID = "URL_CONTEXT_MENU_SERVICE"
private t = getI18n()
public readonly menuID = "url"
private readonly contextMenu = this.bind(ContextMenuService)
constructor() {
super()
this.contextMenu.registerMenu(this)
}
/**
* Opens a new tab with the provided URL
* @param url The URL to open
*/
private openNewTab(url: string) {
//create a new request object
const request = {
...getDefaultRESTRequest(),
endpoint: url,
}
createNewTab({
request: request,
isDirty: false,
})
}
getMenuFor(text: Readonly<string>): ContextMenuState {
const results = ref<ContextMenuResult[]>([])
if (isValidURL(text)) {
results.value = [
{
id: "link-tab",
text: {
type: "text",
text: this.t("context_menu.open_link_in_new_tab"),
},
icon: markRaw(IconCopyPlus),
action: () => {
this.openNewTab(text)
},
},
]
}
const resultObj = <ContextMenuState>{
results: results.value,
}
return resultObj
}
}

View File

@@ -47,7 +47,7 @@
"@esbuild-plugins/node-globals-polyfill": "^0.1.1", "@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4", "@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@histoire/plugin-vue": "^0.12.4", "@histoire/plugin-vue": "^0.12.4",
"@iconify-json/lucide": "^1.1.40", "@iconify-json/lucide": "^1.1.109",
"@intlify/vite-plugin-vue-i18n": "^6.0.1", "@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@rushstack/eslint-patch": "^1.1.4", "@rushstack/eslint-patch": "^1.1.4",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",

14
pnpm-lock.yaml generated
View File

@@ -638,8 +638,8 @@ importers:
specifier: ^3.1.1 specifier: ^3.1.1
version: 3.1.1(graphql@15.8.0) version: 3.1.1(graphql@15.8.0)
'@iconify-json/lucide': '@iconify-json/lucide':
specifier: ^1.1.40 specifier: ^1.1.109
version: 1.1.40 version: 1.1.109
'@intlify/vite-plugin-vue-i18n': '@intlify/vite-plugin-vue-i18n':
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0(vite@3.1.4)(vue-i18n@9.2.2) version: 7.0.0(vite@3.1.4)(vue-i18n@9.2.2)
@@ -1240,8 +1240,8 @@ importers:
specifier: ^0.12.4 specifier: ^0.12.4
version: 0.12.4(histoire@0.12.4)(vite@3.2.4)(vue@3.2.45) version: 0.12.4(histoire@0.12.4)(vite@3.2.4)(vue@3.2.45)
'@iconify-json/lucide': '@iconify-json/lucide':
specifier: ^1.1.40 specifier: ^1.1.109
version: 1.1.40 version: 1.1.109
'@intlify/vite-plugin-vue-i18n': '@intlify/vite-plugin-vue-i18n':
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.0.1(vite@3.2.4) version: 6.0.1(vite@3.2.4)
@@ -5809,10 +5809,10 @@ packages:
/@iarna/toml@2.2.5: /@iarna/toml@2.2.5:
resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==}
/@iconify-json/lucide@1.1.40: /@iconify-json/lucide@1.1.109:
resolution: {integrity: sha512-4GeQtaiv3mJ+b0sn/c2KL8Tgf4XQvsX1AHDOseuGRhgoLCWG+ZdNRFxF5sp1I6T/VcQccegLPOp5XHn3NC1mmA==} resolution: {integrity: sha512-1+zYieiKUAjN1x66kvcRmmtgBJaDbD7i4To8mhB6+3bEm/i61un76nspJ45LOSGovzBMvYZFIJpqJrGMipWPzw==}
dependencies: dependencies:
'@iconify/types': 1.1.0 '@iconify/types': 2.0.0
dev: true dev: true
/@iconify/types@1.1.0: /@iconify/types@1.1.0: