diff --git a/packages/dioc/.gitignore b/packages/dioc/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/packages/dioc/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/dioc/README.md b/packages/dioc/README.md new file mode 100644 index 000000000..506faa656 --- /dev/null +++ b/packages/dioc/README.md @@ -0,0 +1,141 @@ +# dioc + +A small and lightweight dependency injection / inversion of control system. + +### About + +`dioc` is a really simple **DI/IOC** system where you write services (which are singletons per container) that can depend on each other and emit events that can be listened upon. + +### Demo + +```ts +import { Service, Container } from "dioc" + +// Here is a simple service, which you can define by extending the Service class +// and providing an ID static field (of type string) +export class PersistenceService extends Service { + // This should be unique for each container + public static ID = "PERSISTENCE_SERVICE" + + public read(key: string): string | undefined { + // ... + } + + public write(key: string, value: string) { + // ... + } +} + +type TodoServiceEvent = + | { type: "TODO_CREATED"; index: number } + | { type: "TODO_DELETED"; index: number } + +// Services have a built in event system +// Define the generic argument to say what are the possible emitted values +export class TodoService extends Service { + public static ID = "TODO_SERVICE" + + // Inject persistence service into this service + private readonly persistence = this.bind(PersistenceService) + + public todos = [] + + // Service constructors cannot have arguments + constructor() { + super() + + this.todos = JSON.parse(this.persistence.read("todos") ?? "[]") + } + + public addTodo(text: string) { + // ... + + // You can access services via the bound fields + this.persistence.write("todos", JSON.stringify(this.todos)) + + // This is how you emit an event + this.emit({ + type: "TODO_CREATED", + index, + }) + } + + public removeTodo(index: number) { + // ... + + this.emit({ + type: "TODO_DELETED", + index, + }) + } +} + +// Services need a container to run in +const container = new Container() + +// You can initialize and get services using Container#bind +// It will automatically initialize the service (and its dependencies) +const todoService = container.bind(TodoService) // Returns an instance of TodoService +``` + +### Demo (Unit Test) + +`dioc/testing` contains `TestContainer` which lets you bind mocked services to the container. + +```ts +import { TestContainer } from "dioc/testing" +import { TodoService, PersistenceService } from "./demo.ts" // The above demo code snippet +import { describe, it, expect, vi } from "vitest" + +describe("TodoService", () => { + it("addTodo writes to persistence", () => { + const container = new TestContainer() + + const writeFn = vi.fn() + + // The first parameter is the service to mock and the second parameter + // is the mocked service fields and functions + container.bindMock(PersistenceService, { + read: () => undefined, // Not really important for this test + write: writeFn, + }) + + // the peristence service bind in TodoService will now use the + // above defined mocked implementation + const todoService = container.bind(TodoService) + + todoService.addTodo("sup") + + expect(writeFn).toHaveBeenCalledOnce() + expect(writeFn).toHaveBeenCalledWith("todos", JSON.stringify(["sup"])) + }) +}) +``` + +### Demo (Vue) + +`dioc/vue` contains a Vue Plugin and a `useService` composable that allows Vue components to use the defined services. + +In the app entry point: + +```ts +import { createApp } from "vue" +import { diocPlugin } from "dioc/vue" + +const app = createApp() + +app.use(diocPlugin, { + container: new Container(), // You can pass in the container you want to provide to the components here +}) +``` + +In your Vue components: + +```vue + +``` diff --git a/packages/dioc/index.d.ts b/packages/dioc/index.d.ts new file mode 100644 index 000000000..bbb9d0161 --- /dev/null +++ b/packages/dioc/index.d.ts @@ -0,0 +1,2 @@ +export { default } from "./dist/main.d.ts" +export * from "./dist/main.d.ts" diff --git a/packages/dioc/lib/container.ts b/packages/dioc/lib/container.ts new file mode 100644 index 000000000..bad74f3f5 --- /dev/null +++ b/packages/dioc/lib/container.ts @@ -0,0 +1,147 @@ +import { Service } from "./service" +import { Observable, Subject } from 'rxjs' + +/** + * Stores the current container instance in the current operating context. + * + * NOTE: This should not be used outside of dioc library code + */ +export let currentContainer: Container | null = null + +/** + * The events emitted by the container + * + * `SERVICE_BIND` - emitted when a service is bound to the container directly or as a dependency to another service + * `SERVICE_INIT` - emitted when a service is initialized + */ +export type ContainerEvent = + | { + type: 'SERVICE_BIND'; + + /** The Service ID of the service being bounded (the dependency) */ + boundeeID: string; + + /** + * The Service ID of the bounder that is binding the boundee (the dependent) + * + * NOTE: This will be undefined if the service is bound directly to the container + */ + bounderID: string | undefined + } + | { + type: 'SERVICE_INIT'; + + /** The Service ID of the service being initialized */ + serviceID: string + } + +/** + * The dependency injection container, allows for services to be initialized and maintains the dependency trees. + */ +export class Container { + /** Used during the `bind` operation to detect circular dependencies */ + private bindStack: string[] = [] + + /** The map of bound services to their IDs */ + protected boundMap = new Map>() + + /** The RxJS observable representing the event stream */ + protected event$ = new Subject() + + /** + * Returns whether a container has the given service bound + * @param service The service to check for + */ + public hasBound< + T extends typeof Service & { ID: string } + >(service: T): boolean { + return this.boundMap.has(service.ID) + } + + /** + * Returns the service bound to the container with the given ID or if not found, undefined. + * + * NOTE: This is an advanced method and should not be used as much as possible. + * + * @param serviceID The ID of the service to get + */ + public getBoundServiceWithID(serviceID: string): Service | undefined { + return this.boundMap.get(serviceID) + } + + /** + * Binds a service to the container. This is equivalent to marking a service as a dependency. + * @param service The class reference of a service to bind + * @param bounder The class reference of the service that is binding the service (if bound directly to the container, this should be undefined) + */ + public bind & { ID: string }>( + service: T, + bounder: ((typeof Service) & { ID: string }) | undefined = undefined + ): InstanceType { + // We need to store the current container in a variable so that we can restore it after the bind operation + const oldCurrentContainer = currentContainer; + currentContainer = this; + + // If the service is already bound, return the existing instance + if (this.hasBound(service)) { + this.event$.next({ + type: 'SERVICE_BIND', + boundeeID: service.ID, + bounderID: bounder?.ID // Return the bounder ID if it is defined, else assume its the container + }) + + return this.boundMap.get(service.ID) as InstanceType // Casted as InstanceType because service IDs and types are expected to match + } + + // Detect circular dependency and throw error + if (this.bindStack.findIndex((serviceID) => serviceID === service.ID) !== -1) { + const circularServices = `${this.bindStack.join(' -> ')} -> ${service.ID}` + + throw new Error(`Circular dependency detected.\nChain: ${circularServices}`) + } + + // Push the service ID onto the bind stack to detect circular dependencies + this.bindStack.push(service.ID) + + // Initialize the service and emit events + + // NOTE: We need to cast the service to any as TypeScript thinks that the service is abstract + const instance: Service = new (service as any)() + + this.boundMap.set(service.ID, instance) + + this.bindStack.pop() + + this.event$.next({ + type: 'SERVICE_INIT', + serviceID: service.ID, + }) + + this.event$.next({ + type: 'SERVICE_BIND', + boundeeID: service.ID, + bounderID: bounder?.ID + }) + + + // Restore the current container + currentContainer = oldCurrentContainer; + + // We expect the return type to match the service definition + return instance as InstanceType + } + + /** + * Returns an iterator of the currently bound service IDs and their instances + */ + public getBoundServices(): IterableIterator<[string, Service]> { + return this.boundMap.entries() + } + + /** + * Returns the public container event stream + */ + public getEventStream(): Observable { + return this.event$.asObservable() + } +} diff --git a/packages/dioc/lib/main.ts b/packages/dioc/lib/main.ts new file mode 100644 index 000000000..a6882c575 --- /dev/null +++ b/packages/dioc/lib/main.ts @@ -0,0 +1,2 @@ +export * from "./container" +export * from "./service" diff --git a/packages/dioc/lib/service.ts b/packages/dioc/lib/service.ts new file mode 100644 index 000000000..1e9cb6b8f --- /dev/null +++ b/packages/dioc/lib/service.ts @@ -0,0 +1,65 @@ +import { Observable, Subject } from 'rxjs' +import { Container, currentContainer } from './container' + +/** + * A Dioc service that can bound to a container and can bind dependency services. + * + * NOTE: Services cannot have a constructor that takes arguments. + * + * @template EventDef The type of events that can be emitted by the service. These will be accessible by event streams + */ +export abstract class Service { + + /** + * The internal event stream of the service + */ + private event$ = new Subject() + + /** The container the service is bound to */ + #container: Container + + constructor() { + if (!currentContainer) { + throw new Error( + `Tried to initialize service with no container (ID: ${ (this.constructor as any).ID })` + ) + } + + this.#container = currentContainer + } + + /** + * Binds a dependency service into this service. + * @param service The class reference of the service to bind + */ + protected bind & { ID: string }>(service: T): InstanceType { + if (!currentContainer) { + throw new Error('No currentContainer defined.') + } + + return currentContainer.bind(service, this.constructor as typeof Service & { ID: string }) + } + + /** + * Returns the container the service is bound to + */ + protected getContainer(): Container { + return this.#container + } + + /** + * Emits an event on the service's event stream + * @param event The event to emit + */ + protected emit(event: EventDef) { + this.event$.next(event) + } + + /** + * Returns the event stream of the service + */ + public getEventStream(): Observable { + + return this.event$.asObservable() + } +} diff --git a/packages/dioc/lib/testing.ts b/packages/dioc/lib/testing.ts new file mode 100644 index 000000000..0464f036b --- /dev/null +++ b/packages/dioc/lib/testing.ts @@ -0,0 +1,33 @@ +import { Container, Service } from "./main"; + +/** + * A container that can be used for writing tests, contains additional methods + * for binding suitable for writing tests. (see `bindMock`). + */ +export class TestContainer extends Container { + + /** + * Binds a mock service to the container. + * + * @param service + * @param mock + */ + public bindMock< + T extends typeof Service & { ID: string }, + U extends Partial> + >(service: T, mock: U): U { + if (this.boundMap.has(service.ID)) { + throw new Error(`Service '${service.ID}' already bound to container. Did you already call bindMock on this ?`) + } + + this.boundMap.set(service.ID, mock as any) + + this.event$.next({ + type: "SERVICE_BIND", + boundeeID: service.ID, + bounderID: undefined, + }) + + return mock + } +} diff --git a/packages/dioc/lib/vue.ts b/packages/dioc/lib/vue.ts new file mode 100644 index 000000000..1342010f4 --- /dev/null +++ b/packages/dioc/lib/vue.ts @@ -0,0 +1,34 @@ +import { Plugin, inject } from "vue" +import { Container } from "./container" +import { Service } from "./service" + +const VUE_CONTAINER_KEY = Symbol() + +// TODO: Some Vue version issue with plugin generics is breaking type checking +/** + * The Vue Dioc Plugin, this allows the composables to work and access the container + * + * NOTE: Make sure you add `vue` as dependency to be able to use this plugin (duh) + */ +export const diocPlugin: Plugin = { + install(app, { container }) { + app.provide(VUE_CONTAINER_KEY, container) + } +} + +/** + * A composable that binds a service to a Vue Component + * + * @param service The class reference of the service to bind + */ +export function useService< + T extends typeof Service & { ID: string } +>(service: T): InstanceType { + const container = inject(VUE_CONTAINER_KEY) as Container | undefined | null + + if (!container) { + throw new Error("Container not found, did you forget to install the dioc plugin?") + } + + return container.bind(service) +} diff --git a/packages/dioc/package.json b/packages/dioc/package.json new file mode 100644 index 000000000..0a0f4634c --- /dev/null +++ b/packages/dioc/package.json @@ -0,0 +1,54 @@ +{ + "name": "dioc", + "private": true, + "version": "0.1.0", + "type": "module", + "files": [ + "dist", + "index.d.ts" + ], + "main": "./dist/counter.umd.cjs", + "module": "./dist/counter.js", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./dist/main.d.ts", + "require": "./dist/index.cjs", + "import": "./dist/index.js" + }, + "./vue": { + "types": "./dist/vue.d.ts", + "require": "./dist/vue.cjs", + "import": "./dist/vue.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "require": "./dist/testing.cjs", + "import": "./dist/testing.js" + } + }, + "scripts": { + "dev": "vite", + "build": "vite build && tsc --emitDeclarationOnly", + "prepare": "pnpm run build", + "test": "vitest run", + "do-test": "pnpm run test", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^4.9.4", + "vite": "^4.0.4", + "vitest": "^0.29.3" + }, + "dependencies": { + "rxjs": "^7.8.1" + }, + "peerDependencies": { + "vue": "^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } +} diff --git a/packages/dioc/test/container.spec.ts b/packages/dioc/test/container.spec.ts new file mode 100644 index 000000000..f70e6e677 --- /dev/null +++ b/packages/dioc/test/container.spec.ts @@ -0,0 +1,262 @@ +import { it, expect, describe, vi } from "vitest" +import { Service } from "../lib/service" +import { Container, currentContainer, ContainerEvent } from "../lib/container" + +class TestServiceA extends Service { + public static ID = "TestServiceA" +} + +class TestServiceB extends Service { + public static ID = "TestServiceB" + + // Marked public to allow for testing + public readonly serviceA = this.bind(TestServiceA) +} + +describe("Container", () => { + describe("getBoundServiceWithID", () => { + it("returns the service instance if it is bound to the container", () => { + const container = new Container() + + const service = container.bind(TestServiceA) + + expect(container.getBoundServiceWithID(TestServiceA.ID)).toBe(service) + }) + + it("returns undefined if the service is not bound to the container", () => { + const container = new Container() + + expect(container.getBoundServiceWithID(TestServiceA.ID)).toBeUndefined() + }) + }) + + describe("bind", () => { + it("correctly binds the service to it", () => { + const container = new Container() + + const service = container.bind(TestServiceA) + + // @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check + expect(service.getContainer()).toBe(container) + }) + + it("after bind, the current container is set back to its previous value", () => { + const originalValue = currentContainer + + const container = new Container() + container.bind(TestServiceA) + + expect(currentContainer).toBe(originalValue) + }) + + it("dependent services are registered in the same container", () => { + const container = new Container() + + const serviceB = container.bind(TestServiceB) + + // @ts-expect-error getContainer is defined as a protected property, but we are leveraging it here to check + expect(serviceB.serviceA.getContainer()).toBe(container) + }) + + it("binding an already initialized service returns the initialized instance (services are singletons)", () => { + const container = new Container() + + const serviceA = container.bind(TestServiceA) + const serviceA2 = container.bind(TestServiceA) + + expect(serviceA).toBe(serviceA2) + }) + + it("binding a service which is a dependency of another service returns the same instance created from the dependency resolution (services are singletons)", () => { + const container = new Container() + + const serviceB = container.bind(TestServiceB) + const serviceA = container.bind(TestServiceA) + + expect(serviceB.serviceA).toBe(serviceA) + }) + + it("binding an initialized service as a dependency returns the same instance", () => { + const container = new Container() + + const serviceA = container.bind(TestServiceA) + const serviceB = container.bind(TestServiceB) + + expect(serviceB.serviceA).toBe(serviceA) + }) + + it("container emits an init event when an uninitialized service is initialized via bind and event only called once", () => { + const container = new Container() + + const serviceFunc = vi.fn< + [ContainerEvent & { type: "SERVICE_INIT" }], + void + >() + + container.getEventStream().subscribe((ev) => { + if (ev.type === "SERVICE_INIT") { + serviceFunc(ev) + } + }) + + const instance = container.bind(TestServiceA) + + expect(serviceFunc).toHaveBeenCalledOnce() + expect(serviceFunc).toHaveBeenCalledWith({ + type: "SERVICE_INIT", + serviceID: TestServiceA.ID, + }) + }) + + it("the bind event emitted has an undefined bounderID when the service is bound directly to the container", () => { + const container = new Container() + + const serviceFunc = vi.fn< + [ContainerEvent & { type: "SERVICE_BIND" }], + void + >() + + container.getEventStream().subscribe((ev) => { + if (ev.type === "SERVICE_BIND") { + serviceFunc(ev) + } + }) + + container.bind(TestServiceA) + + expect(serviceFunc).toHaveBeenCalledOnce() + expect(serviceFunc).toHaveBeenCalledWith({ + type: "SERVICE_BIND", + boundeeID: TestServiceA.ID, + bounderID: undefined, + }) + }) + + it("the bind event emitted has the correct bounderID when the service is bound to another service", () => { + const container = new Container() + + const serviceFunc = vi.fn< + [ContainerEvent & { type: "SERVICE_BIND" }], + void + >() + + container.getEventStream().subscribe((ev) => { + // We only care about the bind event of TestServiceA + if (ev.type === "SERVICE_BIND" && ev.boundeeID === TestServiceA.ID) { + serviceFunc(ev) + } + }) + + container.bind(TestServiceB) + + expect(serviceFunc).toHaveBeenCalledOnce() + expect(serviceFunc).toHaveBeenCalledWith({ + type: "SERVICE_BIND", + boundeeID: TestServiceA.ID, + bounderID: TestServiceB.ID, + }) + }) + }) + + describe("hasBound", () => { + it("returns true if the given service is bound to the container", () => { + const container = new Container() + + container.bind(TestServiceA) + + expect(container.hasBound(TestServiceA)).toEqual(true) + }) + + it("returns false if the given service is not bound to the container", () => { + const container = new Container() + + expect(container.hasBound(TestServiceA)).toEqual(false) + }) + + it("returns true when the service is bound because it is a dependency of another service", () => { + const container = new Container() + + container.bind(TestServiceB) + + expect(container.hasBound(TestServiceA)).toEqual(true) + }) + }) + + describe("getEventStream", () => { + it("returns an observable which emits events correctly when services are initialized", () => { + const container = new Container() + + const serviceFunc = vi.fn< + [ContainerEvent & { type: "SERVICE_INIT" }], + void + >() + + container.getEventStream().subscribe((ev) => { + if (ev.type === "SERVICE_INIT") { + serviceFunc(ev) + } + }) + + container.bind(TestServiceB) + + expect(serviceFunc).toHaveBeenCalledTimes(2) + expect(serviceFunc).toHaveBeenNthCalledWith(1, { + type: "SERVICE_INIT", + serviceID: TestServiceA.ID, + }) + expect(serviceFunc).toHaveBeenNthCalledWith(2, { + type: "SERVICE_INIT", + serviceID: TestServiceB.ID, + }) + }) + + it("returns an observable which emits events correctly when services are bound", () => { + const container = new Container() + + const serviceFunc = vi.fn< + [ContainerEvent & { type: "SERVICE_BIND" }], + void + >() + + container.getEventStream().subscribe((ev) => { + if (ev.type === "SERVICE_BIND") { + serviceFunc(ev) + } + }) + + container.bind(TestServiceB) + + expect(serviceFunc).toHaveBeenCalledTimes(2) + expect(serviceFunc).toHaveBeenNthCalledWith(1, { + type: "SERVICE_BIND", + boundeeID: TestServiceA.ID, + bounderID: TestServiceB.ID, + }) + expect(serviceFunc).toHaveBeenNthCalledWith(2, { + type: "SERVICE_BIND", + boundeeID: TestServiceB.ID, + bounderID: undefined, + }) + }) + }) + + describe("getBoundServices", () => { + it("returns an iterator over all services bound to the container in the format [service id, service instance]", () => { + const container = new Container() + + const instanceB = container.bind(TestServiceB) + const instanceA = instanceB.serviceA + + expect(Array.from(container.getBoundServices())).toEqual([ + [TestServiceA.ID, instanceA], + [TestServiceB.ID, instanceB], + ]) + }) + + it("returns an empty iterator if no services are bound", () => { + const container = new Container() + + expect(Array.from(container.getBoundServices())).toEqual([]) + }) + }) +}) diff --git a/packages/dioc/test/service.spec.ts b/packages/dioc/test/service.spec.ts new file mode 100644 index 000000000..e25524086 --- /dev/null +++ b/packages/dioc/test/service.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest" +import { Service, Container } from "../lib/main" + +class TestServiceA extends Service { + public static ID = "TestServiceA" +} + +class TestServiceB extends Service<"test"> { + public static ID = "TestServiceB" + + // Marked public to allow for testing + public readonly serviceA = this.bind(TestServiceA) + + public emitTestEvent() { + this.emit("test") + } +} + +describe("Service", () => { + describe("constructor", () => { + it("throws an error if the service is initialized without a container", () => { + expect(() => new TestServiceA()).toThrowError( + "Tried to initialize service with no container (ID: TestServiceA)" + ) + }) + }) + + describe("bind", () => { + it("correctly binds the dependency service using the container", () => { + const container = new Container() + + const serviceA = container.bind(TestServiceA) + + const serviceB = container.bind(TestServiceB) + expect(serviceB.serviceA).toBe(serviceA) + }) + }) + + describe("getContainer", () => { + it("returns the container the service is bound to", () => { + const container = new Container() + + const serviceA = container.bind(TestServiceA) + + // @ts-expect-error getContainer is a protected member, we are just using it to help with testing + expect(serviceA.getContainer()).toBe(container) + }) + }) + + describe("getEventStream", () => { + it("returns the valid event stream of the service", () => { + const container = new Container() + + const serviceB = container.bind(TestServiceB) + + const serviceFunc = vi.fn() + + serviceB.getEventStream().subscribe(serviceFunc) + + serviceB.emitTestEvent() + + expect(serviceFunc).toHaveBeenCalledOnce() + expect(serviceFunc).toHaveBeenCalledWith("test") + }) + }) +}) diff --git a/packages/dioc/test/test-container.spec.ts b/packages/dioc/test/test-container.spec.ts new file mode 100644 index 000000000..b51914d19 --- /dev/null +++ b/packages/dioc/test/test-container.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest" +import { TestContainer } from "../lib/testing" +import { Service } from "../lib/service" +import { ContainerEvent } from "../lib/container" + +class TestServiceA extends Service { + public static ID = "TestServiceA" + + public test() { + return "real" + } +} + +class TestServiceB extends Service { + public static ID = "TestServiceB" + + // declared public to help with testing + public readonly serviceA = this.bind(TestServiceA) + + public test() { + return this.serviceA.test() + } +} + +describe("TestContainer", () => { + describe("bindMock", () => { + it("returns the fake service defined", () => { + const container = new TestContainer() + + const fakeService = { + test: () => "fake", + } + + const result = container.bindMock(TestServiceA, fakeService) + + expect(result).toBe(fakeService) + }) + + it("new services bound to the container get the mock service", () => { + const container = new TestContainer() + + const fakeServiceA = { + test: () => "fake", + } + + container.bindMock(TestServiceA, fakeServiceA) + + const serviceB = container.bind(TestServiceB) + + expect(serviceB.serviceA).toBe(fakeServiceA) + }) + + it("container emits SERVICE_BIND event", () => { + const container = new TestContainer() + + const fakeServiceA = { + test: () => "fake", + } + + const serviceFunc = vi.fn<[ContainerEvent, void]>() + + container.getEventStream().subscribe((ev) => { + serviceFunc(ev) + }) + + container.bindMock(TestServiceA, fakeServiceA) + + expect(serviceFunc).toHaveBeenCalledOnce() + expect(serviceFunc).toHaveBeenCalledWith({ + type: "SERVICE_BIND", + boundeeID: TestServiceA.ID, + bounderID: undefined, + }) + }) + + it("throws if service already bound", () => { + const container = new TestContainer() + + const fakeServiceA = { + test: () => "fake", + } + + container.bindMock(TestServiceA, fakeServiceA) + + expect(() => { + container.bindMock(TestServiceA, fakeServiceA) + }).toThrowError( + "Service 'TestServiceA' already bound to container. Did you already call bindMock on this ?" + ) + }) + }) +}) diff --git a/packages/dioc/testing.d.ts b/packages/dioc/testing.d.ts new file mode 100644 index 000000000..f0219165a --- /dev/null +++ b/packages/dioc/testing.d.ts @@ -0,0 +1,2 @@ +export { default } from "./dist/testing.d.ts" +export * from "./dist/testing.d.ts" diff --git a/packages/dioc/tsconfig.json b/packages/dioc/tsconfig.json new file mode 100644 index 000000000..8033b856e --- /dev/null +++ b/packages/dioc/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true + }, + "include": ["lib"] +} diff --git a/packages/dioc/vite.config.ts b/packages/dioc/vite.config.ts new file mode 100644 index 000000000..51388192e --- /dev/null +++ b/packages/dioc/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + lib: { + entry: { + index: './lib/main.ts', + vue: './lib/vue.ts', + testing: './lib/testing.ts', + }, + }, + rollupOptions: { + external: ['vue'], + } + }, +}) diff --git a/packages/dioc/vitest.config.ts b/packages/dioc/vitest.config.ts new file mode 100644 index 000000000..1d45d7226 --- /dev/null +++ b/packages/dioc/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + + } +}) diff --git a/packages/dioc/vue.d.ts b/packages/dioc/vue.d.ts new file mode 100644 index 000000000..63ea4bff4 --- /dev/null +++ b/packages/dioc/vue.d.ts @@ -0,0 +1,2 @@ +export { default } from "./dist/vue.d.ts" +export * from "./dist/vue.d.ts" diff --git a/packages/hoppscotch-backend/src/admin/admin.resolver.ts b/packages/hoppscotch-backend/src/admin/admin.resolver.ts index 12838963a..d8832071f 100644 --- a/packages/hoppscotch-backend/src/admin/admin.resolver.ts +++ b/packages/hoppscotch-backend/src/admin/admin.resolver.ts @@ -411,6 +411,23 @@ export class AdminResolver { return deletedTeam.right; } + @Mutation(() => Boolean, { + description: 'Revoke a team Invite by Invite ID', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async revokeTeamInviteByAdmin( + @Args({ + name: 'inviteID', + description: 'Team Invite ID', + type: () => ID, + }) + inviteID: string, + ): Promise { + const invite = await this.adminService.revokeTeamInviteByID(inviteID); + if (E.isLeft(invite)) throwErr(invite.left); + return true; + } + /* Subscriptions */ @Subscription(() => InvitedUser, { diff --git a/packages/hoppscotch-backend/src/admin/admin.service.ts b/packages/hoppscotch-backend/src/admin/admin.service.ts index ab1f1dc9c..1ac4102ae 100644 --- a/packages/hoppscotch-backend/src/admin/admin.service.ts +++ b/packages/hoppscotch-backend/src/admin/admin.service.ts @@ -11,6 +11,7 @@ import { INVALID_EMAIL, ONLY_ONE_ADMIN_ACCOUNT, TEAM_INVITE_ALREADY_MEMBER, + TEAM_INVITE_NO_INVITE_FOUND, USER_ALREADY_INVITED, USER_IS_ADMIN, USER_NOT_FOUND, @@ -416,4 +417,19 @@ export class AdminService { if (E.isLeft(team)) return E.left(team.left); return E.right(team.right); } + + /** + * Revoke a team invite by ID + * @param inviteID Team Invite ID + * @returns an Either of boolean or error + */ + async revokeTeamInviteByID(inviteID: string) { + const teamInvite = await this.teamInvitationService.revokeInvitation( + inviteID, + ); + + if (E.isLeft(teamInvite)) return E.left(teamInvite.left); + + return E.right(teamInvite.right); + } } diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts b/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts index 8be6f8a99..ad4f08d77 100644 --- a/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts +++ b/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts @@ -15,7 +15,7 @@ import { TeamService } from 'src/team/team.service'; * This guard only allows user to execute the resolver * 1. If user is invitee, allow * 2. Or else, if user is team member, allow - * + * * TLDR: Allow if user is invitee or team member */ @Injectable() diff --git a/packages/hoppscotch-common/.eslintrc.js b/packages/hoppscotch-common/.eslintrc.js index d53b00aca..6b720a289 100644 --- a/packages/hoppscotch-common/.eslintrc.js +++ b/packages/hoppscotch-common/.eslintrc.js @@ -6,7 +6,6 @@ module.exports = { env: { browser: true, node: true, - jest: true, }, parserOptions: { sourceType: "module", diff --git a/packages/hoppscotch-common/assets/scss/styles.scss b/packages/hoppscotch-common/assets/scss/styles.scss index 64bfe65aa..da5e37ca3 100644 --- a/packages/hoppscotch-common/assets/scss/styles.scss +++ b/packages/hoppscotch-common/assets/scss/styles.scss @@ -4,6 +4,7 @@ @apply after:backface-hidden; @apply selection:bg-accentDark; @apply selection:text-accentContrast; + @apply overscroll-none; } :root { diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 092225236..cb30e4ebe 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -4,6 +4,7 @@ "cancel": "Cancel", "choose_file": "Choose a file", "clear": "Clear", + "clear_history": "Clear All History", "clear_all": "Clear all", "close": "Close", "connect": "Connect", @@ -150,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}", @@ -173,6 +179,7 @@ "folder": "Folder is empty", "headers": "This request does not have any headers", "history": "History is empty", + "history_suggestions": "History does not have any matching entries", "invites": "Invite list is empty", "members": "Team is empty", "parameters": "This request does not have any parameters", @@ -193,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": { @@ -582,6 +596,11 @@ "log": "Log", "url": "URL" }, + "spotlight": { + "section": { + "user": "User" + } + }, "sse": { "event_type": "Event type", "log": "Log", diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json index a6373f5db..f431c7eb3 100644 --- a/packages/hoppscotch-common/package.json +++ b/packages/hoppscotch-common/package.json @@ -4,6 +4,8 @@ "version": "2023.4.8", "scripts": { "dev": "pnpm exec npm-run-all -p -l dev:*", + "test": "vitest --run", + "test:watch": "vitest", "dev:vite": "vite", "dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml --watch dotenv_config_path=\"../../.env\"", "lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .", @@ -13,6 +15,7 @@ "preview": "vite preview", "gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"", "postinstall": "pnpm run gql-codegen", + "do-test": "pnpm run test", "do-lint": "pnpm run prod-lint", "do-typecheck": "pnpm run lint", "do-lintfix": "pnpm run lintfix" @@ -43,11 +46,12 @@ "@urql/exchange-auth": "^0.1.7", "@urql/exchange-graphcache": "^4.4.3", "@vitejs/plugin-legacy": "^2.3.0", - "@vueuse/core": "^8.7.5", + "@vueuse/core": "^8.9.4", "@vueuse/head": "^0.7.9", "acorn-walk": "^8.2.0", "axios": "^0.21.4", "buffer": "^6.0.3", + "dioc": "workspace:^", "esprima": "^4.0.1", "events": "^3.3.0", "fp-ts": "^2.12.1", @@ -63,6 +67,7 @@ "jsonpath-plus": "^7.0.0", "lodash-es": "^4.17.21", "lossless-json": "^2.0.8", + "minisearch": "^6.1.0", "nprogress": "^0.2.0", "paho-mqtt": "^1.1.0", "path": "^0.12.7", @@ -84,7 +89,6 @@ "util": "^0.12.4", "uuid": "^8.3.2", "vue": "^3.2.25", - "vue-github-button": "^3.0.3", "vue-i18n": "^9.2.2", "vue-pdf-embed": "^1.1.4", "vue-router": "^4.0.16", @@ -106,8 +110,9 @@ "@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", "@types/js-yaml": "^4.0.5", "@types/lodash-es": "^4.17.6", @@ -142,10 +147,11 @@ "vite-plugin-html-config": "^1.0.10", "vite-plugin-inspect": "^0.7.4", "vite-plugin-pages": "^0.26.0", - "vite-plugin-pages-sitemap": "^1.4.0", + "vite-plugin-pages-sitemap": "^1.4.5", "vite-plugin-pwa": "^0.13.1", "vite-plugin-vue-layouts": "^0.7.0", "vite-plugin-windicss": "^1.8.8", + "vitest": "^0.32.2", "vue-tsc": "^0.38.2", "windicss": "^3.5.6" } diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 8f040fcda..e31dd5701 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -9,22 +9,24 @@ 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'] - AppFuse: typeof import('./components/app/Fuse.vue')['default'] AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default'] AppHeader: typeof import('./components/app/Header.vue')['default'] AppInterceptor: typeof import('./components/app/Interceptor.vue')['default'] AppLogo: typeof import('./components/app/Logo.vue')['default'] AppOptions: typeof import('./components/app/Options.vue')['default'] AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default'] - AppPowerSearch: typeof import('./components/app/PowerSearch.vue')['default'] - AppPowerSearchEntry: typeof import('./components/app/PowerSearchEntry.vue')['default'] AppShare: typeof import('./components/app/Share.vue')['default'] AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default'] AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default'] AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default'] AppSidenav: typeof import('./components/app/Sidenav.vue')['default'] + AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default'] + AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default'] + AppSpotlightEntryGQLHistory: typeof import('./components/app/spotlight/entry/GQLHistory.vue')['default'] + AppSpotlightEntryRESTHistory: typeof import('./components/app/spotlight/entry/RESTHistory.vue')['default'] AppSupport: typeof import('./components/app/Support.vue')['default'] ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default'] ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default'] @@ -53,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'] @@ -99,6 +102,20 @@ declare module '@vue/runtime-core' { HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default'] HttpTests: typeof import('./components/http/Tests.vue')['default'] HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default'] + IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] + IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] + IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] + IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] + IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] + IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'] + IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] + IconLucideInfo: typeof import('~icons/lucide/info')['default'] + IconLucideLayers: typeof import('~icons/lucide/layers')['default'] + IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] + IconLucideMinus: typeof import('~icons/lucide/minus')['default'] + IconLucideRss: typeof import('~icons/lucide/rss')['default'] + IconLucideSearch: typeof import('~icons/lucide/search')['default'] + IconLucideUsers: typeof import('~icons/lucide/users')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default'] LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default'] @@ -134,6 +151,7 @@ declare module '@vue/runtime-core' { SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default'] SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default'] SmartPicture: typeof import('./../../hoppscotch-ui/src/components/smart/Picture.vue')['default'] + SmartPlaceholder: typeof import('./../../hoppscotch-ui/src/components/smart/Placeholder.vue')['default'] SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default'] SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default'] SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.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/Footer.vue b/packages/hoppscotch-common/src/components/app/Footer.vue index f82912b61..040289322 100644 --- a/packages/hoppscotch-common/src/components/app/Footer.vue +++ b/packages/hoppscotch-common/src/components/app/Footer.vue @@ -152,7 +152,7 @@ v-tippy="{ theme: 'tooltip', allowHTML: true }" :title="`${t( 'app.shortcuts' - )} ${getSpecialKey()}K`" + )} ${getSpecialKey()}/`" :icon="IconZap" @click="invokeAction('flyouts.keybinds.toggle')" /> diff --git a/packages/hoppscotch-common/src/components/app/Fuse.vue b/packages/hoppscotch-common/src/components/app/Fuse.vue deleted file mode 100644 index 516e6893a..000000000 --- a/packages/hoppscotch-common/src/components/app/Fuse.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/packages/hoppscotch-common/src/components/app/Header.vue b/packages/hoppscotch-common/src/components/app/Header.vue index ceb683d3f..90c567d9a 100644 --- a/packages/hoppscotch-common/src/components/app/Header.vue +++ b/packages/hoppscotch-common/src/components/app/Header.vue @@ -15,16 +15,21 @@ :label="t('app.name')" to="/" /> - -
- + +
+
(null) const settings = ref(null) const logout = ref(null) const accountActions = ref(null) + +defineActionHandler( + "user.login", + () => { + invokeAction("modals.login.toggle") + }, + computed(() => !currentUser.value) +) diff --git a/packages/hoppscotch-common/src/components/app/PowerSearch.vue b/packages/hoppscotch-common/src/components/app/PowerSearch.vue deleted file mode 100644 index 88645191b..000000000 --- a/packages/hoppscotch-common/src/components/app/PowerSearch.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - diff --git a/packages/hoppscotch-common/src/components/app/PowerSearchEntry.vue b/packages/hoppscotch-common/src/components/app/PowerSearchEntry.vue deleted file mode 100644 index 631e06cfb..000000000 --- a/packages/hoppscotch-common/src/components/app/PowerSearchEntry.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - diff --git a/packages/hoppscotch-common/src/components/app/Shortcuts.vue b/packages/hoppscotch-common/src/components/app/Shortcuts.vue index 40edaf886..ec73ed19c 100644 --- a/packages/hoppscotch-common/src/components/app/Shortcuts.vue +++ b/packages/hoppscotch-common/src/components/app/Shortcuts.vue @@ -14,46 +14,17 @@ />
-
-
- - - - {{ t(map.item.section) }} - - -
- -
-
-
+ - - {{ t("state.nothing_found") }} - "{{ filterText }}" - -
-
-
+
@@ -64,13 +35,13 @@ - {{ t(map.section) }} + {{ sectionTitle }}
@@ -81,10 +52,11 @@ diff --git a/packages/hoppscotch-common/src/components/app/ShortcutsPrompt.vue b/packages/hoppscotch-common/src/components/app/ShortcutsPrompt.vue index 45f788935..001b4b98d 100644 --- a/packages/hoppscotch-common/src/components/app/ShortcutsPrompt.vue +++ b/packages/hoppscotch-common/src/components/app/ShortcutsPrompt.vue @@ -22,10 +22,11 @@
{{ getSpecialKey() }} - K + /
- / + {{ getSpecialKey() }} + K
? diff --git a/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue b/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue new file mode 100644 index 000000000..bcee97c4e --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue @@ -0,0 +1,122 @@ + + + + + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/GQLHistory.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/GQLHistory.vue new file mode 100644 index 000000000..b1a845dbb --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/GQLHistory.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTHistory.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTHistory.vue new file mode 100644 index 000000000..da1d8d21e --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTHistory.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/index.vue b/packages/hoppscotch-common/src/components/app/spotlight/index.vue new file mode 100644 index 000000000..cebd053f7 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/index.vue @@ -0,0 +1,238 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/Collection.vue b/packages/hoppscotch-common/src/components/collections/Collection.vue index 0c069a545..44caea729 100644 --- a/packages/hoppscotch-common/src/components/collections/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/Collection.vue @@ -193,7 +193,7 @@ import IconTrash2 from "~icons/lucide/trash-2" import IconEdit from "~icons/lucide/edit" import IconFolder from "~icons/lucide/folder" import IconFolderOpen from "~icons/lucide/folder-open" -import { PropType, ref, computed, watch } from "vue" +import { ref, computed, watch } from "vue" import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" import { useI18n } from "@composables/i18n" import { TippyComponent } from "vue-tippy" @@ -209,67 +209,36 @@ type FolderType = "collection" | "folder" const t = useI18n() -const props = defineProps({ - id: { - type: String, - default: "", - required: true, - }, - parentID: { - type: String as PropType, - default: null, - required: false, - }, - data: { - type: Object as PropType | TeamCollection>, - default: () => ({}), - required: true, - }, - collectionsType: { - type: String as PropType, - default: "my-collections", - required: true, - }, - /** - * Collection component can be used for both collections and folders. - * folderType is used to determine which one it is. - */ - folderType: { - type: String as PropType, - default: "collection", - required: true, - }, - isOpen: { - type: Boolean, - default: false, - required: true, - }, - isSelected: { - type: Boolean as PropType, - default: false, - required: false, - }, - exportLoading: { - type: Boolean, - default: false, - required: false, - }, - hasNoTeamAccess: { - type: Boolean, - default: false, - required: false, - }, - collectionMoveLoading: { - type: Array as PropType, - default: () => [], - required: false, - }, - isLastItem: { - type: Boolean, - default: false, - required: false, - }, -}) +const props = withDefaults( + defineProps<{ + id: string + parentID?: string | null + data: HoppCollection | TeamCollection + /** + * Collection component can be used for both collections and folders. + * folderType is used to determine which one it is. + */ + collectionsType: CollectionType + folderType: FolderType + isOpen: boolean + isSelected?: boolean | null + exportLoading?: boolean + hasNoTeamAccess?: boolean + collectionMoveLoading?: string[] + isLastItem?: boolean + }>(), + { + id: "", + parentID: null, + collectionsType: "my-collections", + folderType: "collection", + isOpen: false, + isSelected: false, + exportLoading: false, + hasNoTeamAccess: false, + isLastItem: false, + } +) const emit = defineEmits<{ (event: "toggle-children"): void @@ -448,8 +417,13 @@ const notSameDestination = computed(() => { }) const isCollLoading = computed(() => { - if (props.collectionMoveLoading.length > 0 && props.data.id) { - return props.collectionMoveLoading.includes(props.data.id) + const { collectionMoveLoading } = props + if ( + collectionMoveLoading && + collectionMoveLoading.length > 0 && + props.data.id + ) { + return collectionMoveLoading.includes(props.data.id) } else { return false } diff --git a/packages/hoppscotch-common/src/components/collections/MyCollections.vue b/packages/hoppscotch-common/src/components/collections/MyCollections.vue index e9e34e138..86b3d4b9f 100644 --- a/packages/hoppscotch-common/src/components/collections/MyCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/MyCollections.vue @@ -243,49 +243,33 @@ /> + + - - - {{ t("empty.collection") }} - + + -
-
+ - - - {{ t("empty.folder") }} - -
+ diff --git a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue index 93c57c660..1168ce547 100644 --- a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue @@ -262,67 +262,53 @@ diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue index f6debfa99..89ceb3e1a 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue @@ -171,21 +171,14 @@ @duplicate-request="$emit('duplicate-request', $event)" @select="$emit('select', $event)" /> -
- - - {{ t("empty.collection") }} - -
+ -
- - - {{ t("empty.folder") }} - -
+ -
- - - {{ t("empty.collections") }} - -
-
+ - - - {{ t("state.nothing_found") }} "{{ filterText }}" - -
+ + + + + + + + + diff --git a/packages/hoppscotch-common/src/components/environments/Selector.vue b/packages/hoppscotch-common/src/components/environments/Selector.vue index f539fb0e7..4e25c7195 100644 --- a/packages/hoppscotch-common/src/components/environments/Selector.vue +++ b/packages/hoppscotch-common/src/components/environments/Selector.vue @@ -8,7 +8,7 @@ + diff --git a/packages/hoppscotch-common/src/components/environments/my/index.vue b/packages/hoppscotch-common/src/components/environments/my/index.vue index 654003970..1ba79a606 100644 --- a/packages/hoppscotch-common/src/components/environments/my/index.vue +++ b/packages/hoppscotch-common/src/components/environments/my/index.vue @@ -32,19 +32,12 @@ :environment="environment" @edit-environment="editEnvironment(index)" /> -
- - - {{ t("empty.environments") }} - -
+ -
- - - {{ t("empty.environments") }} - -
+ diff --git a/packages/hoppscotch-common/src/components/environments/teams/index.vue b/packages/hoppscotch-common/src/components/environments/teams/index.vue index ea36f0182..c18a049b5 100644 --- a/packages/hoppscotch-common/src/components/environments/teams/index.vue +++ b/packages/hoppscotch-common/src/components/environments/teams/index.vue @@ -43,19 +43,12 @@ /> -
- - - {{ t("empty.environments") }} - -
+
+
{ } } -const OpenLogoutModal = () => { +const openLogoutModal = () => { emit("confirm-logout") confirmLogout.value = true } + +defineActionHandler("user.logout", () => { + openLogoutModal() +}) diff --git a/packages/hoppscotch-common/src/components/graphql/Authorization.vue b/packages/hoppscotch-common/src/components/graphql/Authorization.vue index 1f8513ee5..ec700962e 100644 --- a/packages/hoppscotch-common/src/components/graphql/Authorization.vue +++ b/packages/hoppscotch-common/src/components/graphql/Authorization.vue @@ -114,19 +114,12 @@ />
-
- - - {{ t("empty.authorization") }} - -
+
diff --git a/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue index 02d546c26..c76ee845e 100644 --- a/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue +++ b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue @@ -289,19 +289,12 @@
-
- - - {{ t("empty.headers") }} - -
+
diff --git a/packages/hoppscotch-common/src/components/graphql/Response.vue b/packages/hoppscotch-common/src/components/graphql/Response.vue index b0138d782..1bbfd7f7d 100644 --- a/packages/hoppscotch-common/src/components/graphql/Response.vue +++ b/packages/hoppscotch-common/src/components/graphql/Response.vue @@ -1,12 +1,13 @@ diff --git a/packages/hoppscotch-common/src/components/history/index.vue b/packages/hoppscotch-common/src/components/history/index.vue index 5301b48b0..91c12b968 100644 --- a/packages/hoppscotch-common/src/components/history/index.vue +++ b/packages/hoppscotch-common/src/components/history/index.vue @@ -108,31 +108,23 @@ />
-
- - - {{ t("empty.history") }} - -
-
+ - - - {{ t("state.nothing_found") }} "{{ filterText || filterSelection }}" - + -
+ { toggleRESTHistoryEntryStar(entry as RESTHistoryEntry) else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry) } + +defineActionHandler("history.clear", () => { + confirmRemove.value = true +}) diff --git a/packages/hoppscotch-common/src/components/http/Authorization.vue b/packages/hoppscotch-common/src/components/http/Authorization.vue index 7a8e6f855..40af1b884 100644 --- a/packages/hoppscotch-common/src/components/http/Authorization.vue +++ b/packages/hoppscotch-common/src/components/http/Authorization.vue @@ -113,17 +113,12 @@ /> -
- - {{ t("empty.authorization") }} -
+
diff --git a/packages/hoppscotch-common/src/components/http/Body.vue b/packages/hoppscotch-common/src/components/http/Body.vue index 8c1e91211..c674c4c93 100644 --- a/packages/hoppscotch-common/src/components/http/Body.vue +++ b/packages/hoppscotch-common/src/components/http/Body.vue @@ -102,17 +102,12 @@ v-model="body" /> -
- - {{ t("empty.body") }} -
+
diff --git a/packages/hoppscotch-common/src/components/http/BodyParameters.vue b/packages/hoppscotch-common/src/components/http/BodyParameters.vue index 7fa7ee007..14e74b5ef 100644 --- a/packages/hoppscotch-common/src/components/http/BodyParameters.vue +++ b/packages/hoppscotch-common/src/components/http/BodyParameters.vue @@ -152,17 +152,12 @@ -
- - {{ t("empty.body") }} -
+
diff --git a/packages/hoppscotch-common/src/components/http/CodegenModal.vue b/packages/hoppscotch-common/src/components/http/CodegenModal.vue index 1a93c460d..41e8d40f5 100644 --- a/packages/hoppscotch-common/src/components/http/CodegenModal.vue +++ b/packages/hoppscotch-common/src/components/http/CodegenModal.vue @@ -56,20 +56,19 @@ } " /> -
- - - {{ t("state.nothing_found") }} "{{ searchQuery }}" - -
+ +
diff --git a/packages/hoppscotch-common/src/components/http/Headers.vue b/packages/hoppscotch-common/src/components/http/Headers.vue index 5015843a8..4c2ccdf93 100644 --- a/packages/hoppscotch-common/src/components/http/Headers.vue +++ b/packages/hoppscotch-common/src/components/http/Headers.vue @@ -202,17 +202,12 @@ -
- - {{ t("empty.headers") }} -
+ diff --git a/packages/hoppscotch-common/src/components/http/Parameters.vue b/packages/hoppscotch-common/src/components/http/Parameters.vue index a51dce11d..381a57ff7 100644 --- a/packages/hoppscotch-common/src/components/http/Parameters.vue +++ b/packages/hoppscotch-common/src/components/http/Parameters.vue @@ -145,18 +145,12 @@ - -
- - {{ t("empty.parameters") }} -
+ diff --git a/packages/hoppscotch-common/src/components/http/Request.vue b/packages/hoppscotch-common/src/components/http/Request.vue index c018dd15a..6d7182d9f 100644 --- a/packages/hoppscotch-common/src/components/http/Request.vue +++ b/packages/hoppscotch-common/src/components/http/Request.vue @@ -1,6 +1,6 @@ -
- - - {{ t("empty.protocols") }} - -
+ 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-common/src/services/debug.service.ts b/packages/hoppscotch-common/src/services/debug.service.ts new file mode 100644 index 000000000..5d80797cf --- /dev/null +++ b/packages/hoppscotch-common/src/services/debug.service.ts @@ -0,0 +1,64 @@ +import { Service } from "dioc" + +/** + * This service provice debug utilities for the application and is + * supposed to be used only in development. + * + * This service logs events from the container and also events + * from all the services that are bound to the container. + * + * This service injects couple of utilities into the global scope: + * - `_getService(id: string): Service | undefined` - Returns the service instance with the given ID or undefined. + * - `_getBoundServiceIDs(): string[]` - Returns the IDs of all the bound services. + */ +export class DebugService extends Service { + public static readonly ID = "DEBUG_SERVICE" + + constructor() { + super() + + console.log("DebugService is initialized...") + + const container = this.getContainer() + + // Log container events + container.getEventStream().subscribe((event) => { + if (event.type === "SERVICE_BIND") { + console.log( + "[CONTAINER] Service Bind:", + event.bounderID ?? "", + "->", + event.boundeeID + ) + } else if (event.type === "SERVICE_INIT") { + console.log("[CONTAINER] Service Init:", event.serviceID) + + // Subscribe to event stream of the newly initialized service + const service = container.getBoundServiceWithID(event.serviceID) + + service?.getEventStream().subscribe((ev: any) => { + console.log(`[${event.serviceID}] Event:`, ev) + }) + } + }) + + // Subscribe to event stream of all already bound services (if any) + for (const [id, service] of container.getBoundServices()) { + service.getEventStream().subscribe((event: any) => { + console.log(`[${id}]`, event) + }) + } + + // Inject debug utilities into the global scope + ;(window as any)._getService = this.getService.bind(this) + ;(window as any)._getBoundServiceIDs = this.getBoundServiceIDs.bind(this) + } + + private getBoundServiceIDs() { + return Array.from(this.getContainer().getBoundServices()).map(([id]) => id) + } + + private getService(id: string) { + return this.getContainer().getBoundServiceWithID(id) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/__tests__/index.spec.ts b/packages/hoppscotch-common/src/services/spotlight/__tests__/index.spec.ts new file mode 100644 index 000000000..b2d951894 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/__tests__/index.spec.ts @@ -0,0 +1,550 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + SpotlightSearcher, + SpotlightSearcherSessionState, + SpotlightSearcherResult, + SpotlightService, +} from "../" +import { Ref, computed, nextTick, ref, watch } from "vue" +import { TestContainer } from "dioc/testing" + +const echoSearcher: SpotlightSearcher = { + searcherID: "echo-searcher", + searcherSectionTitle: "Echo Searcher", + createSearchSession: (query: Readonly>) => { + // A basic searcher that returns the query string as the sole result + const loading = ref(false) + const results = ref([]) + + watch( + query, + (query) => { + loading.value = true + + results.value = [ + { + id: "searcher-a-result", + text: { + type: "text", + text: query, + }, + icon: {}, + score: 1, + }, + ] + + loading.value = false + }, + { immediate: true } + ) + + const onSessionEnd = () => { + /* noop */ + } + + return [ + computed(() => ({ + loading: loading.value, + results: results.value, + })), + onSessionEnd, + ] + }, + + onResultSelect: () => { + /* noop */ + }, +} + +const emptySearcher: SpotlightSearcher = { + searcherID: "empty-searcher", + searcherSectionTitle: "Empty Searcher", + createSearchSession: () => { + const loading = ref(false) + + return [ + computed(() => ({ + loading: loading.value, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, +} + +describe("SpotlightService", () => { + describe("registerSearcher", () => { + it("registers a searcher with a given ID", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + const [id, searcher] = spotlight.getAllSearchers().next().value + + expect(id).toEqual("echo-searcher") + expect(searcher).toBe(echoSearcher) + }) + + it("if 2 searchers are registered with the same ID, the last one overwrites the first one", () => { + const echoSearcherFake: SpotlightSearcher = { + searcherID: "echo-searcher", + searcherSectionTitle: "Echo Searcher", + createSearchSession: () => { + throw new Error("not implemented") + }, + onResultSelect: () => { + throw new Error("not implemented") + }, + } + + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + spotlight.registerSearcher(echoSearcherFake) + + const [id, searcher] = spotlight.getAllSearchers().next().value + + expect(id).toEqual("echo-searcher") + expect(searcher).toBe(echoSearcherFake) + }) + }) + + describe("createSearchSession", () => { + it("when the source query changes, the searchers are notified", async () => { + const container = new TestContainer() + + const notifiedFn = vi.fn() + + const sampleSearcher: SpotlightSearcher = { + searcherID: "searcher", + searcherSectionTitle: "Searcher", + createSearchSession: (query) => { + const stop = watch(query, notifiedFn, { immediate: true }) + + return [ + ref({ + loading: false, + results: [], + }), + () => { + stop() + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(sampleSearcher) + + const query = ref("test") + + const [, dispose] = spotlight.createSearchSession(query) + + query.value = "test2" + await nextTick() + + expect(notifiedFn).toHaveBeenCalledTimes(2) + + dispose() + }) + + it("when a searcher returns results, they are added to the results", async () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + await nextTick() + + expect(session.value.results).toHaveProperty("echo-searcher") + expect(session.value.results["echo-searcher"]).toEqual({ + title: "Echo Searcher", + avgScore: 1, + results: [ + { + id: "searcher-a-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }, + ], + }) + + dispose() + }) + + it("when a searcher does not return any results, they are not added to the results", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(emptySearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.results).not.toHaveProperty("empty-searcher") + expect(session.value.results).toEqual({}) + + dispose() + }) + + it("when any of the searchers report they are loading, the search session says it is loading", () => { + const container = new TestContainer() + + const loadingSearcher: SpotlightSearcher = { + searcherID: "loading-searcher", + searcherSectionTitle: "Loading Searcher", + createSearchSession: () => { + const loading = ref(true) + const results = ref([]) + + return [ + computed(() => ({ + loading: loading.value, + results: results.value, + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(loadingSearcher) + spotlight.registerSearcher(echoSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.loading).toBe(true) + + dispose() + }) + + it("when all of the searchers report they are not loading, the search session says it is not loading", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + spotlight.registerSearcher(emptySearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.loading).toBe(false) + + dispose() + }) + + it("when a searcher changes its loading state after a while, the search session state updates", async () => { + const container = new TestContainer() + + const loading = ref(true) + + const loadingSearcher: SpotlightSearcher = { + searcherID: "loading-searcher", + searcherSectionTitle: "Loading Searcher", + createSearchSession: () => { + return [ + computed(() => ({ + loading: loading.value, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(loadingSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.loading).toBe(true) + + loading.value = false + + await nextTick() + + expect(session.value.loading).toBe(false) + + dispose() + }) + + it("when the searcher updates its results, the search session state updates", async () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.results).toHaveProperty("echo-searcher") + expect(session.value.results["echo-searcher"]).toEqual({ + title: "Echo Searcher", + avgScore: 1, + results: [ + { + id: "searcher-a-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }, + ], + }) + + query.value = "test2" + await nextTick() + + expect(session.value.results).toHaveProperty("echo-searcher") + expect(session.value.results["echo-searcher"]).toEqual({ + title: "Echo Searcher", + avgScore: 1, + results: [ + { + id: "searcher-a-result", + text: { + type: "text", + text: "test2", + }, + icon: {}, + score: 1, + }, + ], + }) + + dispose() + }) + + it("when the returned dispose function is called, the searchers are notified", () => { + const container = new TestContainer() + + const disposeFn = vi.fn() + + const testSearcher: SpotlightSearcher = { + searcherID: "test-searcher", + searcherSectionTitle: "Test Searcher", + createSearchSession: () => { + return [ + computed(() => ({ + loading: false, + results: [], + })), + disposeFn, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + const query = ref("test") + const [, dispose] = spotlight.createSearchSession(query) + + dispose() + + expect(disposeFn).toHaveBeenCalledOnce() + }) + + it("when the search session is disposed, changes to the query are not notified to the searchers", async () => { + const container = new TestContainer() + + const notifiedFn = vi.fn() + + const testSearcher: SpotlightSearcher = { + searcherID: "test-searcher", + searcherSectionTitle: "Test Searcher", + createSearchSession: (query) => { + watch(query, notifiedFn, { immediate: true }) + + return [ + computed(() => ({ + loading: false, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + const query = ref("test") + const [, dispose] = spotlight.createSearchSession(query) + + query.value = "test2" + await nextTick() + + expect(notifiedFn).toHaveBeenCalledTimes(2) + + dispose() + + query.value = "test3" + await nextTick() + + expect(notifiedFn).toHaveBeenCalledTimes(3) + }) + + describe("selectSearchResult", () => { + const onResultSelectFn = vi.fn() + + const testSearcher: SpotlightSearcher = { + searcherID: "test-searcher", + searcherSectionTitle: "Test Searcher", + createSearchSession: () => { + return [ + computed(() => ({ + loading: false, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: onResultSelectFn, + } + + beforeEach(() => { + onResultSelectFn.mockReset() + }) + + it("does nothing if the searcherID is invalid", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + spotlight.selectSearchResult("invalid-searcher-id", { + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + + expect(onResultSelectFn).not.toHaveBeenCalled() + }) + + it("calls the correspondig searcher's onResultSelect method", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + spotlight.selectSearchResult("test-searcher", { + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + + expect(onResultSelectFn).toHaveBeenCalledOnce() + }) + + it("passes the correct information to the searcher's onResultSelect method", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + spotlight.selectSearchResult("test-searcher", { + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + + expect(onResultSelectFn).toHaveBeenCalledWith({ + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + }) + }) + }) + + describe("getAllSearchers", () => { + it("when no searchers are registered, it returns an empty array", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + + expect(Array.from(spotlight.getAllSearchers())).toEqual([]) + }) + + it("when a searcher is registered, it returns an array with a tuple of the searcher id and then then searcher", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + expect(Array.from(spotlight.getAllSearchers())).toEqual([ + ["echo-searcher", echoSearcher], + ]) + }) + + it("returns all registered searchers", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + spotlight.registerSearcher(emptySearcher) + + expect(Array.from(spotlight.getAllSearchers())).toEqual([ + ["echo-searcher", echoSearcher], + ["empty-searcher", emptySearcher], + ]) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/spotlight/index.ts b/packages/hoppscotch-common/src/services/spotlight/index.ts new file mode 100644 index 000000000..44d6f7242 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/index.ts @@ -0,0 +1,216 @@ +import { Service } from "dioc" +import { watch, type Ref, ref, reactive, effectScope, Component } from "vue" + +/** + * Defines how to render the entry text in a Spotlight Search Result + */ +export type SpotlightResultTextType = + | { + type: "text" + /** + * The text to render. Passing an array of strings will render each string separated by a chevron + */ + text: string[] | 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 spotlight light so the UI can render it + */ +export type SpotlightSearcherResult = { + /** + * The unique ID of the result + */ + id: string + + /** + * The text to render in the result + */ + text: SpotlightResultTextType + + /** + * The icon to render as the signifier of the result + */ + icon: object | Component + + /** + * The score of the result, the UI should sort the results by this + */ + score: number + + /** + * Additional metadata about the result + */ + meta?: { + /** + * The keyboard shortcut to trigger the result + */ + keyboardShortcut?: string[] + } +} + +/** + * Defines the state of a searcher during a spotlight search session + */ +export type SpotlightSearcherSessionState = { + /** + * Whether the searcher is currently loading results + */ + loading: boolean + + /** + * The results presented by the corresponding searcher in a session + */ + results: SpotlightSearcherResult[] +} + +export interface SpotlightSearcher { + searcherID: string + searcherSectionTitle: string + + createSearchSession( + query: Readonly> + ): [Ref, () => void] + + onResultSelect(result: SpotlightSearcherResult): void +} + +/** + * Defines the state of a searcher during a search session that + * is exposed to through the spotlight service + */ +export type SpotlightSearchSearcherState = { + title: string + avgScore: number + results: SpotlightSearcherResult[] +} + +/** + * Defines the state of a spotlight search session + */ +export type SpotlightSearchState = { + /** + * Whether any of the searchers are currently loading results + */ + loading: boolean + + /** + * The results presented by the corresponding searcher in a session + */ + results: Record +} + +export class SpotlightService extends Service { + public static readonly ID = "SPOTLIGHT_SERVICE" + + private searchers: Map = new Map() + + /** + * Registers a searcher with the spotlight service + * @param searcher The searcher instance to register + */ + public registerSearcher(searcher: SpotlightSearcher) { + this.searchers.set(searcher.searcherID, searcher) + } + + /** + * Gets an iterator over all registered searchers and their IDs + */ + public getAllSearchers(): IterableIterator<[string, SpotlightSearcher]> { + return this.searchers.entries() + } + + /** + * Creates a new search session + * @param query A ref to the query to search for, updating this ref will notify the searchers about the change + * @returns A ref to the state of the search session and a function to end the session + */ + public createSearchSession( + query: Ref + ): [Ref, () => void] { + const searchSessions = Array.from(this.searchers.values()).map( + (x) => [x, ...x.createSearchSession(query)] as const + ) + + const loadingSearchers = reactive(new Set()) + const onSessionEndList: Array<() => void> = [] + + const resultObj = ref({ + loading: false, + results: {}, + }) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + for (const [searcher, state, onSessionEnd] of searchSessions) { + watch( + state, + (newState) => { + if (newState.loading) { + loadingSearchers.add(searcher.searcherID) + } else { + loadingSearchers.delete(searcher.searcherID) + } + + if (newState.results.length === 0) { + delete resultObj.value.results[searcher.searcherID] + } else { + resultObj.value.results[searcher.searcherID] = { + title: searcher.searcherSectionTitle, + avgScore: + newState.results.reduce((acc, x) => acc + x.score, 0) / + newState.results.length, + results: newState.results, + } + } + }, + { immediate: true } + ) + + onSessionEndList.push(onSessionEnd) + } + + watch( + loadingSearchers, + (set) => { + resultObj.value.loading = set.size > 0 + }, + { immediate: true } + ) + }) + + const onSearchEnd = () => { + scopeHandle.stop() + + for (const onEnd of onSessionEndList) { + onEnd() + } + } + + return [resultObj, onSearchEnd] + } + + /** + * Selects a search result. To be called when the user selects a result + * @param searcherID The ID of the searcher that the result belongs to + * @param result The resuklt to look at + */ + public selectSearchResult( + searcherID: string, + result: SpotlightSearcherResult + ) { + this.searchers.get(searcherID)?.onResultSelect(result) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/history.searcher.spec.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/history.searcher.spec.ts new file mode 100644 index 000000000..3a5f307dc --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/history.searcher.spec.ts @@ -0,0 +1,444 @@ +import { TestContainer } from "dioc/testing" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { HistorySpotlightSearcherService } from "../history.searcher" +import { nextTick, ref } from "vue" +import { SpotlightService } from "../.." +import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history" +import { getDefaultRESTRequest } from "~/helpers/rest/default" +import { HoppAction, HoppActionWithArgs } from "~/helpers/actions" +import { defaultGQLSession } from "~/newstore/GQLSession" + +async function flushPromises() { + return await new Promise((r) => setTimeout(r)) +} + +const tabMock = vi.hoisted(() => ({ + createNewTab: vi.fn(), +})) + +vi.mock("~/helpers/rest/tab", () => ({ + __esModule: true, + createNewTab: tabMock.createNewTab, +})) + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +const actionsMock = vi.hoisted(() => ({ + value: [] as (HoppAction | HoppActionWithArgs)[], + invokeAction: vi.fn(), +})) + +vi.mock("~/helpers/actions", async () => { + const { BehaviorSubject }: any = await vi.importActual("rxjs") + + return { + __esModule: true, + activeActions$: new BehaviorSubject(actionsMock.value), + invokeAction: actionsMock.invokeAction, + } +}) + +const historyMock = vi.hoisted(() => ({ + restEntries: [] as RESTHistoryEntry[], + gqlEntries: [] as GQLHistoryEntry[], +})) + +vi.mock("~/newstore/history", () => ({ + __esModule: true, + restHistoryStore: { + value: { + state: historyMock.restEntries, + }, + }, + graphqlHistoryStore: { + value: { + state: historyMock.gqlEntries, + }, + }, +})) + +describe("HistorySpotlightSearcherService", () => { + beforeEach(() => { + let x = actionsMock.value.pop() + while (x) { + x = actionsMock.value.pop() + } + + let y = historyMock.restEntries.pop() + while (y) { + y = historyMock.restEntries.pop() + } + + actionsMock.invokeAction.mockReset() + tabMock.createNewTab.mockReset() + }) + + it("registers with the spotlight service upon initialization", async () => { + const container = new TestContainer() + + const registerSearcherFn = vi.fn() + + container.bindMock(SpotlightService, { + registerSearcher: registerSearcherFn, + }) + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + expect(registerSearcherFn).toHaveBeenCalledOnce() + expect(registerSearcherFn).toHaveBeenCalledWith(history) + }) + + it("returns a clear history result if the action is available", async () => { + const container = new TestContainer() + + actionsMock.value.push("history.clear") + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("his") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "clear-history", + }) + ) + }) + + it("doesn't return a clear history result if the action is not available", async () => { + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("his") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: "clear-history", + }) + ) + }) + + it("selecting a clear history entry invokes the clear history action", async () => { + const container = new TestContainer() + + actionsMock.value.push("history.clear") + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("his") + const [result] = history.createSearchSession(query) + await nextTick() + + history.onResultSelect(result.value.results[0]) + + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("history.clear") + }) + + it("returns all the valid rest history entries for the search term", async () => { + actionsMock.value.push("rest.request.open") + + historyMock.restEntries.push({ + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "rest-0", + text: { + type: "custom", + component: expect.anything(), + componentProps: expect.objectContaining({ + historyEntry: historyMock.restEntries[0], + }), + }, + }) + ) + }) + + it("selecting a rest history entry invokes action to open the rest request", async () => { + actionsMock.value.push("rest.request.open") + + const historyEntry: RESTHistoryEntry = { + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + } + + historyMock.restEntries.push(historyEntry) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + const doc = result.value.results[0] + + history.onResultSelect(doc) + + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("rest.request.open", { + doc: { + request: historyEntry.request, + isDirty: false, + }, + }) + }) + + it("returns all the valid graphql history entries for the search term", async () => { + actionsMock.value.push("gql.request.open") + + historyMock.gqlEntries.push({ + request: { + ...defaultGQLSession.request, + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "gql-0", + text: { + type: "custom", + component: expect.anything(), + componentProps: expect.objectContaining({ + historyEntry: historyMock.gqlEntries[0], + }), + }, + }) + ) + }) + + it("selecting a graphql history entry invokes action to open the graphql request", async () => { + actionsMock.value.push("gql.request.open") + + const historyEntry: GQLHistoryEntry = { + request: { + ...defaultGQLSession.request, + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + } + + historyMock.gqlEntries.push(historyEntry) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + const doc = result.value.results[0] + + history.onResultSelect(doc) + + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("gql.request.open", { + request: historyEntry.request, + }) + }) + + it("rest history entries are not shown when the rest open action is not registered", async () => { + actionsMock.value.push("gql.request.open") + + historyMock.gqlEntries.push({ + request: { + ...defaultGQLSession.request, + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + }) + + historyMock.restEntries.push({ + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^gql/), + }) + ) + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^rest/), + }) + ) + }) + + it("gql history entries are not shown when the gql open action is not registered", async () => { + actionsMock.value.push("rest.request.open") + + historyMock.gqlEntries.push({ + request: { + ...defaultGQLSession.request, + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + }) + + historyMock.restEntries.push({ + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^rest/), + }) + ) + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^gql/), + }) + ) + }) + + it("none of the history entries are show when neither of the open actions are registered", async () => { + historyMock.gqlEntries.push({ + request: { + ...defaultGQLSession.request, + url: "bla.com", + }, + response: "{}", + star: false, + v: 1, + updatedOn: new Date(), + }) + + historyMock.restEntries.push({ + request: { + ...getDefaultRESTRequest(), + endpoint: "bla.com", + }, + responseMeta: { + duration: null, + statusCode: null, + }, + star: false, + v: 1, + updatedOn: new Date(), + }) + + const container = new TestContainer() + + const history = container.bind(HistorySpotlightSearcherService) + await flushPromises() + + const query = ref("bla") + const [result] = history.createSearchSession(query) + await nextTick() + + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^rest/), + }) + ) + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: expect.stringMatching(/^gql/), + }) + ) + }) +}) diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/user.searcher.spec.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/user.searcher.spec.ts new file mode 100644 index 000000000..ec6775343 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/__tests__/user.searcher.spec.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, it, expect, vi } from "vitest" +import { TestContainer } from "dioc/testing" +import { UserSpotlightSearcherService } from "../user.searcher" +import { nextTick, ref } from "vue" +import { SpotlightService } from "../.." + +async function flushPromises() { + return await new Promise((r) => setTimeout(r)) +} + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +const actionsMock = vi.hoisted(() => ({ + value: ["user.login"], + invokeAction: vi.fn(), +})) + +vi.mock("~/helpers/actions", async () => { + const { BehaviorSubject }: any = await vi.importActual("rxjs") + + return { + __esModule: true, + activeActions$: new BehaviorSubject(actionsMock.value), + invokeAction: actionsMock.invokeAction, + } +}) + +describe("UserSearcher", () => { + beforeEach(() => { + let x = actionsMock.value.pop() + while (x) { + x = actionsMock.value.pop() + } + + actionsMock.invokeAction.mockReset() + }) + + it("registers with the spotlight service upon initialization", async () => { + const container = new TestContainer() + + const registerSearcherFn = vi.fn() + + container.bindMock(SpotlightService, { + registerSearcher: registerSearcherFn, + }) + + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + expect(registerSearcherFn).toHaveBeenCalledOnce() + expect(registerSearcherFn).toHaveBeenCalledWith(user) + }) + + it("if login action is available, the search result should have the login result", async () => { + const container = new TestContainer() + + actionsMock.value.push("user.login") + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + }) + + it("if login action is not available, the search result should not have the login result", async () => { + const container = new TestContainer() + + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + }) + + it("if logout action is available, the search result should have the logout result", async () => { + const container = new TestContainer() + + actionsMock.value.push("user.logout") + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) + + it("if logout action is not available, the search result should not have the logout result", async () => { + const container = new TestContainer() + + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).not.toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) + + it("if login action and logout action are available, the search result should have both results", async () => { + const container = new TestContainer() + + actionsMock.value.push("user.login", "user.logout") + const user = container.bind(UserSpotlightSearcherService) + await flushPromises() + + const query = ref("log") + const result = user.createSearchSession(query)[0] + await nextTick() + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + + expect(result.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + }) + + it("selecting the login event should invoke the login action", () => { + const container = new TestContainer() + + actionsMock.value.push("user.login") + const user = container.bind(UserSpotlightSearcherService) + const query = ref("log") + + user.createSearchSession(query)[0] + + user.onDocSelected("login") + + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("user.login") + }) + + it("selecting the logout event should invoke the logout action", () => { + const container = new TestContainer() + + actionsMock.value.push("user.logout") + const user = container.bind(UserSpotlightSearcherService) + const query = ref("log") + + user.createSearchSession(query)[0] + + user.onDocSelected("logout") + expect(actionsMock.invokeAction).toHaveBeenCalledOnce() + expect(actionsMock.invokeAction).toHaveBeenCalledWith("user.logout") + }) + + it("selecting an invalid event should not invoke any action", () => { + const container = new TestContainer() + + actionsMock.value.push("user.logout") + const user = container.bind(UserSpotlightSearcherService) + const query = ref("log") + + user.createSearchSession(query)[0] + + user.onDocSelected("bla") + expect(actionsMock.invokeAction).not.toHaveBeenCalled() + }) +}) diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/base/__tests__/static.searcher.spec.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/base/__tests__/static.searcher.spec.ts new file mode 100644 index 000000000..69a0c9943 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/base/__tests__/static.searcher.spec.ts @@ -0,0 +1,403 @@ +import { describe, it, expect, vi } from "vitest" +import { + SearchResult, + StaticSpotlightSearcherService, +} from "../static.searcher" +import { nextTick, reactive, ref } from "vue" +import { SpotlightSearcherResult } from "../../.." +import { TestContainer } from "dioc/testing" + +async function flushPromises() { + return await new Promise((r) => setTimeout(r)) +} + +describe("StaticSpotlightSearcherService", () => { + it("returns docs that have excludeFromSearch set to false", async () => { + class TestSearcherService extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + private documents: Record = reactive({ + login: { + text: "Login", + excludeFromSearch: false, + }, + logout: { + text: "Logout", + excludeFromSearch: false, + }, + }) + + constructor() { + super({ + searchFields: ["text"], + fieldWeights: {}, + }) + + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherService) + await flushPromises() + + const query = ref("log") + + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + }) + + it("doesn't return docs that have excludeFromSearch set to true", async () => { + class TestSearcherServiceB extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_B" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + private documents: Record = reactive({ + login: { + text: "Login", + excludeFromSearch: true, + }, + logout: { + text: "Logout", + excludeFromSearch: false, + }, + }) + + constructor() { + super({ + searchFields: ["text"], + fieldWeights: {}, + }) + + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceB) + await flushPromises() + + const query = ref("log") + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).not.toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) + + it("returns docs that have excludeFromSearch set to undefined", async () => { + class TestSearcherServiceC extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_C" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + private documents: Record = reactive({ + login: { + text: "Login", + }, + logout: { + text: "Logout", + }, + }) + + constructor() { + super({ + searchFields: ["text"], + fieldWeights: {}, + }) + + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceC) + await flushPromises() + + const query = ref("log") + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) + + it("onDocSelected is called with a valid doc id and doc when onResultSelect is called", async () => { + class TestSearcherServiceD extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_D" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + public documents: Record = reactive({ + login: { + text: "Login", + }, + logout: { + text: "Logout", + }, + }) + + constructor() { + super({ + searchFields: ["text"], + fieldWeights: {}, + }) + + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected = vi.fn() + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceD) + await flushPromises() + + const query = ref("log") + const [results] = service.createSearchSession(query) + await nextTick() + + const doc = results.value.results[0] + + service.onResultSelect(doc) + + expect(service.onDocSelected).toHaveBeenCalledOnce() + expect(service.onDocSelected).toHaveBeenCalledWith( + doc.id, + service.documents["login"] + ) + }) + + it("returns search results from entries as specified by getSearcherResultForSearchResult", async () => { + class TestSearcherServiceE extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_E" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + public documents: Record = reactive({ + login: { + text: "Login", + }, + logout: { + text: "Logout", + }, + }) + + constructor() { + super({ + searchFields: ["text"], + fieldWeights: {}, + }) + + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text.toUpperCase() }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceE) + await flushPromises() + + const query = ref("log") + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + text: { type: "text", text: "LOGIN" }, + }) + ) + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + text: { type: "text", text: "LOGOUT" }, + }) + ) + }) + + it("indexes the documents by the 'searchFields' property and obeys multiple index fields", async () => { + class TestSearcherServiceF extends StaticSpotlightSearcherService< + Record + > { + public static readonly ID = "TEST_SEARCHER_SERVICE_F" + + public readonly searcherID = "test" + public searcherSectionTitle = "test" + + public documents: Record = reactive({ + login: { + text: "Login", + alternate: ["sign in"], + }, + logout: { + text: "Logout", + alternate: ["sign out"], + }, + }) + + constructor() { + super({ + searchFields: ["text", "alternate"], + fieldWeights: {}, + }) + + this.setDocuments(this.documents) + } + + protected getSearcherResultForSearchResult( + result: SearchResult> + ): SpotlightSearcherResult { + return { + id: result.id, + icon: {}, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(): void { + // noop + } + } + + const container = new TestContainer() + + const service = container.bind(TestSearcherServiceF) + await flushPromises() + + const query = ref("sign") + const [results] = service.createSearchSession(query) + await nextTick() + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "login", + }) + ) + + expect(results.value.results).toContainEqual( + expect.objectContaining({ + id: "logout", + }) + ) + }) +}) diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts new file mode 100644 index 000000000..73c11537d --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts @@ -0,0 +1,175 @@ +import { Service } from "dioc" +import { + type SpotlightSearcher, + type SpotlightSearcherResult, + type SpotlightSearcherSessionState, +} from "../.." +import MiniSearch from "minisearch" +import { Ref, computed, effectScope, ref, watch } from "vue" +import { resolveUnref } from "@vueuse/core" + +/** + * Defines a search result and additional metadata returned by a StaticSpotlightSearcher + */ +export type SearchResult = + { + id: string + score: number + doc: Doc + } + +/** + * Options for StaticSpotlightSearcher initialization + */ +export type StaticSpotlightSearcherOptions< + Doc extends object & { excludeFromSearch?: boolean } +> = { + /** + * The array of field names in the given documents to search against + */ + searchFields: Array + + /** + * The weights to apply to each field in the search, this allows for certain + * fields to have more priority than others in the search and update the score + */ + fieldWeights?: Partial> + + /** + * How much the score should be boosted if the search matched fuzzily. + * Increasing this value generally makes the search ignore typos, but reduces performance + */ + fuzzyWeight?: number + + /** + * How much the score should be boosted if the search matched by prefix. + * For e.g, when searching for "hop", "hoppscotch" would match by prefix. + */ + prefixWeight?: number +} + +/** + * A base class for SpotlightSearcherServices that have a static set of documents + * that can optionally be toggled against (via the `excludeFromSearch` property in the Doc) + */ +export abstract class StaticSpotlightSearcherService< + Doc extends object & { excludeFromSearch?: boolean } + > + extends Service + implements SpotlightSearcher +{ + public abstract readonly searcherID: string + public abstract readonly searcherSectionTitle: string + + private minisearch: MiniSearch + + private loading = ref(false) + + private _documents: Record = {} + + constructor(private opts: StaticSpotlightSearcherOptions) { + super() + + this.minisearch = new MiniSearch({ + fields: opts.searchFields as string[], + }) + } + + /** + * Sets the documents to search against. + * NOTE: We generally expect this function to only be called once and we expect + * the documents to not change generally. You can pass a reactive object, if you want to toggle + * states if you want to. + * @param docs The documents to search against, this is an object, with the key being the document ID + */ + protected setDocuments(docs: Record) { + this._documents = docs + + this.addDocsToSearchIndex() + } + + private async addDocsToSearchIndex() { + this.loading.value = true + + this.minisearch.removeAll() + this.minisearch.vacuum() + + await this.minisearch.addAllAsync( + Object.entries(this._documents).map(([id, doc]) => ({ + id, + ...doc, + })) + ) + + this.loading.value = false + } + + /** + * Specifies how to convert a document into the Spotlight entry format + * @param result The search result to convert + */ + protected abstract getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult + + public createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const results = ref([]) + + const resultObj = computed(() => ({ + loading: this.loading.value, + results: results.value, + })) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + watch( + [query, () => this._documents], + ([query, docs]) => { + const searchResults = this.minisearch.search(query, { + prefix: true, + boost: (this.opts.fieldWeights as any) ?? {}, + weights: { + fuzzy: this.opts.fuzzyWeight ?? 0.2, + prefix: this.opts.prefixWeight ?? 0.6, + }, + }) + + results.value = searchResults + .filter( + (result) => + this._documents[result.id].excludeFromSearch === undefined || + this._documents[result.id].excludeFromSearch === false + ) + .map((result) => { + return this.getSearcherResultForSearchResult({ + id: result.id, + score: result.score, + doc: docs[result.id], + }) + }) + }, + { immediate: true } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + } + + return [resultObj, onSessionEnd] + } + + /** + * Called when a document is selected from the search results + * @param id The ID of the document selected + * @param doc The document information of the document selected + */ + public abstract onDocSelected(id: string, doc: Doc): void + + public onResultSelect(result: SpotlightSearcherResult): void { + this.onDocSelected(result.id, resolveUnref(this._documents)[result.id]) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts new file mode 100644 index 000000000..75c96e8ef --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts @@ -0,0 +1,255 @@ +import { Service } from "dioc" +import { + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from "../" +import { Ref, computed, effectScope, markRaw, ref, watch } from "vue" +import { getI18n } from "~/modules/i18n" +import MiniSearch from "minisearch" +import { graphqlHistoryStore, restHistoryStore } from "~/newstore/history" +import { useTimeAgo } from "@vueuse/core" +import IconHistory from "~icons/lucide/history" +import IconTrash2 from "~icons/lucide/trash-2" +import SpotlightRESTHistoryEntry from "~/components/app/spotlight/entry/RESTHistory.vue" +import SpotlightGQLHistoryEntry from "~/components/app/spotlight/entry/GQLHistory.vue" +import { capitalize } from "lodash-es" +import { shortDateTime } from "~/helpers/utils/date" +import { useStreamStatic } from "~/composables/stream" +import { activeActions$, invokeAction } from "~/helpers/actions" +import { map } from "rxjs/operators" +import { HoppRESTDocument } from "~/helpers/rest/document" + +/** + * This searcher is responsible for searching through the history. + * It also provides actions to clear the history. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class HistorySpotlightSearcherService + extends Service + implements SpotlightSearcher +{ + public static readonly ID = "HISTORY_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public searcherID = "history" + public searcherSectionTitle = this.t("tab.history") + + private readonly spotlight = this.bind(SpotlightService) + + private clearHistoryActionEnabled = useStreamStatic( + activeActions$.pipe(map((actions) => actions.includes("history.clear"))), + activeActions$.value.includes("history.clear"), + () => { + /* noop */ + } + )[0] + + private restHistoryEntryOpenable = useStreamStatic( + activeActions$.pipe( + map((actions) => actions.includes("rest.request.open")) + ), + activeActions$.value.includes("rest.request.open"), + () => { + /* noop */ + } + )[0] + + private gqlHistoryEntryOpenable = useStreamStatic( + activeActions$.pipe(map((actions) => actions.includes("gql.request.open"))), + activeActions$.value.includes("gql.request.open"), + () => { + /* noop */ + } + )[0] + + constructor() { + super() + + this.spotlight.registerSearcher(this) + } + + createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const loading = ref(false) + const results = ref([]) + + const minisearch = new MiniSearch({ + fields: ["url", "title", "reltime", "date"], + storeFields: ["url"], + }) + + const stopWatchHandle = watch( + this.clearHistoryActionEnabled, + (enabled) => { + if (enabled) { + if (minisearch.has("clear-history")) return + + minisearch.add({ + id: "clear-history", + title: this.t("action.clear_history"), + }) + } else { + if (!minisearch.has("clear-history")) return + + minisearch.discard("clear-history") + } + }, + { immediate: true } + ) + + if (this.restHistoryEntryOpenable.value) { + minisearch.addAll( + restHistoryStore.value.state + .filter((x) => !!x.updatedOn) + .map((entry, index) => { + const relTimeString = capitalize( + useTimeAgo(entry.updatedOn!, { + updateInterval: 0, + }).value + ) + + return { + id: `rest-${index}`, + url: entry.request.endpoint, + reltime: relTimeString, + date: shortDateTime(entry.updatedOn!), + } + }) + ) + } + + if (this.gqlHistoryEntryOpenable.value) { + minisearch.addAll( + graphqlHistoryStore.value.state + .filter((x) => !!x.updatedOn) + .map((entry, index) => { + const relTimeString = capitalize( + useTimeAgo(entry.updatedOn!, { + updateInterval: 0, + }).value + ) + + return { + id: `gql-${index}`, + url: entry.request.url, + reltime: relTimeString, + date: shortDateTime(entry.updatedOn!), + } + }) + ) + } + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + watch( + [query, this.clearHistoryActionEnabled], + ([query]) => { + results.value = minisearch + .search(query, { + prefix: true, + fuzzy: true, + boost: { + reltime: 2, + }, + weights: { + fuzzy: 0.2, + prefix: 0.8, + }, + }) + .map((x) => { + if (x.id === "clear-history") { + return { + id: "clear-history", + icon: markRaw(IconTrash2), + score: x.score, + text: { + type: "text", + text: this.t("action.clear_history"), + }, + } + } + if (x.id.startsWith("rest-")) { + const entry = + restHistoryStore.value.state[parseInt(x.id.split("-")[1])] + + return { + id: x.id, + icon: markRaw(IconHistory), + score: x.score, + text: { + type: "custom", + component: markRaw(SpotlightRESTHistoryEntry), + componentProps: { + historyEntry: entry, + }, + }, + } + } else { + // Assume gql + const entry = + graphqlHistoryStore.value.state[parseInt(x.id.split("-")[1])] + + return { + id: x.id, + icon: markRaw(IconHistory), + score: x.score, + text: { + type: "custom", + component: markRaw(SpotlightGQLHistoryEntry), + componentProps: { + historyEntry: entry, + }, + }, + } + } + }) + }, + { immediate: true } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + stopWatchHandle() + minisearch.removeAll() + } + + const resultObj = computed(() => ({ + loading: loading.value, + results: results.value, + })) + + return [resultObj, onSessionEnd] + } + + onResultSelect(result: SpotlightSearcherResult): void { + if (result.id === "clear-history") { + invokeAction("history.clear") + } else if (result.id.startsWith("rest")) { + const req = + restHistoryStore.value.state[parseInt(result.id.split("-")[1])].request + + invokeAction("rest.request.open", { + doc: { + request: req, + isDirty: false, + }, + }) + } else { + // Assume gql + const req = + graphqlHistoryStore.value.state[parseInt(result.id.split("-")[1])] + .request + + invokeAction("gql.request.open", { + request: req, + }) + } + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/user.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/user.searcher.ts new file mode 100644 index 000000000..cc92bdb4c --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/user.searcher.ts @@ -0,0 +1,96 @@ +import { SpotlightSearcherResult, SpotlightService } from ".." +import { + SearchResult, + StaticSpotlightSearcherService, +} from "./base/static.searcher" +import { getI18n } from "~/modules/i18n" +import { Component, computed, markRaw, reactive } from "vue" +import { useStreamStatic } from "~/composables/stream" +import IconLogin from "~icons/lucide/log-in" +import IconLogOut from "~icons/lucide/log-out" +import { activeActions$, invokeAction } from "~/helpers/actions" + +type Doc = { + text: string + excludeFromSearch?: boolean + alternates: string[] + icon: object | Component +} + +/** + * This searcher is responsible for providing user related actions on the spotlight results. + * + * NOTE: Initializing this service registers it as a searcher with the Spotlight Service. + */ +export class UserSpotlightSearcherService extends StaticSpotlightSearcherService { + public static readonly ID = "USER_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public readonly searcherID = "user" + public searcherSectionTitle = this.t("spotlight.section.user") + + private readonly spotlight = this.bind(SpotlightService) + + private activeActions = useStreamStatic(activeActions$, [], () => { + /* noop */ + })[0] + + private hasLoginAction = computed(() => + this.activeActions.value.includes("user.login") + ) + + private hasLogoutAction = computed(() => + this.activeActions.value.includes("user.logout") + ) + + private documents: Record = reactive({ + login: { + text: this.t("auth.login"), + excludeFromSearch: computed(() => !this.hasLoginAction.value), + alternates: ["sign in", "log in"], + icon: markRaw(IconLogin), + }, + logout: { + text: this.t("auth.logout"), + excludeFromSearch: computed(() => !this.hasLogoutAction.value), + alternates: ["sign out", "log out"], + icon: markRaw(IconLogOut), + }, + }) + + constructor() { + super({ + searchFields: ["text", "alternates"], + fieldWeights: { + text: 2, + alternates: 1, + }, + }) + + this.setDocuments(this.documents) + this.spotlight.registerSearcher(this) + } + + protected getSearcherResultForSearchResult( + result: SearchResult + ): SpotlightSearcherResult { + return { + id: result.id, + icon: result.doc.icon, + text: { type: "text", text: result.doc.text }, + score: result.score, + } + } + + public onDocSelected(id: string): void { + switch (id) { + case "login": + invokeAction("user.login") + break + case "logout": + invokeAction("user.logout") + break + } + } +} diff --git a/packages/hoppscotch-common/src/setupTests.ts b/packages/hoppscotch-common/src/setupTests.ts new file mode 100644 index 000000000..4be13f91a --- /dev/null +++ b/packages/hoppscotch-common/src/setupTests.ts @@ -0,0 +1,71 @@ +import * as E from "fp-ts/Either" +import { expect } from "vitest" + +expect.extend({ + toBeLeft(received, expected) { + const { isNot } = this + + return { + pass: + E.isLeft(received) && + this.equals(received.left, expected, undefined, false), + message: () => + `Expected received value ${isNot ? "not " : ""}to be a left`, + } + }, + toBeRight(received) { + const { isNot } = this + + return { + pass: E.isRight(received), + message: () => + `Expected received value ${isNot ? "not " : ""}to be a right`, + } + }, + toEqualLeft(received, expected) { + const { isNot } = this + + const isLeft = E.isLeft(received) + const leftEquals = E.isLeft(received) + ? this.equals(received.left, expected) + : false + + return { + pass: isLeft && leftEquals, + message: () => { + if (!isLeft) { + return `Expected received value ${isNot ? "not " : ""}to be a left` + } else if (!leftEquals) { + return `Expected received left value ${ + isNot ? "not" : "" + } to equal expected value` + } + + throw new Error("Invalid state on `toEqualLeft` matcher") + }, + } + }, + toSubsetEqualRight(received, expected) { + const { isNot } = this + + const isRight = E.isRight(received) + const rightSubsetEquals = E.isRight(received) + ? !!this.utils.subsetEquality(received.right, expected) + : false + + return { + pass: isRight && rightSubsetEquals, + message: () => { + if (!isRight) { + return `Expected received value ${isNot ? "not " : ""}to be a left` + } else if (!rightSubsetEquals) { + return `Expected received left value to ${ + isNot ? "not " : "" + }equal expected value` + } + + throw new Error("Invalid state on `toSubsetEqualRight` matcher") + }, + } + }, +}) diff --git a/packages/hoppscotch-common/src/vitest.d.ts b/packages/hoppscotch-common/src/vitest.d.ts new file mode 100644 index 000000000..07d4836c2 --- /dev/null +++ b/packages/hoppscotch-common/src/vitest.d.ts @@ -0,0 +1,13 @@ +import type { Assertion, AsymmetricMatchersContaining } from "vitest" + +interface CustomMatchers { + toBeLeft(expected: unknown): R + toBeRight(): R + toEqualLeft(expected: unknown): R + toSubsetEqualRight(expected: unknown): R +} + +declare module 'vitest' { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} diff --git a/packages/hoppscotch-common/tsconfig.json b/packages/hoppscotch-common/tsconfig.json index 8b37ddb21..b42647625 100644 --- a/packages/hoppscotch-common/tsconfig.json +++ b/packages/hoppscotch-common/tsconfig.json @@ -1,9 +1,10 @@ { "compilerOptions": { "target": "ESNext", + "allowJs": true, "useDefineForClassFields": true, "module": "ESNext", - "moduleResolution": "node", + "moduleResolution": "node", "strict": true, "jsx": "preserve", "sourceMap": true, @@ -20,7 +21,7 @@ "@helpers/*": [ "./src/helpers/*" ], "@modules/*": [ "./src/modules/*" ], "@workers/*": [ "./src/workers/*" ], - "@functional/*": [ "./src/helpers/functional/*" ], + "@functional/*": [ "./src/helpers/functional/*" ] }, "types": [ "vite/client", @@ -28,14 +29,16 @@ "vite-plugin-pages/client", "vite-plugin-vue-layouts/client", "vite-plugin-pwa/client" - ], + ] }, "include": [ "meta.ts", + "src/**/*.js", + "src/*.ts", "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", - "src/**/*.vue", + "src/**/*.vue" ], "vueCompilerOptions": { "jsxTemplates": true, diff --git a/packages/hoppscotch-common/vitest.config.ts b/packages/hoppscotch-common/vitest.config.ts new file mode 100644 index 000000000..95fa896b7 --- /dev/null +++ b/packages/hoppscotch-common/vitest.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from "vitest/config" +import * as path from "path" +import Icons from "unplugin-icons/vite" +import { FileSystemIconLoader } from "unplugin-icons/loaders" +import Vue from "@vitejs/plugin-vue" + +export default defineConfig({ + test: { + environment: "jsdom", + setupFiles: "./src/setupTests.ts", + }, + resolve: { + alias: { + "~": path.resolve(__dirname, "../hoppscotch-common/src"), + "@composables": path.resolve( + __dirname, + "../hoppscotch-common/src/composables" + ), + }, + }, + plugins: [ + Vue(), + Icons({ + compiler: "vue3", + customCollections: { + hopp: FileSystemIconLoader("../hoppscotch-common/assets/icons"), + auth: FileSystemIconLoader("../hoppscotch-common/assets/icons/auth"), + brands: FileSystemIconLoader( + "../hoppscotch-common/assets/icons/brands" + ), + }, + }) as any, + ], +}) diff --git a/packages/hoppscotch-selfhost-web/package.json b/packages/hoppscotch-selfhost-web/package.json index ed7fec799..467f63240 100644 --- a/packages/hoppscotch-selfhost-web/package.json +++ b/packages/hoppscotch-selfhost-web/package.json @@ -66,7 +66,7 @@ "vite-plugin-html-config": "^1.0.10", "vite-plugin-inspect": "^0.7.4", "vite-plugin-pages": "^0.26.0", - "vite-plugin-pages-sitemap": "^1.4.0", + "vite-plugin-pages-sitemap": "^1.4.5", "vite-plugin-pwa": "^0.13.1", "vite-plugin-static-copy": "^0.12.0", "vite-plugin-vue-layouts": "^0.7.0", diff --git a/packages/hoppscotch-selfhost-web/vite.config.ts b/packages/hoppscotch-selfhost-web/vite.config.ts index 1ab48152c..fb5268a53 100644 --- a/packages/hoppscotch-selfhost-web/vite.config.ts +++ b/packages/hoppscotch-selfhost-web/vite.config.ts @@ -78,16 +78,14 @@ export default defineConfig({ routeStyle: "nuxt", dirs: "../hoppscotch-common/src/pages", importMode: "async", - onRoutesGenerated(routes) { - // HACK: See: https://github.com/jbaubree/vite-plugin-pages-sitemap/issues/173 - return ((generateSitemap as any).default as typeof generateSitemap)({ + onRoutesGenerated: (routes) => + generateSitemap({ routes, nuxtStyle: true, allowRobots: true, dest: ".sitemap-gen", hostname: ENV.VITE_BASE_URL, - }) - }, + }), }), StaticCopy({ targets: [ diff --git a/packages/hoppscotch-sh-admin/.dockerignore b/packages/hoppscotch-sh-admin/.dockerignore new file mode 100644 index 000000000..4b904442b --- /dev/null +++ b/packages/hoppscotch-sh-admin/.dockerignore @@ -0,0 +1 @@ +./node_modules diff --git a/packages/hoppscotch-sh-admin/assets/scss/styles.scss b/packages/hoppscotch-sh-admin/assets/scss/styles.scss index 64bfe65aa..da5e37ca3 100644 --- a/packages/hoppscotch-sh-admin/assets/scss/styles.scss +++ b/packages/hoppscotch-sh-admin/assets/scss/styles.scss @@ -4,6 +4,7 @@ @apply after:backface-hidden; @apply selection:bg-accentDark; @apply selection:text-accentContrast; + @apply overscroll-none; } :root { diff --git a/packages/hoppscotch-sh-admin/languages.json b/packages/hoppscotch-sh-admin/languages.json new file mode 100644 index 000000000..870bc07bf --- /dev/null +++ b/packages/hoppscotch-sh-admin/languages.json @@ -0,0 +1,195 @@ +[ + { + "code": "af", + "file": "af.json", + "iso": "af-AF", + "name": "Afrikaans" + }, + { + "code": "ar", + "dir": "rtl", + "file": "ar.json", + "iso": "ar-AR", + "name": "عربى" + }, + { + "code": "ca", + "file": "ca.json", + "iso": "ca-CA", + "name": "Català" + }, + { + "code": "cn", + "file": "cn.json", + "iso": "zh-CN", + "name": "简体中文" + }, + { + "code": "cs", + "file": "cs.json", + "iso": "cs-CS", + "name": "Čeština" + }, + { + "code": "da", + "file": "da.json", + "iso": "da-DA", + "name": "Dansk" + }, + { + "code": "de", + "file": "de.json", + "iso": "de-DE", + "name": "Deutsch" + }, + { + "code": "el", + "file": "el.json", + "iso": "el-EL", + "name": "Ελληνικά" + }, + { + "code": "en", + "file": "en.json", + "iso": "en-US", + "name": "English" + }, + { + "code": "es", + "file": "es.json", + "iso": "es-ES", + "name": "Español" + }, + { + "code": "fi", + "file": "fi.json", + "iso": "fi-FI", + "name": "Suomalainen" + }, + { + "code": "fr", + "file": "fr.json", + "iso": "fr-FR", + "name": "Français" + }, + { + "code": "he", + "file": "he.json", + "iso": "he-HE", + "name": "עִברִית" + }, + { + "code": "hi", + "file": "hi.json", + "iso": "hi-HI", + "name": "हिन्दी" + }, + { + "code": "hu", + "file": "hu.json", + "iso": "hu-HU", + "name": "Magyar" + }, + { + "code": "id", + "file": "id.json", + "iso": "id", + "name": "Indonesian" + }, + { + "code": "it", + "file": "it.json", + "iso": "it", + "name": "Italiano" + }, + { + "code": "ja", + "file": "ja.json", + "iso": "ja-JA", + "name": "日本語" + }, + { + "code": "ko", + "file": "ko.json", + "iso": "ko-KO", + "name": "한국어" + }, + { + "code": "nl", + "file": "nl.json", + "iso": "nl-NL", + "name": "Nederlands" + }, + { + "code": "no", + "file": "no.json", + "iso": "no-NO", + "name": "Norsk" + }, + { + "code": "pl", + "file": "pl.json", + "iso": "pl-PL", + "name": "Polskie" + }, + { + "code": "pt-br", + "file": "pt-br.json", + "iso": "pt-BR", + "name": "Português Brasileiro" + }, + { + "code": "pt", + "file": "pt.json", + "iso": "pt-PT", + "name": "Português" + }, + { + "code": "ro", + "file": "ro.json", + "iso": "ro-RO", + "name": "Română" + }, + { + "code": "ru", + "file": "ru.json", + "iso": "ru-RU", + "name": "Pусский" + }, + { + "code": "sr", + "file": "sr.json", + "iso": "sr-SR", + "name": "Српски" + }, + { + "code": "sv", + "file": "sv.json", + "iso": "sv-SV", + "name": "Svenska" + }, + { + "code": "tr", + "file": "tr.json", + "iso": "tr-TR", + "name": "Türkçe" + }, + { + "code": "tw", + "file": "tw.json", + "iso": "zh-TW", + "name": "繁體中文" + }, + { + "code": "uk", + "file": "uk.json", + "iso": "uk-UK", + "name": "Українська" + }, + { + "code": "vi", + "file": "vi.json", + "iso": "vi-VI", + "name": "Tiếng Việt" + } +] diff --git a/packages/hoppscotch-sh-admin/locales/af.json b/packages/hoppscotch-sh-admin/locales/af.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/af.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ar.json b/packages/hoppscotch-sh-admin/locales/ar.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ar.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ca.json b/packages/hoppscotch-sh-admin/locales/ca.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ca.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/cn.json b/packages/hoppscotch-sh-admin/locales/cn.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/cn.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/cs.json b/packages/hoppscotch-sh-admin/locales/cs.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/cs.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/da.json b/packages/hoppscotch-sh-admin/locales/da.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/da.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/de.json b/packages/hoppscotch-sh-admin/locales/de.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/de.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/el.json b/packages/hoppscotch-sh-admin/locales/el.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/el.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/en.json b/packages/hoppscotch-sh-admin/locales/en.json new file mode 100644 index 000000000..a955b839a --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/en.json @@ -0,0 +1,132 @@ +{ + "app": { + "collapse_sidebar": "Collapse Sidebar", + "expand_sidebar": "Expand Sidebar", + "name": "HOPPSCOTCH", + "no_name": "No name", + "open_navigation": "Open Navigation" + }, + "metrics": { + "dashboard": "Dashboard", + "no_metrics": "No metrics found", + "total_collections": "Total Collections", + "total_requests": "Total Requests", + "total_teams": "Total Teams", + "total_users": "Total Users" + }, + "role": { + "editor": "EDITOR", + "owner": "OWNER", + "viewer": "VIEWER" + }, + "state": { + "add_user_failure": "Failed to add user to the team!!", + "add_user_success": "User is now a member of the team!!", + "admin_failure": "Failed to make user an admin!!", + "admin_success": "User is now an admin!!", + "confirm_logout": "Confirm Logout", + "create_team_failure": "Failed to create team!!", + "create_team_success": "Team created successfully!!", + "delete_team_failure": "Team deletion failed!!", + "delete_team_success": "Team deleted successfully!!", + "delete_user_failure": "User deletion failed!!", + "delete_user_success": "User deleted successfully!!", + "email_failure": "Failed to send invitation", + "email_success": "Email invitation sent successfully", + "enter_team_email": "Please enter email of team owner!!", + "error": "Something went wrong", + "github_signin_failure": "Failed to login with Github", + "google_signin_failure": "Failed to login with Google", + "invalid_email": "Please enter a valid email address", + "logged_out": "Logged out", + "logout": "Logout", + "non_admin_login": "You are logged in. But you're not an admin", + "remove_admin_failure": "Failed to remove admin status!!", + "remove_admin_success": "Admin status removed!!", + "remove_admin_to_delete_user": "Remove admin privilege to delete the user!!", + "remove_invitee_failure": "Removal of invitee failed!!", + "remove_invitee_success": "Removal of invitee is successfull!!", + "remove_member_failure": "Member couldn't be removed!!", + "remove_member_success": "Member removed successfully!!", + "rename_team_failure": "Failed to rename team!!", + "rename_team_success": "Team renamed successfully!", + "role_update_failed": "Roles updation has failed!!", + "role_update_success": "Roles updated successfully!!", + "team_name_long": "Team name should be atleast 6 characters long!!", + "user_not_found": "User not found in the infra!!" + }, + "teams": { + "add_members": "Add Members", + "admin": "Admin", + "admin_Email": "Admin Email", + "admin_id": "Admin ID", + "cancel": "Cancel", + "confirm_team_deletion": "Confirm Deletion of the team?", + "create_team": "Create team", + "date": "Date", + "delete_team": "Delete Team", + "details": "Details", + "edit": "Edit", + "email": "Team owner email", + "email_address": "Email Address", + "error": "Something went wrong. Please try again later.", + "id": "Team ID", + "invited_email": "Invitee Email", + "invited_on": "Invited On", + "invites": "Invites", + "load_info_error": "Unable to load team info", + "load_list_error": "Unable to Load Teams List", + "members": "Number of members", + "name": "Team name", + "no_members": "No members in this team. Add members to this team to collaborate", + "no_pending_invites": "No pending invites", + "pending_invites": "Pending invites", + "remove": "Remove", + "rename": "Rename", + "save": "Save", + "send_invite": "Send Invite", + "show_more": "Show more", + "team_details": "Team details", + "team_members": "Members", + "team_members_tab": "Team members", + "teams": "Teams", + "uid": "UID", + "valid_name": "Please enter a valid team name", + "valid_owner_email": "Please enter a valid owner email" + }, + "users": { + "admin": "Admin", + "admin_email": "Admin Email", + "admin_id": "Admin ID", + "confirm_admin_to_user": "Do you want to remove admin status from this user?", + "confirm_user_deletion": "Confirm user deletion?", + "confirm_user_to_admin": "Do you want to make this user into an admin?", + "created_on": "Created On", + "date": "Date", + "delete": "Delete", + "email": "Email", + "email_address": "Email Address", + "id": "User ID", + "invite_user": "Invite User", + "invited_on": "Invited On", + "invitee_email": "Invitee Email", + "invited_users": "Invited Users", + "invalid_user": "Invalid User", + "load_info_error": "Unable to load user info", + "load_list_error": "Unable to Load Users List", + "make_admin": "Make admin", + "name": "Name", + "no_invite": "No invited users found", + "no_users": "No users found", + "not_found": "User not found", + "remove_admin_privilege": "Remove Admin Privilege", + "remove_admin_status": "Remove Admin Status", + "send_invite": "Send Invite", + "show_more": "Show more", + "uid": "UID", + "unnamed": "(Unnamed User)", + "user_not_found": "User not found in the infra!!", + "users": "Users", + "valid_email": "Please enter a valid email address" + } +} diff --git a/packages/hoppscotch-sh-admin/locales/es.json b/packages/hoppscotch-sh-admin/locales/es.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/es.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/fi.json b/packages/hoppscotch-sh-admin/locales/fi.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/fi.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/fr.json b/packages/hoppscotch-sh-admin/locales/fr.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/fr.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/he.json b/packages/hoppscotch-sh-admin/locales/he.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/he.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/hi.json b/packages/hoppscotch-sh-admin/locales/hi.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/hi.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/hu.json b/packages/hoppscotch-sh-admin/locales/hu.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/hu.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/id.json b/packages/hoppscotch-sh-admin/locales/id.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/id.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/it.json b/packages/hoppscotch-sh-admin/locales/it.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/it.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ja.json b/packages/hoppscotch-sh-admin/locales/ja.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ja.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ko.json b/packages/hoppscotch-sh-admin/locales/ko.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ko.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/nl.json b/packages/hoppscotch-sh-admin/locales/nl.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/nl.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/no.json b/packages/hoppscotch-sh-admin/locales/no.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/no.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/pl.json b/packages/hoppscotch-sh-admin/locales/pl.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/pl.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/pt-br.json b/packages/hoppscotch-sh-admin/locales/pt-br.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/pt-br.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/pt.json b/packages/hoppscotch-sh-admin/locales/pt.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/pt.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ro.json b/packages/hoppscotch-sh-admin/locales/ro.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ro.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/ru.json b/packages/hoppscotch-sh-admin/locales/ru.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/ru.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/sr.json b/packages/hoppscotch-sh-admin/locales/sr.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/sr.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/sv.json b/packages/hoppscotch-sh-admin/locales/sv.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/sv.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/tr.json b/packages/hoppscotch-sh-admin/locales/tr.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/tr.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/tw.json b/packages/hoppscotch-sh-admin/locales/tw.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/tw.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/uk.json b/packages/hoppscotch-sh-admin/locales/uk.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/uk.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/locales/vi.json b/packages/hoppscotch-sh-admin/locales/vi.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/packages/hoppscotch-sh-admin/locales/vi.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/packages/hoppscotch-sh-admin/package.json b/packages/hoppscotch-sh-admin/package.json index 5995eb1c6..c6b7fd935 100644 --- a/packages/hoppscotch-sh-admin/package.json +++ b/packages/hoppscotch-sh-admin/package.json @@ -30,12 +30,13 @@ "io-ts": "^2.2.16", "lodash-es": "^4.17.21", "rxjs": "^7.8.0", + "tippy.js": "^6.3.7", "ts-node-dev": "^2.0.0", "unplugin-icons": "^0.14.9", "unplugin-vue-components": "^0.21.0", "vue": "^3.2.6", + "vue-i18n": "^9.2.2", "vue-router": "4", - "tippy.js": "^6.3.7", "vue-tippy": "6.0.0-alpha.58" }, "devDependencies": { @@ -47,6 +48,7 @@ "@graphql-codegen/typescript-document-nodes": "3.0.0", "@graphql-codegen/typescript-operations": "3.0.0", "@graphql-codegen/urql-introspection": "2.2.1", + "@intlify/vite-plugin-vue-i18n": "^7.0.0", "@vitejs/plugin-vue": "^3.1.0", "@vue/compiler-sfc": "^3.2.6", "graphql-tag": "^2.12.6", diff --git a/packages/hoppscotch-sh-admin/src/components.d.ts b/packages/hoppscotch-sh-admin/src/components.d.ts index 472fa9266..c55f69a7e 100644 --- a/packages/hoppscotch-sh-admin/src/components.d.ts +++ b/packages/hoppscotch-sh-admin/src/components.d.ts @@ -14,14 +14,6 @@ declare module '@vue/runtime-core' { AppSidebar: typeof import('./components/app/Sidebar.vue')['default'] AppToast: typeof import('./components/app/Toast.vue')['default'] DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'] - HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'] - HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'] - HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'] - HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] - HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] - IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] - IconLucideUser: typeof import('~icons/lucide/user')['default'] - ProfilePicture: typeof import('./components/profile/Picture.vue')['default'] TeamsAdd: typeof import('./components/teams/Add.vue')['default'] TeamsDetails: typeof import('./components/teams/Details.vue')['default'] TeamsInvite: typeof import('./components/teams/Invite.vue')['default'] diff --git a/packages/hoppscotch-sh-admin/src/components/app/Header.vue b/packages/hoppscotch-sh-admin/src/components/app/Header.vue index 8d42cdb4f..b0280daff 100644 --- a/packages/hoppscotch-sh-admin/src/components/app/Header.vue +++ b/packages/hoppscotch-sh-admin/src/components/app/Header.vue @@ -5,14 +5,18 @@