feat: context menu (#3180)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user