From 8970ff5c68c07cb3da7ff15cb4d535f97c006e15 Mon Sep 17 00:00:00 2001 From: Nivedin <53208152+nivedin@users.noreply.github.com> Date: Wed, 2 Aug 2023 20:52:16 +0530 Subject: [PATCH] feat: context menu (#3180) Co-authored-by: Liyas Thomas --- packages/hoppscotch-common/locales/en.json | 12 + packages/hoppscotch-common/package.json | 2 +- .../hoppscotch-common/src/components.d.ts | 6 +- .../src/components/app/ContextMenu.vue | 76 +++++ .../src/components/app/Shortcuts.vue | 5 +- .../src/components/environments/Add.vue | 208 +++++++++++++ .../src/components/environments/Selector.vue | 286 ++++++++++++++---- .../src/components/environments/index.vue | 21 ++ .../src/components/smart/EnvInput.vue | 48 ++- .../src/composables/codemirror.ts | 37 +++ .../hoppscotch-common/src/helpers/actions.ts | 15 + .../hoppscotch-common/src/pages/index.vue | 41 +++ .../context-menu/__tests__/index.spec.ts | 114 +++++++ .../src/services/context-menu/index.ts | 109 +++++++ .../menu/__tests__/environment.menu.spec.ts | 70 +++++ .../menu/__tests__/parameter.menu.spec.ts | 94 ++++++ .../menu/__tests__/url.menu.spec.ts | 86 ++++++ .../context-menu/menu/environment.menu.ts | 56 ++++ .../context-menu/menu/parameter.menu.ts | 133 ++++++++ .../services/context-menu/menu/url.menu.ts | 89 ++++++ packages/hoppscotch-ui/package.json | 2 +- pnpm-lock.yaml | 14 +- 22 files changed, 1447 insertions(+), 77 deletions(-) create mode 100644 packages/hoppscotch-common/src/components/app/ContextMenu.vue create mode 100644 packages/hoppscotch-common/src/components/environments/Add.vue create mode 100644 packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts create mode 100644 packages/hoppscotch-common/src/services/context-menu/index.ts create mode 100644 packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts create mode 100644 packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts create mode 100644 packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts create mode 100644 packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts create mode 100644 packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts create mode 100644 packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 6a84716dc..cb30e4ebe 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -151,6 +151,11 @@ "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." }, + "context_menu": { + "set_environment_variable": "Set as variable", + "add_parameter": "Add to parameter", + "open_link_in_new_tab": "Open link in new tab" + }, "count": { "header": "Header {count}", "message": "Message {count}", @@ -195,16 +200,23 @@ "created": "Environment created", "deleted": "Environment deletion", "edit": "Edit Environment", + "global": "Global", "invalid_name": "Please provide a name for the environment", "my_environments": "My Environments", + "name": "Name", "nested_overflow": "nested environment variables are limited to 10 levels", "new": "New Environment", "no_environment": "No environment", "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", + "set_as_environment": "Set as environment", "team_environments": "Team Environments", "title": "Environments", "updated": "Environment updated", + "value": "Value", + "variable": "Variable", "variable_list": "Variable List" }, "error": { diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json index fa8c73d27..ab8d2ad12 100644 --- a/packages/hoppscotch-common/package.json +++ b/packages/hoppscotch-common/package.json @@ -110,7 +110,7 @@ "@graphql-codegen/typescript-urql-graphcache": "^2.3.1", "@graphql-codegen/urql-introspection": "^2.2.0", "@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", "@relmify/jest-fp-ts": "^2.1.1", "@rushstack/eslint-patch": "^1.1.4", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 914ec6108..849ec7515 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -9,6 +9,7 @@ declare module '@vue/runtime-core' { export interface GlobalComponents { AppActionHandler: typeof import('./components/app/ActionHandler.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'] AppFooter: typeof import('./components/app/Footer.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'] CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.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'] EnvironmentsMy: typeof import('./components/environments/my/index.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'] HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'] - HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle'] HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow'] HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows'] HttpAuthorization: typeof import('./components/http/Authorization.vue')['default'] @@ -133,10 +134,7 @@ declare module '@vue/runtime-core' { IconLucideLayers: typeof import('~icons/lucide/layers')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default'] -<<<<<<< HEAD IconLucideRss: typeof import('~icons/lucide/rss')['default'] -======= ->>>>>>> 6db825779 (fix: firefox browser scrollbar issue) IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] diff --git a/packages/hoppscotch-common/src/components/app/ContextMenu.vue b/packages/hoppscotch-common/src/components/app/ContextMenu.vue new file mode 100644 index 000000000..123d09202 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/ContextMenu.vue @@ -0,0 +1,76 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/Shortcuts.vue b/packages/hoppscotch-common/src/components/app/Shortcuts.vue index dc5d0e608..ec73ed19c 100644 --- a/packages/hoppscotch-common/src/components/app/Shortcuts.vue +++ b/packages/hoppscotch-common/src/components/app/Shortcuts.vue @@ -15,7 +15,10 @@
- +
+ + + + + + + diff --git a/packages/hoppscotch-common/src/components/environments/Selector.vue b/packages/hoppscotch-common/src/components/environments/Selector.vue index e960cf41a..4e25c7195 100644 --- a/packages/hoppscotch-common/src/components/environments/Selector.vue +++ b/packages/hoppscotch-common/src/components/environments/Selector.vue @@ -8,7 +8,7 @@ + @@ -138,6 +145,24 @@ const reqName = ref("") const t = useI18n() const toast = useToast() +type PopupDetails = { + show: boolean + position: { + top: number + left: number + } + text: string | null +} + +const contextMenu = ref({ + show: false, + position: { + top: 0, + left: 0, + }, + text: null, +}) + const tabs = getActiveTabs() 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() bindRequestToURLParams() oAuthURL() diff --git a/packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts b/packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts new file mode 100644 index 000000000..627775d08 --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts @@ -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([]) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/context-menu/index.ts b/packages/hoppscotch-common/src/services/context-menu/index.ts new file mode 100644 index 000000000..609ca7b64 --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/index.ts @@ -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 = + | { + 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 ? 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 + /** + * 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 = 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 + } +} diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts new file mode 100644 index 000000000..0f9a35923 --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts @@ -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, + } + ) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts new file mode 100644 index 000000000..bfd0fc70e --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts @@ -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) + }) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts new file mode 100644 index 000000000..5b4b0d31e --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts @@ -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, + }) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts b/packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts new file mode 100644 index 000000000..1882bda4b --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts @@ -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): ContextMenuState { + const results = ref([]) + 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 = { + results: results.value, + } + return resultObj + } +} diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts b/packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts new file mode 100644 index 000000000..ecb468ced --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts @@ -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): ContextMenuState { + const results = ref([]) + + 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 = { + results: results.value, + } + + return resultObj + } +} diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts b/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts new file mode 100644 index 000000000..fcec234ce --- /dev/null +++ b/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts @@ -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): ContextMenuState { + const results = ref([]) + + 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 = { + results: results.value, + } + + return resultObj + } +} diff --git a/packages/hoppscotch-ui/package.json b/packages/hoppscotch-ui/package.json index 79c5afeff..1c1a04929 100644 --- a/packages/hoppscotch-ui/package.json +++ b/packages/hoppscotch-ui/package.json @@ -47,7 +47,7 @@ "@esbuild-plugins/node-globals-polyfill": "^0.1.1", "@esbuild-plugins/node-modules-polyfill": "^0.1.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", "@rushstack/eslint-patch": "^1.1.4", "@types/lodash-es": "^4.17.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3919b203a..35c19d7ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -638,8 +638,8 @@ importers: specifier: ^3.1.1 version: 3.1.1(graphql@15.8.0) '@iconify-json/lucide': - specifier: ^1.1.40 - version: 1.1.40 + specifier: ^1.1.109 + version: 1.1.109 '@intlify/vite-plugin-vue-i18n': specifier: ^7.0.0 version: 7.0.0(vite@3.1.4)(vue-i18n@9.2.2) @@ -1240,8 +1240,8 @@ importers: specifier: ^0.12.4 version: 0.12.4(histoire@0.12.4)(vite@3.2.4)(vue@3.2.45) '@iconify-json/lucide': - specifier: ^1.1.40 - version: 1.1.40 + specifier: ^1.1.109 + version: 1.1.109 '@intlify/vite-plugin-vue-i18n': specifier: ^6.0.1 version: 6.0.1(vite@3.2.4) @@ -5809,10 +5809,10 @@ packages: /@iarna/toml@2.2.5: resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} - /@iconify-json/lucide@1.1.40: - resolution: {integrity: sha512-4GeQtaiv3mJ+b0sn/c2KL8Tgf4XQvsX1AHDOseuGRhgoLCWG+ZdNRFxF5sp1I6T/VcQccegLPOp5XHn3NC1mmA==} + /@iconify-json/lucide@1.1.109: + resolution: {integrity: sha512-1+zYieiKUAjN1x66kvcRmmtgBJaDbD7i4To8mhB6+3bEm/i61un76nspJ45LOSGovzBMvYZFIJpqJrGMipWPzw==} dependencies: - '@iconify/types': 1.1.0 + '@iconify/types': 2.0.0 dev: true /@iconify/types@1.1.0: