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

@@ -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
}
}