Compare commits

...

47 Commits

Author SHA1 Message Date
Balu Babu
9eb067feaf chore: fixed issues with pnpm-lock file 2023-08-03 19:39:35 +05:30
Balu Babu
0b5f57436f chore: modified error code for magic-link provider check 2023-08-03 19:36:41 +05:30
Mir Arif Hasan
0c0ed5610e chore: env check func moved to utils file 2023-08-03 19:36:32 +05:30
Mir Arif Hasan
dce032a275 feat: feedback applied 2023-08-03 19:36:17 +05:30
Mir Arif Hasan
2599b1d326 chore: check added if ALLOWED_AUTH_PROVIDERS is there in the env file or not 2023-08-03 19:35:40 +05:30
Mir Arif Hasan
419e376f46 fix: provider return type in SSO guards 2023-08-03 19:35:21 +05:30
Mir Arif Hasan
c79fcbeceb chore: handled internal server error for missing auth providers 2023-08-03 19:35:14 +05:30
Mir Arif Hasan
092cb4c3a5 chore: auth provider name read from enum 2023-08-03 19:35:06 +05:30
Mir Arif Hasan
d3f25361f7 chore: removed unused imports 2023-08-03 19:34:57 +05:30
Mir Arif Hasan
09c13e86b2 feat: remove EmptyClassProvider class 2023-08-03 19:34:48 +05:30
Balu Babu
04bb219c12 chore: fixed mistake in AUTH_PROVIDER_NOT_SPECIFIED error description 2023-08-03 19:34:40 +05:30
Balu Babu
ca79cf40b1 chore: changed target of hoppscotch-backend service back to prod in docker.compose file 2023-08-03 19:34:32 +05:30
Balu Babu
454c82975e chore: added comments to authProviderCheck function in auth/helper.ts 2023-08-03 19:34:24 +05:30
Balu Babu
c38488dfc4 feat: magic-link can now be conditionally provisioned 2023-08-03 19:34:16 +05:30
Balu Babu
2d0ebedbbb feat: social auth providers can now be conditionally provisioned 2023-08-03 19:34:07 +05:30
Ankit Sridhar
88f6a4ae26 [feat] : Allow admins to revoke a team invite (HBE-230) (#3162)
feat: added functionality for admin to revoke team invite
2023-08-03 14:08:32 +05:30
Anwarul Islam
610538ca02 chore: type and ux improvement for SmartTree (#3126) 2023-08-02 20:54:02 +05:30
Nivedin
8970ff5c68 feat: context menu (#3180)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-02 20:52:16 +05:30
Liyas Thomas
d1a564d5b8 fix: elastic overscroll on safari (#3221) 2023-08-02 20:47:54 +05:30
Anwarul Islam
8bb1d19c07 fix: firefox browser scrollbar issue (#3201) 2023-08-01 13:20:17 +05:30
Liyas Thomas
5a516f7242 docs: fixed shortcut keys for spotlight and shortcuts menu (#3192) 2023-07-17 19:27:49 +05:30
Liyas Thomas
3b217d78e7 fix: deps mismatch for vite-plugin-pages-sitemap (#3191) 2023-07-17 19:26:43 +05:30
Liyas Thomas
8e153b38dc redesigned search button (#3187) 2023-07-17 14:40:14 +05:30
Akash K
6f38bfb148 chore: update generateSitemap usage (#3182) 2023-07-17 14:39:32 +05:30
Liyas Thomas
31fd6567b7 fix: text overflow on spotlight search results (#3181) 2023-07-17 12:32:45 +05:30
Andrew Bastin
8300f9a0a2 chore: merge release/2023.4.8 into release/2023.8.0 2023-07-13 12:10:14 +05:30
Andrew Bastin
5230d2d3b8 feat: revamped spotlight (#3171)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-07-11 23:02:33 +05:30
Nivedin
c3531c9d8b feat: auto-complete recent history entries in URL bar (#3141)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-07-11 20:03:42 +05:30
Andrew Bastin
9dbce74f5e chore: bump version to 2023.4.7 2023-06-27 15:43:03 +05:30
Liyas Thomas
db1cf5cc08 fix: explicitly added background color 2023-06-27 15:43:03 +05:30
Liyas Thomas
09360abf81 fix: text overflow on details summary label (#3160)
Co-authored-by: Nivedin <nivedinp@gmail.com>
2023-06-27 15:42:58 +05:30
Andrew Bastin
355bd62b8d feat: introduce more events into the analytics pipeline (#3156) 2023-06-27 15:37:25 +05:30
James Butler
5650de1183 fix: self-host unable to use Azure oauth (#3138) 2023-06-27 15:37:25 +05:30
Akash K
2ee8614b93 fix: use --location param for url when parsing curl (#3152) 2023-06-27 15:37:25 +05:30
Ankit Sridhar
5632334c9a fix: remove existing team invitation for an invitee when adding invitee to team by admin (HBE-229) (#3157) 2023-06-27 15:37:25 +05:30
Anwarul Islam
780dd8a713 fix: graphql authorization headers (#3136) 2023-06-27 15:37:25 +05:30
Nivedin
7db3c6d290 fix: unified bg color in collection tree (#3155) 2023-06-27 15:37:25 +05:30
Omer Baflah
c765270dfe fix: correct typos (#3153) 2023-06-27 15:37:25 +05:30
Webysther Sperandio
03f667c21d feat: custom location on admin redirect to base (#3103) 2023-06-27 15:37:25 +05:30
Balázs Úr
f79f3078dc chore(i18n): updated hungarian translation (#3151) 2023-06-27 15:37:25 +05:30
Nivedin
6e29a2f6d4 fix: shortcode resolution screen is stuck on invalid shortcodes (#3142)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-06-27 15:37:25 +05:30
Balu Babu
6304fd50c3 fix: fixed issue with team-invitations and new user accounts (#3137) 2023-06-27 15:37:25 +05:30
Andrew Bastin
6f35574d68 refactor: move hoppscotch-common tests to vitest (#3154) 2023-06-22 00:36:25 +05:30
Anwarul Islam
fc3e3aeaec feat: placeholder component in hoppscotch-ui (#3123)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-06-21 00:09:16 +05:30
Joel Jacob Stephen
331d482b22 feat: introducing i18n support to admin dashboard (#3051) 2023-06-16 09:47:00 +05:30
Andrew Bastin
b07243f131 chore: merge main@2023.4.6 into release/2023.8.0 2023-06-16 09:45:05 +05:30
Andrew Bastin
81a7e23a12 feat: introduce dioc into hoppscotch-common 2023-06-07 15:20:49 +05:30
201 changed files with 8787 additions and 2180 deletions

View File

@@ -13,6 +13,7 @@ SESSION_SECRET='add some secret here'
# Hoppscotch App Domain Config # Hoppscotch App Domain Config
REDIRECT_URL="http://localhost:3000" REDIRECT_URL="http://localhost:3000"
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100" WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
# Google Auth Config # Google Auth Config
GOOGLE_CLIENT_ID="************************************************" GOOGLE_CLIENT_ID="************************************************"

24
packages/dioc/.gitignore vendored Normal file
View File

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

141
packages/dioc/README.md Normal file
View File

@@ -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<TodoServiceEvent> {
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
<script setup>
import { TodoService } from "./demo.ts" // The above demo
import { useService } from "dioc/vue"
const todoService = useService(TodoService) // Returns an instance of the TodoService class
</script>
```

2
packages/dioc/index.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { default } from "./dist/main.d.ts"
export * from "./dist/main.d.ts"

View File

@@ -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<string, Service<unknown>>()
/** The RxJS observable representing the event stream */
protected event$ = new Subject<ContainerEvent>()
/**
* Returns whether a container has the given service bound
* @param service The service to check for
*/
public hasBound<
T extends typeof Service<any> & { 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<unknown> | 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<T extends typeof Service<any> & { ID: string }>(
service: T,
bounder: ((typeof Service<T>) & { ID: string }) | undefined = undefined
): InstanceType<T> {
// 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<T> // Casted as InstanceType<T> 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<any> = 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<T>
}
/**
* Returns an iterator of the currently bound service IDs and their instances
*/
public getBoundServices(): IterableIterator<[string, Service<any>]> {
return this.boundMap.entries()
}
/**
* Returns the public container event stream
*/
public getEventStream(): Observable<ContainerEvent> {
return this.event$.asObservable()
}
}

View File

@@ -0,0 +1,2 @@
export * from "./container"
export * from "./service"

View File

@@ -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<EventDef = {}> {
/**
* The internal event stream of the service
*/
private event$ = new Subject<EventDef>()
/** 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<T extends typeof Service<any> & { ID: string }>(service: T): InstanceType<T> {
if (!currentContainer) {
throw new Error('No currentContainer defined.')
}
return currentContainer.bind(service, this.constructor as typeof Service<any> & { 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<EventDef> {
return this.event$.asObservable()
}
}

View File

@@ -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<any> & { ID: string },
U extends Partial<InstanceType<T>>
>(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
}
}

34
packages/dioc/lib/vue.ts Normal file
View File

@@ -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<any> & { ID: string }
>(service: T): InstanceType<T> {
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)
}

View File

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

View File

@@ -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(<ContainerEvent>{
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(<ContainerEvent>{
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(<ContainerEvent>{
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, <ContainerEvent>{
type: "SERVICE_INIT",
serviceID: TestServiceA.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
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, <ContainerEvent>{
type: "SERVICE_BIND",
boundeeID: TestServiceA.ID,
bounderID: TestServiceB.ID,
})
expect(serviceFunc).toHaveBeenNthCalledWith(2, <ContainerEvent>{
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([])
})
})
})

View File

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

View File

@@ -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(<ContainerEvent>{
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 ?"
)
})
})
})

2
packages/dioc/testing.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { default } from "./dist/testing.d.ts"
export * from "./dist/testing.d.ts"

View File

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

View File

@@ -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'],
}
},
})

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
}
})

2
packages/dioc/vue.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { default } from "./dist/vue.d.ts"
export * from "./dist/vue.d.ts"

View File

@@ -411,6 +411,23 @@ export class AdminResolver {
return deletedTeam.right; 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<boolean> {
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
if (E.isLeft(invite)) throwErr(invite.left);
return true;
}
/* Subscriptions */ /* Subscriptions */
@Subscription(() => InvitedUser, { @Subscription(() => InvitedUser, {

View File

@@ -11,6 +11,7 @@ import {
INVALID_EMAIL, INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT, ONLY_ONE_ADMIN_ACCOUNT,
TEAM_INVITE_ALREADY_MEMBER, TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_NO_INVITE_FOUND,
USER_ALREADY_INVITED, USER_ALREADY_INVITED,
USER_IS_ADMIN, USER_IS_ADMIN,
USER_NOT_FOUND, USER_NOT_FOUND,
@@ -416,4 +417,19 @@ export class AdminService {
if (E.isLeft(team)) return E.left(team.left); if (E.isLeft(team)) return E.left(team.left);
return E.right(team.right); 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);
}
} }

View File

@@ -2,9 +2,9 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
InternalServerErrorException,
Post, Post,
Query, Query,
Req,
Request, Request,
Res, Res,
UseGuards, UseGuards,
@@ -19,12 +19,18 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser'; import { AuthUser } from 'src/types/AuthUser';
import { RTCookie } from 'src/decorators/rt-cookie.decorator'; import { RTCookie } from 'src/decorators/rt-cookie.decorator';
import { authCookieHandler, throwHTTPErr } from './helper'; import {
AuthProvider,
authCookieHandler,
authProviderCheck,
throwHTTPErr,
} from './helper';
import { GoogleSSOGuard } from './guards/google-sso.guard'; import { GoogleSSOGuard } from './guards/google-sso.guard';
import { GithubSSOGuard } from './guards/github-sso.guard'; import { GithubSSOGuard } from './guards/github-sso.guard';
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard'; import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@UseGuards(ThrottlerBehindProxyGuard) @UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' }) @Controller({ path: 'auth', version: '1' })
@@ -39,6 +45,9 @@ export class AuthController {
@Body() authData: SignInMagicDto, @Body() authData: SignInMagicDto,
@Query('origin') origin: string, @Query('origin') origin: string,
) { ) {
if (!authProviderCheck(AuthProvider.EMAIL))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
const deviceIdToken = await this.authService.signInMagicLink( const deviceIdToken = await this.authService.signInMagicLink(
authData.email, authData.email,
origin, origin,

View File

@@ -11,6 +11,7 @@ import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
import { GoogleStrategy } from './strategies/google.strategy'; import { GoogleStrategy } from './strategies/google.strategy';
import { GithubStrategy } from './strategies/github.strategy'; import { GithubStrategy } from './strategies/github.strategy';
import { MicrosoftStrategy } from './strategies/microsoft.strategy'; import { MicrosoftStrategy } from './strategies/microsoft.strategy';
import { AuthProvider, authProviderCheck } from './helper';
@Module({ @Module({
imports: [ imports: [
@@ -26,9 +27,9 @@ import { MicrosoftStrategy } from './strategies/microsoft.strategy';
AuthService, AuthService,
JwtStrategy, JwtStrategy,
RTJwtStrategy, RTJwtStrategy,
GoogleStrategy, ...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
GithubStrategy, ...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
MicrosoftStrategy, ...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
], ],
controllers: [AuthController], controllers: [AuthController],
}) })

View File

@@ -1,8 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable() @Injectable()
export class GithubSSOGuard extends AuthGuard('github') { export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GITHUB))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
return super.canActivate(context);
}
getAuthenticateOptions(context: ExecutionContext) { getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest(); const req = context.switchToHttp().getRequest();

View File

@@ -1,8 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable() @Injectable()
export class GoogleSSOGuard extends AuthGuard('google') { export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.GOOGLE))
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
return super.canActivate(context);
}
getAuthenticateOptions(context: ExecutionContext) { getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest(); const req = context.switchToHttp().getRequest();

View File

@@ -1,8 +1,26 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
@Injectable() @Injectable()
export class MicrosoftSSOGuard extends AuthGuard('microsoft') { export class MicrosoftSSOGuard
extends AuthGuard('microsoft')
implements CanActivate
{
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (!authProviderCheck(AuthProvider.MICROSOFT))
throwHTTPErr({
message: AUTH_PROVIDER_NOT_SPECIFIED,
statusCode: 404,
});
return super.canActivate(context);
}
getAuthenticateOptions(context: ExecutionContext) { getAuthenticateOptions(context: ExecutionContext) {
const req = context.switchToHttp().getRequest(); const req = context.switchToHttp().getRequest();

View File

@@ -1,10 +1,11 @@
import { ForbiddenException, HttpException, HttpStatus } from '@nestjs/common'; import { HttpException, HttpStatus } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AuthError } from 'src/types/AuthError'; import { AuthError } from 'src/types/AuthError';
import { AuthTokens } from 'src/types/AuthTokens'; import { AuthTokens } from 'src/types/AuthTokens';
import { Response } from 'express'; import { Response } from 'express';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { COOKIES_NOT_FOUND } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
import { throwErr } from 'src/utils';
enum AuthTokenType { enum AuthTokenType {
ACCESS_TOKEN = 'access_token', ACCESS_TOKEN = 'access_token',
@@ -16,6 +17,13 @@ export enum Origin {
APP = 'app', APP = 'app',
} }
export enum AuthProvider {
GOOGLE = 'GOOGLE',
GITHUB = 'GITHUB',
MICROSOFT = 'MICROSOFT',
EMAIL = 'EMAIL',
}
/** /**
* This function allows throw to be used as an expression * This function allows throw to be used as an expression
* @param errMessage Message present in the error message * @param errMessage Message present in the error message
@@ -97,3 +105,25 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN], refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
}; };
}; };
/**
* Check to see if given auth provider is present in the ALLOWED_AUTH_PROVIDERS env variable
*
* @param provider Provider we want to check the presence of
* @returns Boolean if provider specified is present or not
*/
export function authProviderCheck(provider: string) {
if (!provider) {
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
}
const envVariables = process.env.ALLOWED_AUTH_PROVIDERS
? process.env.ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
provider.trim().toUpperCase(),
)
: [];
if (!envVariables.includes(provider.toUpperCase())) return false;
return true;
}

View File

@@ -22,6 +22,30 @@ export const AUTH_FAIL = 'auth/fail';
*/ */
export const JSON_INVALID = 'json_invalid'; export const JSON_INVALID = 'json_invalid';
/**
* Auth Provider not specified
* (Auth)
*/
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
/**
* Environment variable "ALLOWED_AUTH_PROVIDERS" is not present in .env file
*/
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
'"ALLOWED_AUTH_PROVIDERS" is not present in .env file';
/**
* Environment variable "ALLOWED_AUTH_PROVIDERS" is empty in .env file
*/
export const ENV_EMPTY_AUTH_PROVIDERS =
'"ALLOWED_AUTH_PROVIDERS" is empty in .env file';
/**
* Environment variable "ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
*/
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
'"ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
/** /**
* Tried to delete a user data document from fb firestore but failed. * Tried to delete a user data document from fb firestore but failed.
* (FirebaseService) * (FirebaseService)

View File

@@ -5,11 +5,14 @@ import * as cookieParser from 'cookie-parser';
import { VersioningType } from '@nestjs/common'; import { VersioningType } from '@nestjs/common';
import * as session from 'express-session'; import * as session from 'express-session';
import { emitGQLSchemaFile } from './gql-schema'; import { emitGQLSchemaFile } from './gql-schema';
import { checkEnvironmentAuthProvider } from './utils';
async function bootstrap() { async function bootstrap() {
console.log(`Running in production: ${process.env.PRODUCTION}`); console.log(`Running in production: ${process.env.PRODUCTION}`);
console.log(`Port: ${process.env.PORT}`); console.log(`Port: ${process.env.PORT}`);
checkEnvironmentAuthProvider();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.use( app.use(

View File

@@ -15,7 +15,7 @@ import { TeamService } from 'src/team/team.service';
* This guard only allows user to execute the resolver * This guard only allows user to execute the resolver
* 1. If user is invitee, allow * 1. If user is invitee, allow
* 2. Or else, if user is team member, allow * 2. Or else, if user is team member, allow
* *
* TLDR: Allow if user is invitee or team member * TLDR: Allow if user is invitee or team member
*/ */
@Injectable() @Injectable()

View File

@@ -9,7 +9,8 @@ import * as E from 'fp-ts/Either';
import * as A from 'fp-ts/Array'; import * as A from 'fp-ts/Array';
import { TeamMemberRole } from './team/team.model'; import { TeamMemberRole } from './team/team.model';
import { User } from './user/user.model'; import { User } from './user/user.model';
import { JSON_INVALID } from './errors'; import { ENV_EMPTY_AUTH_PROVIDERS, ENV_NOT_FOUND_KEY_AUTH_PROVIDERS, ENV_NOT_SUPPORT_AUTH_PROVIDERS, JSON_INVALID } from './errors';
import { AuthProvider } from './auth/helper';
/** /**
* A workaround to throw an exception in an expression. * A workaround to throw an exception in an expression.
@@ -152,3 +153,31 @@ export function isValidLength(title: string, length: number) {
return true; return true;
} }
/**
* This function is called by bootstrap() in main.ts
* It checks if the "ALLOWED_AUTH_PROVIDERS" environment variable is properly set or not.
* If not, it throws an error.
*/
export function checkEnvironmentAuthProvider() {
if (!process.env.hasOwnProperty('ALLOWED_AUTH_PROVIDERS')) {
throw new Error(ENV_NOT_FOUND_KEY_AUTH_PROVIDERS);
}
if (process.env.ALLOWED_AUTH_PROVIDERS === '') {
throw new Error(ENV_EMPTY_AUTH_PROVIDERS);
}
const givenAuthProviders = process.env.ALLOWED_AUTH_PROVIDERS.split(',').map(
(provider) => provider.toLocaleUpperCase(),
);
const supportedAuthProviders = Object.values(AuthProvider).map(
(provider: string) => provider.toLocaleUpperCase(),
);
for (const givenAuthProvider of givenAuthProviders) {
if (!supportedAuthProviders.includes(givenAuthProvider)) {
throw new Error(ENV_NOT_SUPPORT_AUTH_PROVIDERS);
}
}
}

View File

@@ -6,7 +6,6 @@ module.exports = {
env: { env: {
browser: true, browser: true,
node: true, node: true,
jest: true,
}, },
parserOptions: { parserOptions: {
sourceType: "module", sourceType: "module",

View File

@@ -4,6 +4,7 @@
@apply after:backface-hidden; @apply after:backface-hidden;
@apply selection:bg-accentDark; @apply selection:bg-accentDark;
@apply selection:text-accentContrast; @apply selection:text-accentContrast;
@apply overscroll-none;
} }
:root { :root {

View File

@@ -4,6 +4,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"choose_file": "Choose a file", "choose_file": "Choose a file",
"clear": "Clear", "clear": "Clear",
"clear_history": "Clear All History",
"clear_all": "Clear all", "clear_all": "Clear all",
"close": "Close", "close": "Close",
"connect": "Connect", "connect": "Connect",
@@ -150,6 +151,11 @@
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "Do you want to save changes made in this tab?",
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress." "sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
}, },
"context_menu": {
"set_environment_variable": "Set as variable",
"add_parameter": "Add to parameter",
"open_link_in_new_tab": "Open link in new tab"
},
"count": { "count": {
"header": "Header {count}", "header": "Header {count}",
"message": "Message {count}", "message": "Message {count}",
@@ -173,6 +179,7 @@
"folder": "Folder is empty", "folder": "Folder is empty",
"headers": "This request does not have any headers", "headers": "This request does not have any headers",
"history": "History is empty", "history": "History is empty",
"history_suggestions": "History does not have any matching entries",
"invites": "Invite list is empty", "invites": "Invite list is empty",
"members": "Team is empty", "members": "Team is empty",
"parameters": "This request does not have any parameters", "parameters": "This request does not have any parameters",
@@ -193,16 +200,23 @@
"created": "Environment created", "created": "Environment created",
"deleted": "Environment deletion", "deleted": "Environment deletion",
"edit": "Edit Environment", "edit": "Edit Environment",
"global": "Global",
"invalid_name": "Please provide a name for the environment", "invalid_name": "Please provide a name for the environment",
"my_environments": "My Environments", "my_environments": "My Environments",
"name": "Name",
"nested_overflow": "nested environment variables are limited to 10 levels", "nested_overflow": "nested environment variables are limited to 10 levels",
"new": "New Environment", "new": "New Environment",
"no_environment": "No environment", "no_environment": "No environment",
"no_environment_description": "No environments were selected. Choose what to do with the following variables.", "no_environment_description": "No environments were selected. Choose what to do with the following variables.",
"replace_with_variable": "Replace with variable",
"scope": "Scope",
"select": "Select environment", "select": "Select environment",
"set_as_environment": "Set as environment",
"team_environments": "Team Environments", "team_environments": "Team Environments",
"title": "Environments", "title": "Environments",
"updated": "Environment updated", "updated": "Environment updated",
"value": "Value",
"variable": "Variable",
"variable_list": "Variable List" "variable_list": "Variable List"
}, },
"error": { "error": {
@@ -582,6 +596,11 @@
"log": "Log", "log": "Log",
"url": "URL" "url": "URL"
}, },
"spotlight": {
"section": {
"user": "User"
}
},
"sse": { "sse": {
"event_type": "Event type", "event_type": "Event type",
"log": "Log", "log": "Log",

View File

@@ -4,6 +4,8 @@
"version": "2023.4.7", "version": "2023.4.7",
"scripts": { "scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*", "dev": "pnpm exec npm-run-all -p -l dev:*",
"test": "vitest --run",
"test:watch": "vitest",
"dev:vite": "vite", "dev:vite": "vite",
"dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml --watch dotenv_config_path=\"../../.env\"", "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 .", "lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .",
@@ -13,6 +15,7 @@
"preview": "vite preview", "preview": "vite preview",
"gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"", "gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"",
"postinstall": "pnpm run gql-codegen", "postinstall": "pnpm run gql-codegen",
"do-test": "pnpm run test",
"do-lint": "pnpm run prod-lint", "do-lint": "pnpm run prod-lint",
"do-typecheck": "pnpm run lint", "do-typecheck": "pnpm run lint",
"do-lintfix": "pnpm run lintfix" "do-lintfix": "pnpm run lintfix"
@@ -43,11 +46,12 @@
"@urql/exchange-auth": "^0.1.7", "@urql/exchange-auth": "^0.1.7",
"@urql/exchange-graphcache": "^4.4.3", "@urql/exchange-graphcache": "^4.4.3",
"@vitejs/plugin-legacy": "^2.3.0", "@vitejs/plugin-legacy": "^2.3.0",
"@vueuse/core": "^8.7.5", "@vueuse/core": "^8.9.4",
"@vueuse/head": "^0.7.9", "@vueuse/head": "^0.7.9",
"acorn-walk": "^8.2.0", "acorn-walk": "^8.2.0",
"axios": "^0.21.4", "axios": "^0.21.4",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"dioc": "workspace:^",
"esprima": "^4.0.1", "esprima": "^4.0.1",
"events": "^3.3.0", "events": "^3.3.0",
"fp-ts": "^2.12.1", "fp-ts": "^2.12.1",
@@ -63,6 +67,7 @@
"jsonpath-plus": "^7.0.0", "jsonpath-plus": "^7.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lossless-json": "^2.0.8", "lossless-json": "^2.0.8",
"minisearch": "^6.1.0",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"paho-mqtt": "^1.1.0", "paho-mqtt": "^1.1.0",
"path": "^0.12.7", "path": "^0.12.7",
@@ -84,7 +89,6 @@
"util": "^0.12.4", "util": "^0.12.4",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"vue": "^3.2.25", "vue": "^3.2.25",
"vue-github-button": "^3.0.3",
"vue-i18n": "^9.2.2", "vue-i18n": "^9.2.2",
"vue-pdf-embed": "^1.1.4", "vue-pdf-embed": "^1.1.4",
"vue-router": "^4.0.16", "vue-router": "^4.0.16",
@@ -106,8 +110,9 @@
"@graphql-codegen/typescript-urql-graphcache": "^2.3.1", "@graphql-codegen/typescript-urql-graphcache": "^2.3.1",
"@graphql-codegen/urql-introspection": "^2.2.0", "@graphql-codegen/urql-introspection": "^2.2.0",
"@graphql-typed-document-node/core": "^3.1.1", "@graphql-typed-document-node/core": "^3.1.1",
"@iconify-json/lucide": "^1.1.40", "@iconify-json/lucide": "^1.1.109",
"@intlify/vite-plugin-vue-i18n": "^7.0.0", "@intlify/vite-plugin-vue-i18n": "^7.0.0",
"@relmify/jest-fp-ts": "^2.1.1",
"@rushstack/eslint-patch": "^1.1.4", "@rushstack/eslint-patch": "^1.1.4",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
@@ -142,10 +147,11 @@
"vite-plugin-html-config": "^1.0.10", "vite-plugin-html-config": "^1.0.10",
"vite-plugin-inspect": "^0.7.4", "vite-plugin-inspect": "^0.7.4",
"vite-plugin-pages": "^0.26.0", "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-pwa": "^0.13.1",
"vite-plugin-vue-layouts": "^0.7.0", "vite-plugin-vue-layouts": "^0.7.0",
"vite-plugin-windicss": "^1.8.8", "vite-plugin-windicss": "^1.8.8",
"vitest": "^0.32.2",
"vue-tsc": "^0.38.2", "vue-tsc": "^0.38.2",
"windicss": "^3.5.6" "windicss": "^3.5.6"
} }

View File

@@ -9,22 +9,24 @@ declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default'] AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default'] AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default'] AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default'] AppFooter: typeof import('./components/app/Footer.vue')['default']
AppFuse: typeof import('./components/app/Fuse.vue')['default']
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default'] AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
AppHeader: typeof import('./components/app/Header.vue')['default'] AppHeader: typeof import('./components/app/Header.vue')['default']
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default'] AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
AppLogo: typeof import('./components/app/Logo.vue')['default'] AppLogo: typeof import('./components/app/Logo.vue')['default']
AppOptions: typeof import('./components/app/Options.vue')['default'] AppOptions: typeof import('./components/app/Options.vue')['default']
AppPaneLayout: typeof import('./components/app/PaneLayout.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'] AppShare: typeof import('./components/app/Share.vue')['default']
AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default'] AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default']
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default'] AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default'] AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
AppSidenav: typeof import('./components/app/Sidenav.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'] AppSupport: typeof import('./components/app/Support.vue')['default']
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default'] ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.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'] CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default'] CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default'] Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default'] EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default'] EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default'] EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
@@ -131,6 +134,7 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default'] IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
@@ -168,6 +172,7 @@ declare module '@vue/runtime-core' {
SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default'] SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default']
SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default'] SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default']
SmartPicture: typeof import('./../../hoppscotch-ui/src/components/smart/Picture.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'] SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default'] SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default'] SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default']

View File

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

View File

@@ -152,7 +152,7 @@
v-tippy="{ theme: 'tooltip', allowHTML: true }" v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t( :title="`${t(
'app.shortcuts' 'app.shortcuts'
)} <kbd>${getSpecialKey()}</kbd><kbd>K</kbd>`" )} <kbd>${getSpecialKey()}</kbd><kbd>/</kbd>`"
:icon="IconZap" :icon="IconZap"
@click="invokeAction('flyouts.keybinds.toggle')" @click="invokeAction('flyouts.keybinds.toggle')"
/> />

View File

@@ -1,70 +0,0 @@
<template>
<div key="outputHash" class="flex flex-col flex-1 overflow-auto">
<div class="flex flex-col">
<AppPowerSearchEntry
v-for="(shortcut, shortcutIndex) in searchResults"
:key="`shortcut-${shortcutIndex}`"
:active="shortcutIndex === selectedEntry"
:shortcut="shortcut.item"
@action="emit('action', shortcut.item.action)"
@mouseover="selectedEntry = shortcutIndex"
/>
</div>
<div
v-if="searchResults.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center">
{{ t("state.nothing_found") }} "{{ search }}"
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onUnmounted, onMounted } from "vue"
import Fuse from "fuse.js"
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
import { HoppAction } from "~/helpers/actions"
import { useI18n } from "@composables/i18n"
const t = useI18n()
const props = defineProps<{
input: Record<string, any>[]
search: string
}>()
const emit = defineEmits<{
(e: "action", action: HoppAction): void
}>()
const options = {
keys: ["keys", "label", "action", "tags"],
}
const fuse = new Fuse(props.input, options)
const searchResults = computed(() => fuse.search(props.search))
const searchResultsItems = computed(() =>
searchResults.value.map((searchResult) => searchResult.item)
)
const emitSearchAction = (action: HoppAction) => emit("action", action)
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
useArrowKeysNavigation(searchResultsItems, {
onEnter: emitSearchAction,
stopPropagation: true,
})
onMounted(() => {
bindArrowKeysListeners()
})
onUnmounted(() => {
unbindArrowKeysListeners()
})
</script>

View File

@@ -15,16 +15,21 @@
:label="t('app.name')" :label="t('app.name')"
to="/" to="/"
/> />
<!-- <AppGitHubStarButton class="mt-1.5 transition" /> -->
</div> </div>
<div class="inline-flex items-center space-x-2"> <div class="inline-flex items-center justify-center flex-1 space-x-2">
<HoppButtonSecondary <button
v-tippy="{ theme: 'tooltip', allowHTML: true }" class="flex flex-1 items-center justify-between px-2 py-1 bg-primaryDark transition text-secondaryLight cursor-text rounded border border-dividerDark max-w-xs hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
:title="`${t('app.search')} <kbd>/</kbd>`"
:icon="IconSearch"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.search.toggle')" @click="invokeAction('modals.search.toggle')"
/> >
<span class="inline-flex flex-1 items-center">
<icon-lucide-search class="mr-2 svg-icons" />
{{ t("app.search") }}
</span>
<span class="flex">
<kbd class="shortcut-key">{{ getPlatformSpecialKey() }}</kbd>
<kbd class="shortcut-key">K</kbd>
</span>
</button>
<HoppButtonSecondary <HoppButtonSecondary
v-if="showInstallButton" v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -42,6 +47,8 @@
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark" class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')" @click="invokeAction('modals.support.toggle')"
/> />
</div>
<div class="inline-flex items-center justify-end flex-1 space-x-2">
<div <div
v-if="currentUser === null" v-if="currentUser === null"
class="inline-flex items-center space-x-2" class="inline-flex items-center space-x-2"
@@ -236,17 +243,17 @@ import IconDownload from "~icons/lucide/download"
import IconUploadCloud from "~icons/lucide/upload-cloud" import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUserPlus from "~icons/lucide/user-plus" import IconUserPlus from "~icons/lucide/user-plus"
import IconLifeBuoy from "~icons/lucide/life-buoy" import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSearch from "~icons/lucide/search"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core" import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { pwaDefferedPrompt, installPWA } from "@modules/pwa" import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
import { platform } from "~/platform" import { platform } from "~/platform"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { invokeAction } from "@helpers/actions" import { defineActionHandler, invokeAction } from "@helpers/actions"
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace" import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter" import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { onLoggedIn } from "~/composables/auth" import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql" import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
const t = useI18n() const t = useI18n()
@@ -374,4 +381,12 @@ const profile = ref<any | null>(null)
const settings = ref<any | null>(null) const settings = ref<any | null>(null)
const logout = ref<any | null>(null) const logout = ref<any | null>(null)
const accountActions = ref<any | null>(null) const accountActions = ref<any | null>(null)
defineActionHandler(
"user.login",
() => {
invokeAction("modals.login.toggle")
},
computed(() => !currentUser.value)
)
</script> </script>

View File

@@ -1,122 +0,0 @@
<template>
<HoppSmartModal
v-if="show"
styles="sm:max-w-lg"
full-width
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col border-b transition border-dividerLight">
<input
id="command"
v-model="search"
v-focus
type="text"
autocomplete="off"
name="command"
:placeholder="`${t('app.type_a_command_search')}`"
class="flex flex-shrink-0 p-6 text-base bg-transparent text-secondaryDark"
/>
<div
class="flex flex-shrink-0 text-tiny text-secondaryLight px-4 pb-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
>
<div class="flex items-center">
<kbd class="shortcut-key"></kbd>
<kbd class="shortcut-key"></kbd>
<span class="ml-2 truncate">
{{ t("action.to_navigate") }}
</span>
<kbd class="shortcut-key"></kbd>
<span class="ml-2 truncate">
{{ t("action.to_select") }}
</span>
</div>
<div class="flex items-center">
<kbd class="shortcut-key">ESC</kbd>
<span class="ml-2 truncate">
{{ t("action.to_close") }}
</span>
</div>
</div>
</div>
<AppFuse
v-if="search && show"
:input="fuse"
:search="search"
@action="runAction"
/>
<div
v-else
class="flex flex-col flex-1 overflow-auto space-y-4 divide-y divide-dividerLight"
>
<div
v-for="(map, mapIndex) in mappings"
:key="`map-${mapIndex}`"
class="flex flex-col"
>
<h5 class="px-6 py-2 my-2 text-secondaryLight">
{{ t(map.section) }}
</h5>
<AppPowerSearchEntry
v-for="(shortcut, shortcutIndex) in map.shortcuts"
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
:shortcut="shortcut"
:active="shortcutsItems.indexOf(shortcut) === selectedEntry"
@action="runAction"
@mouseover="selectedEntry = shortcutsItems.indexOf(shortcut)"
/>
</div>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue"
import { HoppAction, invokeAction } from "~/helpers/actions"
import { spotlight as mappings, fuse } from "@helpers/shortcuts"
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
import { useI18n } from "@composables/i18n"
const t = useI18n()
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const search = ref("")
const hideModal = () => {
search.value = ""
emit("hide-modal")
}
const runAction = (command: HoppAction) => {
invokeAction(command)
hideModal()
}
const shortcutsItems = computed(() =>
mappings.reduce(
(shortcuts, section) => [...shortcuts, ...section.shortcuts],
[]
)
)
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
useArrowKeysNavigation(shortcutsItems, {
onEnter: runAction,
})
watch(
() => props.show,
(show) => {
if (show) bindArrowKeysListeners()
else unbindArrowKeysListeners()
}
)
</script>

View File

@@ -1,68 +0,0 @@
<template>
<button
class="flex items-center flex-1 px-6 py-3 font-medium transition cursor-pointer search-entry focus:outline-none"
:class="{ active: active }"
tabindex="-1"
@click="emit('action', shortcut.action)"
@keydown.enter="emit('action', shortcut.action)"
>
<component
:is="shortcut.icon"
class="mr-4 transition opacity-50 svg-icons"
:class="{ 'opacity-100 text-secondaryDark': active }"
/>
<span
class="flex flex-1 mr-4 transition"
:class="{ 'text-secondaryDark': active }"
>
{{ t(shortcut.label) }}
</span>
<kbd
v-for="(key, keyIndex) in shortcut.keys"
:key="`key-${String(keyIndex)}`"
class="shortcut-key"
>
{{ key }}
</kbd>
</button>
</template>
<script setup lang="ts">
import type { Component } from "vue"
import { useI18n } from "@composables/i18n"
const t = useI18n()
defineProps<{
shortcut: {
label: string
keys: string[]
action: string
icon: object | Component
}
active: boolean
}>()
const emit = defineEmits<{
(e: "action", action: string): void
}>()
</script>
<style lang="scss" scoped>
.search-entry {
@apply relative;
@apply after:absolute;
@apply after:top-0;
@apply after:left-0;
@apply after:bottom-0;
@apply after:bg-transparent;
@apply after:z-2;
@apply after:w-0.5;
@apply after:content-DEFAULT;
&.active {
@apply bg-primaryLight;
@apply after:bg-accentLight;
}
}
</style>

View File

@@ -14,46 +14,17 @@
/> />
</div> </div>
</div> </div>
<div v-if="filterText" class="flex flex-col divide-y divide-dividerLight"> <div class="flex flex-col divide-y divide-dividerLight">
<details <HoppSmartPlaceholder
v-for="(map, mapIndex) in searchResults" v-if="isEmpty(shortcutsResults)"
:key="`map-${mapIndex}`" :text="`${t('state.nothing_found')} ‟${filterText}”`"
class="flex flex-col"
open
>
<summary
class="flex items-center flex-1 min-w-0 px-6 py-4 font-semibold transition cursor-pointer focus:outline-none text-secondaryLight hover:text-secondaryDark"
>
<icon-lucide-chevron-right class="mr-2 indicator" />
<span
class="font-semibold truncate capitalize-first text-secondaryDark"
>
{{ t(map.item.section) }}
</span>
</summary>
<div class="flex flex-col px-6 pb-4 space-y-2">
<AppShortcutsEntry
v-for="(shortcut, index) in map.item.shortcuts"
:key="`shortcut-${index}`"
:shortcut="shortcut"
/>
</div>
</details>
<div
v-if="searchResults.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
> >
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center flex flex-col"> </HoppSmartPlaceholder>
{{ t("state.nothing_found") }}
<span class="break-all">"{{ filterText }}"</span>
</span>
</div>
</div>
<div v-else class="flex flex-col divide-y divide-dividerLight">
<details <details
v-for="(map, mapIndex) in mappings" v-for="(sectionResults, sectionTitle) in shortcutsResults"
:key="`map-${mapIndex}`" v-else
:key="`section-${sectionTitle}`"
class="flex flex-col" class="flex flex-col"
open open
> >
@@ -64,13 +35,13 @@
<span <span
class="font-semibold truncate capitalize-first text-secondaryDark" class="font-semibold truncate capitalize-first text-secondaryDark"
> >
{{ t(map.section) }} {{ sectionTitle }}
</span> </span>
</summary> </summary>
<div class="flex flex-col px-6 pb-4 space-y-2"> <div class="flex flex-col px-6 pb-4 space-y-2">
<AppShortcutsEntry <AppShortcutsEntry
v-for="(shortcut, shortcutIndex) in map.shortcuts" v-for="(shortcut, index) in sectionResults"
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`" :key="`shortcut-${index}`"
:shortcut="shortcut" :shortcut="shortcut"
/> />
</div> </div>
@@ -81,10 +52,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue" import { computed, onBeforeMount, ref } from "vue"
import Fuse from "fuse.js" import { ShortcutDef, getShortcuts } from "~/helpers/shortcuts"
import mappings from "~/helpers/shortcuts" import MiniSearch from "minisearch"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { groupBy, isEmpty } from "lodash-es"
const t = useI18n() const t = useI18n()
@@ -92,15 +64,33 @@ defineProps<{
show: boolean show: boolean
}>() }>()
const options = { const minisearch = new MiniSearch({
keys: ["shortcuts.label"], fields: ["label", "keys", "section"],
} idField: "label",
storeFields: ["label", "keys", "section"],
searchOptions: {
fuzzy: true,
prefix: true,
},
})
const fuse = new Fuse(mappings, options) const shortcuts = getShortcuts(t)
onBeforeMount(() => {
minisearch.addAllAsync(shortcuts)
})
const filterText = ref("") const filterText = ref("")
const searchResults = computed(() => fuse.search(filterText.value)) const shortcutsResults = computed(() => {
// If there are no search text, return all the shortcuts
const results =
filterText.value.length > 0
? minisearch.search(filterText.value)
: shortcuts
return groupBy(results, "section") as Record<string, ShortcutDef[]>
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: "close"): void (e: "close"): void

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex items-center py-1"> <div class="flex items-center py-1">
<span class="flex flex-1 mr-4"> <span class="flex flex-1 mr-4">
{{ t(shortcut.label) }} {{ shortcut.label }}
</span> </span>
<kbd <kbd
v-for="(key, index) in shortcut.keys" v-for="(key, index) in shortcut.keys"
@@ -14,14 +14,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "@composables/i18n" import { ShortcutDef } from "~/helpers/shortcuts"
const t = useI18n()
defineProps<{ defineProps<{
shortcut: { shortcut: ShortcutDef
label: string
keys: string[]
}
}>() }>()
</script> </script>

View File

@@ -22,10 +22,11 @@
</div> </div>
<div class="flex"> <div class="flex">
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd> <kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
<kbd class="shortcut-key">K</kbd> <kbd class="shortcut-key">/</kbd>
</div> </div>
<div class="flex"> <div class="flex">
<kbd class="shortcut-key">/</kbd> <kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
<kbd class="shortcut-key">K</kbd>
</div> </div>
<div class="flex"> <div class="flex">
<kbd class="shortcut-key">?</kbd> <kbd class="shortcut-key">?</kbd>

View File

@@ -0,0 +1,122 @@
<template>
<button
ref="el"
class="flex items-center flex-1 px-6 py-4 font-medium space-x-4 transition cursor-pointer relative search-entry focus:outline-none"
:class="{ 'active bg-primaryLight text-secondaryDark': active }"
tabindex="-1"
@click="emit('action')"
@keydown.enter="emit('action')"
>
<component
:is="entry.icon"
class="opacity-50 svg-icons"
:class="{ 'opacity-100': active }"
/>
<template
v-if="entry.text.type === 'text' && typeof entry.text.text === 'string'"
>
<span class="block truncate">
{{ entry.text.text }}
</span>
</template>
<template
v-else-if="entry.text.type === 'text' && Array.isArray(entry.text.text)"
>
<template
v-for="(labelPart, labelPartIndex) in entry.text.text"
:key="`label-${labelPart}-${labelPartIndex}`"
>
<span class="block truncate">
{{ labelPart }}
</span>
<icon-lucide-chevron-right
v-if="labelPartIndex < entry.text.text.length - 1"
class="flex flex-shrink-0"
/>
</template>
</template>
<template v-else-if="entry.text.type === 'custom'">
<span class="block truncate">
<component
:is="entry.text.component"
v-bind="entry.text.componentProps"
/>
</span>
</template>
<span v-if="formattedShortcutKeys" class="block truncate">
<kbd
v-for="(key, keyIndex) in formattedShortcutKeys"
:key="`key-${String(keyIndex)}`"
class="shortcut-key"
>
{{ key }}
</kbd>
</span>
</button>
</template>
<script lang="ts">
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { SpotlightSearcherResult } from "~/services/spotlight"
const SPECIAL_KEY_CHARS: Record<string, string> = {
ctrl: getPlatformSpecialKey(),
alt: getPlatformAlternateKey(),
up: "↑",
down: "↓",
enter: "↩",
}
</script>
<script setup lang="ts">
import { computed, watch, ref } from "vue"
import { capitalize } from "lodash-es"
import { getPlatformAlternateKey } from "~/helpers/platformutils"
const el = ref<HTMLElement>()
const props = defineProps<{
entry: SpotlightSearcherResult
active: boolean
}>()
const formattedShortcutKeys = computed(() =>
props.entry.meta?.keyboardShortcut?.map((key) => {
return SPECIAL_KEY_CHARS[key] ?? capitalize(key)
})
)
const emit = defineEmits<{
(e: "action"): void
}>()
watch(
() => props.active,
(active) => {
if (active) {
el.value?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
})
}
}
)
</script>
<style lang="scss" scoped>
.search-entry {
@apply after:absolute;
@apply after:top-0;
@apply after:left-0;
@apply after:bottom-0;
@apply after:bg-transparent;
@apply after:z-2;
@apply after:w-0.5;
@apply after:content-DEFAULT;
&.active {
@apply after:bg-accentLight;
}
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<span class="flex flex-1 items-center space-x-2">
<span class="block truncate">
{{ dateTimeText }}
</span>
<icon-lucide-chevron-right class="flex flex-shrink-0" />
<span class="block truncate">
{{ historyEntry.request.url }}
</span>
<span
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
>
{{ historyEntry.request.query.split("\n")[0] }}
</span>
</span>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { shortDateTime } from "~/helpers/utils/date"
import { GQLHistoryEntry } from "~/newstore/history"
const props = defineProps<{
historyEntry: GQLHistoryEntry
}>()
const dateTimeText = computed(() =>
shortDateTime(props.historyEntry.updatedOn!)
)
</script>

View File

@@ -0,0 +1,43 @@
<template>
<span class="flex flex-1 items-center space-x-2">
<span class="block truncate">
{{ dateTimeText }}
</span>
<icon-lucide-chevron-right class="flex flex-shrink-0" />
<span
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
:class="entryStatus.className"
>
{{ historyEntry.request.method }}
</span>
<span class="block truncate">
{{ historyEntry.request.endpoint }}
</span>
</span>
</template>
<script setup lang="ts">
import { computed } from "vue"
import findStatusGroup from "~/helpers/findStatusGroup"
import { shortDateTime } from "~/helpers/utils/date"
import { RESTHistoryEntry } from "~/newstore/history"
const props = defineProps<{
historyEntry: RESTHistoryEntry
}>()
const dateTimeText = computed(() =>
shortDateTime(props.historyEntry.updatedOn!)
)
const entryStatus = computed(() => {
const foundStatusGroup = findStatusGroup(
props.historyEntry.responseMeta.statusCode
)
return (
foundStatusGroup || {
className: "",
}
)
})
</script>

View File

@@ -0,0 +1,238 @@
<template>
<HoppSmartModal
v-if="show"
styles="sm:max-w-lg"
full-width
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col border-b transition border-divider">
<div class="flex items-center">
<input
id="command"
v-model="search"
v-focus
type="text"
autocomplete="off"
name="command"
:placeholder="`${t('app.type_a_command_search')}`"
class="flex flex-1 text-base bg-transparent text-secondaryDark px-6 py-5"
/>
<HoppSmartSpinner v-if="searchSession?.loading" class="mr-6" />
</div>
</div>
<div
v-if="searchSession && search.length > 0"
class="flex flex-col flex-1 overflow-y-auto border-b border-divider divide-y divide-dividerLight"
>
<div
v-for="([sectionID, sectionResult], sectionIndex) in scoredResults"
:key="`section-${sectionID}`"
class="flex flex-col"
>
<h5
class="px-6 py-2 bg-primaryContrast z-10 text-secondaryLight sticky top-0"
>
{{ sectionResult.title }}
</h5>
<AppSpotlightEntry
v-for="(result, entryIndex) in sectionResult.results"
:key="`result-${result.id}`"
:entry="result"
:active="isEqual(selectedEntry, [sectionIndex, entryIndex])"
@mouseover="selectedEntry = [sectionIndex, entryIndex]"
@action="runAction(sectionID, result)"
/>
</div>
<HoppSmartPlaceholder
v-if="search.length > 0 && scoredResults.length === 0"
:text="`${t('state.nothing_found')} ‟${search}”`"
>
<template #icon>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
</template>
<HoppButtonSecondary
:label="t('action.clear')"
outline
@click="search = ''"
/>
</HoppSmartPlaceholder>
</div>
<div
class="flex flex-shrink-0 text-tiny text-secondaryLight p-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
>
<div class="flex items-center">
<kbd class="shortcut-key"></kbd>
<kbd class="shortcut-key"></kbd>
<span class="mx-2 truncate">
{{ t("action.to_navigate") }}
</span>
<kbd class="shortcut-key"></kbd>
<span class="ml-2 truncate">
{{ t("action.to_select") }}
</span>
</div>
<div class="flex items-center">
<kbd class="shortcut-key">ESC</kbd>
<span class="ml-2 truncate">
{{ t("action.to_close") }}
</span>
</div>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue"
import { useService } from "dioc/vue"
import { useI18n } from "@composables/i18n"
import {
SpotlightService,
SpotlightSearchState,
SpotlightSearcherResult,
} from "~/services/spotlight"
import { isEqual } from "lodash-es"
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
const t = useI18n()
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const spotlightService = useService(SpotlightService)
useService(HistorySpotlightSearcherService)
useService(UserSpotlightSearcherService)
const search = ref("")
const searchSession = ref<SpotlightSearchState>()
const stopSearchSession = ref<() => void>()
const scoredResults = computed(() =>
Object.entries(searchSession.value?.results ?? {}).sort(
([, sectionA], [, sectionB]) => sectionB.avgScore - sectionA.avgScore
)
)
const { selectedEntry } = newUseArrowKeysForNavigation()
watch(
() => props.show,
(show) => {
search.value = ""
if (show) {
const [session, onSessionEnd] =
spotlightService.createSearchSession(search)
searchSession.value = session.value
stopSearchSession.value = onSessionEnd
} else {
stopSearchSession.value?.()
stopSearchSession.value = undefined
searchSession.value = undefined
}
}
)
function runAction(searcherID: string, result: SpotlightSearcherResult) {
spotlightService.selectSearchResult(searcherID, result)
emit("hide-modal")
}
function newUseArrowKeysForNavigation() {
const selectedEntry = ref<[number, number]>([0, 0]) // [sectionIndex, entryIndex]
watch(search, () => {
selectedEntry.value = [0, 0]
})
const onArrowDown = () => {
// If no entries, do nothing
if (scoredResults.value.length === 0) return
const [sectionIndex, entryIndex] = selectedEntry.value
const [, section] = scoredResults.value[sectionIndex]
if (entryIndex < section.results.length - 1) {
selectedEntry.value = [sectionIndex, entryIndex + 1]
} else if (sectionIndex < scoredResults.value.length - 1) {
selectedEntry.value = [sectionIndex + 1, 0]
} else {
selectedEntry.value = [0, 0]
}
}
const onArrowUp = () => {
// If no entries, do nothing
if (scoredResults.value.length === 0) return
const [sectionIndex, entryIndex] = selectedEntry.value
if (entryIndex > 0) {
selectedEntry.value = [sectionIndex, entryIndex - 1]
} else if (sectionIndex > 0) {
const [, section] = scoredResults.value[sectionIndex - 1]
selectedEntry.value = [sectionIndex - 1, section.results.length - 1]
} else {
selectedEntry.value = [
scoredResults.value.length - 1,
scoredResults.value[scoredResults.value.length - 1][1].results.length -
1,
]
}
}
const onEnter = () => {
// If no entries, do nothing
if (scoredResults.value.length === 0) return
const [sectionIndex, entryIndex] = selectedEntry.value
const [sectionID, section] = scoredResults.value[sectionIndex]
const result = section.results[entryIndex]
runAction(sectionID, result)
}
function handleKeyPress(e: KeyboardEvent) {
if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
onArrowUp()
} else if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
onArrowDown()
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
onEnter()
}
}
watch(
() => props.show,
(show) => {
if (show) {
window.addEventListener("keydown", handleKeyPress)
} else {
window.removeEventListener("keydown", handleKeyPress)
}
}
)
return { selectedEntry }
}
</script>

View File

@@ -193,7 +193,7 @@ import IconTrash2 from "~icons/lucide/trash-2"
import IconEdit from "~icons/lucide/edit" import IconEdit from "~icons/lucide/edit"
import IconFolder from "~icons/lucide/folder" import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open" 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 { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { TippyComponent } from "vue-tippy" import { TippyComponent } from "vue-tippy"
@@ -209,67 +209,36 @@ type FolderType = "collection" | "folder"
const t = useI18n() const t = useI18n()
const props = defineProps({ const props = withDefaults(
id: { defineProps<{
type: String, id: string
default: "", parentID?: string | null
required: true, data: HoppCollection<HoppRESTRequest> | TeamCollection
}, /**
parentID: { * Collection component can be used for both collections and folders.
type: String as PropType<string | null>, * folderType is used to determine which one it is.
default: null, */
required: false, collectionsType: CollectionType
}, folderType: FolderType
data: { isOpen: boolean
type: Object as PropType<HoppCollection<HoppRESTRequest> | TeamCollection>, isSelected?: boolean | null
default: () => ({}), exportLoading?: boolean
required: true, hasNoTeamAccess?: boolean
}, collectionMoveLoading?: string[]
collectionsType: { isLastItem?: boolean
type: String as PropType<CollectionType>, }>(),
default: "my-collections", {
required: true, id: "",
}, parentID: null,
/** collectionsType: "my-collections",
* Collection component can be used for both collections and folders. folderType: "collection",
* folderType is used to determine which one it is. isOpen: false,
*/ isSelected: false,
folderType: { exportLoading: false,
type: String as PropType<FolderType>, hasNoTeamAccess: false,
default: "collection", isLastItem: false,
required: true, }
}, )
isOpen: {
type: Boolean,
default: false,
required: true,
},
isSelected: {
type: Boolean as PropType<boolean | null>,
default: false,
required: false,
},
exportLoading: {
type: Boolean,
default: false,
required: false,
},
hasNoTeamAccess: {
type: Boolean,
default: false,
required: false,
},
collectionMoveLoading: {
type: Array as PropType<string[]>,
default: () => [],
required: false,
},
isLastItem: {
type: Boolean,
default: false,
required: false,
},
})
const emit = defineEmits<{ const emit = defineEmits<{
(event: "toggle-children"): void (event: "toggle-children"): void
@@ -448,8 +417,13 @@ const notSameDestination = computed(() => {
}) })
const isCollLoading = computed(() => { const isCollLoading = computed(() => {
if (props.collectionMoveLoading.length > 0 && props.data.id) { const { collectionMoveLoading } = props
return props.collectionMoveLoading.includes(props.data.id) if (
collectionMoveLoading &&
collectionMoveLoading.length > 0 &&
props.data.id
) {
return collectionMoveLoading.includes(props.data.id)
} else { } else {
return false return false
} }

View File

@@ -243,49 +243,33 @@
/> />
</template> </template>
<template #emptyNode="{ node }"> <template #emptyNode="{ node }">
<div <HoppSmartPlaceholder
v-if="filterText.length !== 0 && filteredCollections.length === 0" v-if="filterText.length !== 0 && filteredCollections.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :text="`${t('state.nothing_found')}${filterText}`"
> >
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <template #icon>
<span class="my-2 text-center"> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
{{ t("state.nothing_found") }} "{{ filterText }}" </template>
</span> </HoppSmartPlaceholder>
</div> <HoppSmartPlaceholder
<div v-else-if="node === null"> v-else-if="node === null"
<div :src="`/images/states/${colorMode.value}/pack.svg`"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :alt="`${t('empty.collections')}`"
> :text="t('empty.collections')"
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collections')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
</div>
</div>
<div
v-else-if="node.data.type === 'collections'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
> >
<img <HoppButtonSecondary
:src="`/images/states/${colorMode.value}/pack.svg`" :label="t('add.new')"
loading="lazy" filled
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4" outline
:alt="`${t('empty.collection')}`" @click="emit('display-modal-add')"
/> />
<span class="pb-4 text-center"> </HoppSmartPlaceholder>
{{ t("empty.collection") }} <HoppSmartPlaceholder
</span> v-else-if="node.data.type === 'collections'"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('add.new')" :label="t('add.new')"
filled filled
@@ -298,21 +282,14 @@
}) })
" "
/> />
</div> </HoppSmartPlaceholder>
<div <HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'" v-else-if="node.data.type === 'folders'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`"
:text="t('empty.folder')"
> >
<img </HoppSmartPlaceholder>
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</template> </template>
</SmartTree> </SmartTree>
</div> </div>

View File

@@ -262,67 +262,53 @@
</template> </template>
<template #emptyNode="{ node }"> <template #emptyNode="{ node }">
<div v-if="node === null"> <div v-if="node === null">
<div <div @drop="(e) => e.stopPropagation()">
class="flex flex-col items-center justify-center p-4 text-secondaryLight" <HoppSmartPlaceholder
@drop="(e) => e.stopPropagation()"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`" :src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy" :alt="`${t('empty.collections')}`"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4" :text="t('empty.collections')"
:alt="`${t('empty.collection')}`" >
/> <HoppButtonSecondary
<span class="pb-4 text-center"> v-if="hasNoTeamAccess"
{{ t("empty.collections") }} v-tippy="{ theme: 'tooltip' }"
</span> disabled
<HoppButtonSecondary filled
v-if="hasNoTeamAccess" outline
v-tippy="{ theme: 'tooltip' }" :title="t('team.no_access')"
disabled :label="t('action.new')"
filled />
outline <HoppButtonSecondary
:title="t('team.no_access')" v-else
:label="t('action.new')" :icon="IconPlus"
/> :label="t('action.new')"
<HoppButtonSecondary filled
v-else outline
:icon="IconPlus" @click="emit('display-modal-add')"
:label="t('action.new')" />
filled </HoppSmartPlaceholder>
outline
@click="emit('display-modal-add')"
/>
</div> </div>
</div> </div>
<div <div
v-else-if="node.data.type === 'collections'" v-else-if="node.data.type === 'collections'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
@drop="(e) => e.stopPropagation()" @drop="(e) => e.stopPropagation()"
> >
<img <HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/pack.svg`" :src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy" :alt="`${t('empty.collections')}`"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4" :text="t('empty.collections')"
:alt="`${t('empty.collection')}`" >
/> </HoppSmartPlaceholder>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
</div> </div>
<div <div
v-else-if="node.data.type === 'folders'" v-else-if="node.data.type === 'folders'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
@drop="(e) => e.stopPropagation()" @drop="(e) => e.stopPropagation()"
> >
<img <HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/pack.svg`" :src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`" :alt="`${t('empty.folder')}`"
/> :text="t('empty.folder')"
<span class="text-center"> >
{{ t("empty.folder") }} </HoppSmartPlaceholder>
</span>
</div> </div>
</template> </template>
</SmartTree> </SmartTree>

View File

@@ -171,21 +171,14 @@
@duplicate-request="$emit('duplicate-request', $event)" @duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)" @select="$emit('select', $event)"
/> />
<div <HoppSmartPlaceholder
v-if=" v-if="
collection.folders.length === 0 && collection.requests.length === 0 collection.folders.length === 0 && collection.requests.length === 0
" "
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collection')}`"
:text="t('empty.collection')"
> >
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collection") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('add.new')" :label="t('add.new')"
filled filled
@@ -196,7 +189,7 @@
}) })
" "
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
<HoppSmartConfirmModal <HoppSmartConfirmModal

View File

@@ -160,25 +160,19 @@
@duplicate-request="emit('duplicate-request', $event)" @duplicate-request="emit('duplicate-request', $event)"
@select="emit('select', $event)" @select="emit('select', $event)"
/> />
<div
<HoppSmartPlaceholder
v-if=" v-if="
folder.folders && folder.folders &&
folder.folders.length === 0 && folder.folders.length === 0 &&
folder.requests && folder.requests &&
folder.requests.length === 0 folder.requests.length === 0
" "
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`"
:text="t('empty.folder')"
> >
<img </HoppSmartPlaceholder>
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</div> </div>
</div> </div>
<HoppSmartConfirmModal <HoppSmartConfirmModal

View File

@@ -60,35 +60,27 @@
@select="$emit('select', $event)" @select="$emit('select', $event)"
/> />
</div> </div>
<div <HoppSmartPlaceholder
v-if="collections.length === 0" v-if="collections.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
> >
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="t('empty.collections')"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('add.new')" :label="t('add.new')"
filled filled
outline outline
@click="displayModalAdd(true)" @click="displayModalAdd(true)"
/> />
</div> </HoppSmartPlaceholder>
<div <HoppSmartPlaceholder
v-if="!(filteredCollections.length !== 0 || collections.length === 0)" v-if="!(filteredCollections.length !== 0 || collections.length === 0)"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :text="`${t('state.nothing_found')}${filterText}`"
> >
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <template #icon>
<span class="my-2 text-center"> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
{{ t("state.nothing_found") }} "{{ filterText }}" </template>
</span> </HoppSmartPlaceholder>
</div>
<CollectionsGraphqlAdd <CollectionsGraphqlAdd
:show="showModalAdd" :show="showModalAdd"
@hide-modal="displayModalAdd(false)" @hide-modal="displayModalAdd(false)"

View File

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

View File

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

View File

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

View File

@@ -79,26 +79,19 @@
/> />
</div> </div>
</div> </div>
<div <HoppSmartPlaceholder
v-if="vars.length === 0" v-if="vars.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
> >
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.environments") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
filled filled
class="mb-4" class="mb-4"
@click="addEnvironmentVariable" @click="addEnvironmentVariable"
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -32,19 +32,12 @@
:environment="environment" :environment="environment"
@edit-environment="editEnvironment(index)" @edit-environment="editEnvironment(index)"
/> />
<div <HoppSmartPlaceholder
v-if="environments.length === 0" v-if="environments.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
> >
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.environments") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
filled filled
@@ -52,7 +45,7 @@
class="mb-4" class="mb-4"
@click="displayModalAdd(true)" @click="displayModalAdd(true)"
/> />
</div> </HoppSmartPlaceholder>
<EnvironmentsMyDetails <EnvironmentsMyDetails
:show="showModalDetails" :show="showModalDetails"
:action="action" :action="action"

View File

@@ -83,19 +83,12 @@
/> />
</div> </div>
</div> </div>
<div <HoppSmartPlaceholder
v-if="vars.length === 0" v-if="vars.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
> >
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.environments") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
v-if="isViewer" v-if="isViewer"
disabled disabled
@@ -110,7 +103,7 @@
class="mb-4" class="mb-4"
@click="addEnvironmentVariable" @click="addEnvironmentVariable"
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -43,19 +43,12 @@
/> />
</div> </div>
</div> </div>
<div <HoppSmartPlaceholder
v-if="!loading && teamEnvironments.length === 0 && !adapterError" v-if="!loading && teamEnvironments.length === 0 && !adapterError"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
> >
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.environments") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
v-if="team === undefined || team.myRole === 'VIEWER'" v-if="team === undefined || team.myRole === 'VIEWER'"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -74,7 +67,7 @@
class="mb-4" class="mb-4"
@click="displayModalAdd(true)" @click="displayModalAdd(true)"
/> />
</div> </HoppSmartPlaceholder>
<div v-else-if="!loading"> <div v-else-if="!loading">
<EnvironmentsTeamsEnvironment <EnvironmentsTeamsEnvironment
v-for="(environment, index) in JSON.parse( v-for="(environment, index) in JSON.parse(

View File

@@ -1,12 +1,12 @@
<template> <template>
<div class="flex" @click="OpenLogoutModal()"> <div class="flex" @click="openLogoutModal()">
<HoppSmartItem <HoppSmartItem
ref="logoutItem" ref="logoutItem"
:icon="IconLogOut" :icon="IconLogOut"
:label="`${t('auth.logout')}`" :label="`${t('auth.logout')}`"
:outline="outline" :outline="outline"
:shortcut="shortcut" :shortcut="shortcut"
@click="OpenLogoutModal()" @click="openLogoutModal()"
/> />
<HoppSmartConfirmModal <HoppSmartConfirmModal
:show="confirmLogout" :show="confirmLogout"
@@ -23,6 +23,7 @@ import IconLogOut from "~icons/lucide/log-out"
import { useToast } from "@composables/toast" import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { platform } from "~/platform" import { platform } from "~/platform"
import { defineActionHandler } from "~/helpers/actions"
defineProps({ defineProps({
outline: { outline: {
@@ -55,8 +56,12 @@ const logout = async () => {
} }
} }
const OpenLogoutModal = () => { const openLogoutModal = () => {
emit("confirm-logout") emit("confirm-logout")
confirmLogout.value = true confirmLogout.value = true
} }
defineActionHandler("user.logout", () => {
openLogoutModal()
})
</script> </script>

View File

@@ -114,19 +114,12 @@
/> />
</div> </div>
</div> </div>
<div <HoppSmartPlaceholder
v-if="authType === 'none'" v-if="authType === 'none'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/login.svg`"
:alt="`${t('empty.authorization')}`"
:text="t('empty.authorization')"
> >
<img
:src="`/images/states/${colorMode.value}/login.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.authorization')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.authorization") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
outline outline
:label="t('app.documentation')" :label="t('app.documentation')"
@@ -136,7 +129,7 @@
reverse reverse
class="mb-4" class="mb-4"
/> />
</div> </HoppSmartPlaceholder>
<div v-else class="flex flex-1 border-b border-dividerLight"> <div v-else class="flex flex-1 border-b border-dividerLight">
<div class="w-2/3 border-r border-dividerLight"> <div class="w-2/3 border-r border-dividerLight">
<div v-if="authType === 'basic'"> <div v-if="authType === 'basic'">

View File

@@ -289,19 +289,12 @@
</div> </div>
</template> </template>
</draggable> </draggable>
<div <HoppSmartPlaceholder
v-if="workingHeaders.length === 0" v-if="workingHeaders.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_category.svg`"
:alt="`${t('empty.headers')}`"
:text="t('empty.headers')"
> >
<img
:src="`/images/states/${colorMode.value}/add_category.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.headers')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.headers") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
filled filled
@@ -309,7 +302,7 @@
class="mb-4" class="mb-4"
@click="addHeader" @click="addHeader"
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab :id="'authorization'" :label="`${t('tab.authorization')}`"> <HoppSmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">

View File

@@ -1,12 +1,13 @@
<template> <template>
<div class="flex flex-col flex-1 overflow-auto whitespace-nowrap"> <div class="flex flex-col flex-1 overflow-auto whitespace-nowrap">
<div <HoppSmartPlaceholder
v-if="responseString === 'loading'" v-if="responseString === 'loading'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :text="t('state.loading')"
> >
<HoppSmartSpinner class="my-4" /> <template #icon>
<span class="text-secondaryLight">{{ t("state.loading") }}</span> <HoppSmartSpinner class="my-4" />
</div> </template>
</HoppSmartPlaceholder>
<div v-else-if="responseString" class="flex flex-col flex-1"> <div v-else-if="responseString" class="flex flex-col flex-1">
<div <div
class="sticky top-0 z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight" class="sticky top-0 z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight"

View File

@@ -24,25 +24,18 @@
:icon="IconBookOpen" :icon="IconBookOpen"
:label="`${t('tab.documentation')}`" :label="`${t('tab.documentation')}`"
> >
<div <HoppSmartPlaceholder
v-if=" v-if="
queryFields.length === 0 && queryFields.length === 0 &&
mutationFields.length === 0 && mutationFields.length === 0 &&
subscriptionFields.length === 0 && subscriptionFields.length === 0 &&
graphqlTypes.length === 0 graphqlTypes.length === 0
" "
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_comment.svg`"
:alt="`${t('empty.documentation')}`"
:text="t('empty.documentation')"
> >
<img </HoppSmartPlaceholder>
:src="`/images/states/${colorMode.value}/add_comment.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.documentation')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.documentation") }}
</span>
</div>
<div v-else> <div v-else>
<div <div
class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto bg-primary" class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto bg-primary"
@@ -172,20 +165,13 @@
ref="schemaEditor" ref="schemaEditor"
class="flex flex-col flex-1" class="flex flex-col flex-1"
></div> ></div>
<div <HoppSmartPlaceholder
v-else v-else
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.schema')}`"
:text="t('empty.schema')"
> >
<img </HoppSmartPlaceholder>
:src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.schema')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.schema") }}
</span>
</div>
</HoppSmartTab> </HoppSmartTab>
</HoppSmartTabs> </HoppSmartTabs>
</template> </template>

View File

@@ -108,31 +108,23 @@
/> />
</details> </details>
</div> </div>
<div <HoppSmartPlaceholder
v-if="history.length === 0" v-if="history.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/history.svg`"
:alt="`${t('empty.history')}`"
:text="t('empty.history')"
> >
<img </HoppSmartPlaceholder>
:src="`/images/states/${colorMode.value}/history.svg`" <HoppSmartPlaceholder
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.history')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.history") }}
</span>
</div>
<div
v-else-if=" v-else-if="
Object.keys(filteredHistoryGroups).length === 0 || Object.keys(filteredHistoryGroups).length === 0 ||
filteredHistory.length === 0 filteredHistory.length === 0
" "
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :text="`${t('state.nothing_found')} ‟${filterText || filterSelection}”`"
> >
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <template #icon>
<span class="mt-2 mb-4 text-center"> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
{{ t("state.nothing_found") }} "{{ filterText || filterSelection }}" </template>
</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('action.clear')" :label="t('action.clear')"
outline outline
@@ -143,7 +135,7 @@
} }
" "
/> />
</div> </HoppSmartPlaceholder>
<HoppSmartConfirmModal <HoppSmartConfirmModal
:show="confirmRemove" :show="confirmRemove"
:title="`${t('confirm.remove_history')}`" :title="`${t('confirm.remove_history')}`"
@@ -184,6 +176,7 @@ import {
import HistoryRestCard from "./rest/Card.vue" import HistoryRestCard from "./rest/Card.vue"
import HistoryGraphqlCard from "./graphql/Card.vue" import HistoryGraphqlCard from "./graphql/Card.vue"
import { createNewTab } from "~/helpers/rest/tab" import { createNewTab } from "~/helpers/rest/tab"
import { defineActionHandler } from "~/helpers/actions"
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
@@ -337,4 +330,8 @@ const toggleStar = (entry: HistoryEntry) => {
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry) toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry) else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
} }
defineActionHandler("history.clear", () => {
confirmRemove.value = true
})
</script> </script>

View File

@@ -113,17 +113,12 @@
/> />
</div> </div>
</div> </div>
<div <HoppSmartPlaceholder
v-if="auth.authType === 'none'" v-if="auth.authType === 'none'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/login.svg`"
:alt="`${t('empty.authorization')}`"
:text="t('empty.authorization')"
> >
<img
:src="`/images/states/${colorMode.value}/login.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.authorization')}`"
/>
<span class="pb-4 text-center">{{ t("empty.authorization") }}</span>
<HoppButtonSecondary <HoppButtonSecondary
outline outline
:label="t('app.documentation')" :label="t('app.documentation')"
@@ -133,7 +128,7 @@
reverse reverse
class="mb-4" class="mb-4"
/> />
</div> </HoppSmartPlaceholder>
<div v-else class="flex flex-1 border-b border-dividerLight"> <div v-else class="flex flex-1 border-b border-dividerLight">
<div class="w-2/3 border-r border-dividerLight"> <div class="w-2/3 border-r border-dividerLight">
<div v-if="auth.authType === 'basic'"> <div v-if="auth.authType === 'basic'">

View File

@@ -102,17 +102,12 @@
v-model="body" v-model="body"
/> />
<HttpRawBody v-else-if="body.contentType !== null" v-model="body" /> <HttpRawBody v-else-if="body.contentType !== null" v-model="body" />
<div <HoppSmartPlaceholder
v-if="body.contentType == null" v-if="body.contentType == null"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/upload_single_file.svg`"
:alt="`${t('empty.body')}`"
:text="t('empty.body')"
> >
<img
:src="`/images/states/${colorMode.value}/upload_single_file.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.body')}`"
/>
<span class="pb-4 text-center">{{ t("empty.body") }}</span>
<HoppButtonSecondary <HoppButtonSecondary
outline outline
:label="`${t('app.documentation')}`" :label="`${t('app.documentation')}`"
@@ -122,7 +117,7 @@
reverse reverse
class="mb-4" class="mb-4"
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
</template> </template>

View File

@@ -152,17 +152,12 @@
</template> </template>
</draggable> </draggable>
<div <HoppSmartPlaceholder
v-if="workingParams.length === 0" v-if="workingParams.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/upload_single_file.svg`"
:alt="`${t('empty.body')}`"
:text="t('empty.body')"
> >
<img
:src="`/images/states/${colorMode.value}/upload_single_file.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.body')}`"
/>
<span class="pb-4 text-center">{{ t("empty.body") }}</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
filled filled
@@ -170,7 +165,7 @@
class="mb-4" class="mb-4"
@click="addBodyParam" @click="addBodyParam"
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
</template> </template>

View File

@@ -56,20 +56,19 @@
} }
" "
/> />
<div <HoppSmartPlaceholder
v-if=" v-if="
!( !(
filteredCodegenDefinitions.length !== 0 || filteredCodegenDefinitions.length !== 0 ||
CodegenDefinitions.length === 0 CodegenDefinitions.length === 0
) )
" "
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :text="`${t('state.nothing_found')}${searchQuery}`"
> >
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <template #icon>
<span class="my-2 text-center"> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
{{ t("state.nothing_found") }} "{{ searchQuery }}" </template>
</span> </HoppSmartPlaceholder>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -202,17 +202,12 @@
</div> </div>
</template> </template>
</draggable> </draggable>
<div <HoppSmartPlaceholder
v-if="workingHeaders.length === 0" v-if="workingHeaders.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_category.svg`"
:alt="`${t('empty.headers')}`"
:text="t('empty.headers')"
> >
<img
:src="`/images/states/${colorMode.value}/add_category.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.headers')}`"
/>
<span class="pb-4 text-center">{{ t("empty.headers") }}</span>
<HoppButtonSecondary <HoppButtonSecondary
filled filled
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
@@ -220,7 +215,7 @@
class="mb-4" class="mb-4"
@click="addHeader" @click="addHeader"
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -145,18 +145,12 @@
</div> </div>
</template> </template>
</draggable> </draggable>
<HoppSmartPlaceholder
<div
v-if="workingParams.length === 0" v-if="workingParams.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_files.svg`"
:alt="`${t('empty.parameters')}`"
:text="t('empty.parameters')"
> >
<img
:src="`/images/states/${colorMode.value}/add_files.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.parameters')}`"
/>
<span class="pb-4 text-center">{{ t("empty.parameters") }}</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
:icon="IconPlus" :icon="IconPlus"
@@ -164,7 +158,7 @@
class="mb-4" class="mb-4"
@click="addParam" @click="addParam"
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 overflow-x-auto sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary" class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary"
> >
<div <div
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap" class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
@@ -47,13 +47,14 @@
</label> </label>
</div> </div>
<div <div
class="flex flex-1 overflow-auto transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap" class="flex flex-1 transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
> >
<SmartEnvInput <SmartEnvInput
v-model="tab.document.request.endpoint" v-model="tab.document.request.endpoint"
:placeholder="`${t('request.url')}`" :placeholder="`${t('request.url')}`"
@enter="newSendRequest()" :auto-complete-source="userHistories"
@paste="onPasteUrl($event)" @paste="onPasteUrl($event)"
@enter="newSendRequest"
/> />
</div> </div>
</div> </div>
@@ -228,7 +229,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useSetting } from "@composables/settings" import { useSetting } from "@composables/settings"
import { useStreamSubscriber } from "@composables/stream" import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
import { useToast } from "@composables/toast" import { useToast } from "@composables/toast"
import { refAutoReset, useVModel } from "@vueuse/core" import { refAutoReset, useVModel } from "@vueuse/core"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
@@ -259,6 +260,7 @@ import IconSave from "~icons/lucide/save"
import IconShare2 from "~icons/lucide/share-2" import IconShare2 from "~icons/lucide/share-2"
import { HoppRESTTab } from "~/helpers/rest/tab" import { HoppRESTTab } from "~/helpers/rest/tab"
import { getDefaultRESTRequest } from "~/helpers/rest/default" import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
import { platform } from "~/platform" import { platform } from "~/platform"
import { getCurrentStrategyID } from "~/helpers/network" import { getCurrentStrategyID } from "~/helpers/network"
@@ -313,6 +315,12 @@ const clearAll = ref<any | null>(null)
const copyRequestAction = ref<any | null>(null) const copyRequestAction = ref<any | null>(null)
const saveRequestAction = ref<any | null>(null) const saveRequestAction = ref<any | null>(null)
const history = useReadonlyStream<RESTHistoryEntry[]>(restHistory$, [])
const userHistories = computed(() => {
return history.value.map((history) => history.request.endpoint).slice(0, 10)
})
const newSendRequest = async () => { const newSendRequest = async () => {
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) { if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
toast.error(`${t("empty.endpoint")}`) toast.error(`${t("empty.endpoint")}`)

View File

@@ -11,51 +11,29 @@
<HoppSmartSpinner class="my-4" /> <HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span> <span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div> </div>
<div <HoppSmartPlaceholder
v-if="response.type === 'network_fail'" v-if="response.type === 'network_fail'"
class="flex flex-col items-center justify-center flex-1 p-4" :src="`/images/states/${colorMode.value}/youre_lost.svg`"
:alt="`${t('error.network_fail')}`"
:heading="t('error.network_fail')"
:text="t('helpers.network_fail')"
> >
<img
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-32 h-32 my-4"
:alt="`${t('error.network_fail')}`"
/>
<span class="mb-2 font-semibold text-center">
{{ t("error.network_fail") }}
</span>
<span
class="max-w-sm mb-6 text-center whitespace-normal text-secondaryLight"
>
{{ t("helpers.network_fail") }}
</span>
<AppInterceptor class="p-2 border rounded border-dividerLight" /> <AppInterceptor class="p-2 border rounded border-dividerLight" />
</div> </HoppSmartPlaceholder>
<div <HoppSmartPlaceholder
v-if="response.type === 'script_fail'" v-if="response.type === 'script_fail'"
class="flex flex-col items-center justify-center flex-1 p-4" :src="`/images/states/${colorMode.value}/youre_lost.svg`"
:alt="`${t('error.script_fail')}`"
:label="t('error.script_fail')"
:text="t('helpers.script_fail')"
> >
<img
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-32 h-32 my-4"
:alt="`${t('error.script_fail')}`"
/>
<span class="mb-2 font-semibold text-center">
{{ t("error.script_fail") }}
</span>
<span
class="max-w-sm mb-6 text-center whitespace-normal text-secondaryLight"
>
{{ t("helpers.script_fail") }}
</span>
<div <div
class="w-full px-4 py-2 overflow-auto font-mono text-red-400 whitespace-normal rounded bg-primaryLight" class="mt-2 w-full px-4 py-2 overflow-auto font-mono text-red-400 whitespace-normal rounded bg-primaryLight"
> >
{{ response.error.name }}: {{ response.error.message }}<br /> {{ response.error.name }}: {{ response.error.message }}<br />
{{ response.error.stack }} {{ response.error.stack }}
</div> </div>
</div> </HoppSmartPlaceholder>
<div <div
v-if="response.type === 'success' || response.type === 'fail'" v-if="response.type === 'success' || response.type === 'fail'"
class="flex items-center font-semibold text-tiny" class="flex items-center font-semibold text-tiny"

View File

@@ -153,41 +153,21 @@
</div> </div>
</div> </div>
</div> </div>
<div <HoppSmartPlaceholder
v-else-if="testResults && testResults.scriptError" v-else-if="testResults && testResults.scriptError"
class="flex flex-col items-center justify-center flex-1 p-4" :src="`/images/states/${colorMode.value}/youre_lost.svg`"
:alt="`${t('error.test_script_fail')}`"
:heading="t('error.test_script_fail')"
:text="t('helpers.test_script_fail')"
> >
<img </HoppSmartPlaceholder>
:src="`/images/states/${colorMode.value}/youre_lost.svg`" <HoppSmartPlaceholder
loading="lazy"
class="inline-flex flex-col object-contain object-center w-32 h-32 my-4"
:alt="`${t('error.test_script_fail')}`"
/>
<span class="mb-2 font-semibold text-center">
{{ t("error.test_script_fail") }}
</span>
<span
class="max-w-sm mb-6 text-center whitespace-normal text-secondaryLight"
>
{{ t("helpers.test_script_fail") }}
</span>
</div>
<div
v-else v-else
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/validation.svg`"
:alt="`${t('empty.tests')}`"
:heading="t('empty.tests')"
:text="t('helpers.tests')"
> >
<img
:src="`/images/states/${colorMode.value}/validation.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.tests')}`"
/>
<span class="pb-2 text-center">
{{ t("empty.tests") }}
</span>
<span class="pb-4 text-center">
{{ t("helpers.tests") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
outline outline
:label="`${t('action.learn_more')}`" :label="`${t('action.learn_more')}`"
@@ -197,7 +177,7 @@
reverse reverse
class="my-4" class="my-4"
/> />
</div> </HoppSmartPlaceholder>
<EnvironmentsMyDetails <EnvironmentsMyDetails
:show="showMyEnvironmentDetailsModal" :show="showMyEnvironmentDetailsModal"
action="new" action="new"

View File

@@ -143,19 +143,12 @@
</div> </div>
</template> </template>
</draggable> </draggable>
<div <HoppSmartPlaceholder
v-if="workingUrlEncodedParams.length === 0" v-if="workingUrlEncodedParams.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_category.svg`"
:alt="`${t('empty.body')}`"
:text="t('empty.body')"
> >
<img
:src="`/images/states/${colorMode.value}/add_category.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.body')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.body") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
filled filled
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
@@ -163,7 +156,7 @@
class="mb-4" class="mb-4"
@click="addUrlEncodedParam" @click="addUrlEncodedParam"
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -11,20 +11,13 @@
<HoppSmartSpinner class="mb-4" /> <HoppSmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span> <span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div> </div>
<div <HoppSmartPlaceholder
v-if="!loading && myShortcodes.length === 0" v-if="!loading && myShortcodes.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_files.svg`"
:alt="`${t('empty.shortcodes')}`"
:text="t('empty.shortcodes')"
> >
<img </HoppSmartPlaceholder>
:src="`/images/states/${colorMode.value}/add_files.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-8"
:alt="`${t('empty.shortcodes')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.shortcodes") }}
</span>
</div>
<div v-else-if="!loading"> <div v-else-if="!loading">
<div <div
class="hidden w-full border-t rounded-t bg-primaryLight lg:flex border-x border-dividerLight" class="hidden w-full border-t rounded-t bg-primaryLight lg:flex border-x border-dividerLight"

View File

@@ -52,20 +52,19 @@
" "
/> />
</HoppSmartLink> </HoppSmartLink>
<div <HoppSmartPlaceholder
v-if=" v-if="
!( !(
filteredAppLanguages.length !== 0 || filteredAppLanguages.length !== 0 ||
APP_LANGUAGES.length === 0 APP_LANGUAGES.length === 0
) )
" "
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :text="`${t('state.nothing_found')}${searchQuery}`"
> >
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <template #icon>
<span class="my-2 text-center"> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
{{ t("state.nothing_found") }} "{{ searchQuery }}" </template>
</span> </HoppSmartPlaceholder>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,19 +1,44 @@
<template> <template>
<div <div class="autocomplete-wrapper">
class="relative flex items-center flex-1 flex-shrink-0 py-4 overflow-auto whitespace-nowrap" <div class="absolute inset-0 flex flex-1 overflow-x-auto">
>
<div class="absolute inset-0 flex flex-1">
<div <div
ref="editor" ref="editor"
:placeholder="placeholder" :placeholder="placeholder"
class="flex flex-1" class="flex flex-1"
:class="styles" :class="styles"
@keydown.enter.prevent="emit('enter', $event)"
@keyup="emit('keyup', $event)"
@click="emit('click', $event)" @click="emit('click', $event)"
@keydown="emit('keydown', $event)" @keydown="handleKeystroke"
@focusin="showSuggestionPopover = true"
></div> ></div>
</div> </div>
<ul
v-if="showSuggestionPopover && autoCompleteSource"
ref="suggestionsMenu"
class="suggestions"
>
<li
v-for="(suggestion, index) in suggestions"
:key="`suggestion-${index}`"
:class="{ active: currentSuggestionIndex === index }"
@click="updateModelValue(suggestion)"
>
<span class="truncate py-0.5">
{{ suggestion }}
</span>
<div
v-if="currentSuggestionIndex === index"
class="hidden md:flex text-secondary items-center"
>
<kbd class="shortcut-key">TAB</kbd>
<span class="ml-2 truncate">to select</span>
</div>
</li>
<li v-if="suggestions.length === 0" class="pointer-events-none">
<span class="truncate py-0.5">
{{ t("empty.history_suggestions") }}
</span>
</li>
</ul>
</div> </div>
</template> </template>
@@ -35,6 +60,9 @@ import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironme
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments" import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
import { platform } from "~/platform" import { platform } from "~/platform"
import { useI18n } from "~/composables/i18n"
import { onClickOutside, useDebounceFn } from "@vueuse/core"
import { invokeAction } from "~/helpers/actions"
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -46,6 +74,7 @@ const props = withDefaults(
selectTextOnMount?: boolean selectTextOnMount?: boolean
environmentHighlights?: boolean environmentHighlights?: boolean
readonly?: boolean readonly?: boolean
autoCompleteSource?: string[]
}>(), }>(),
{ {
modelValue: "", modelValue: "",
@@ -55,6 +84,7 @@ const props = withDefaults(
focus: false, focus: false,
readonly: false, readonly: false,
environmentHighlights: true, environmentHighlights: true,
autoCompleteSource: undefined,
} }
) )
@@ -68,12 +98,165 @@ const emit = defineEmits<{
(e: "click", ev: any): void (e: "click", ev: any): void
}>() }>()
const t = useI18n()
const cachedValue = ref(props.modelValue) const cachedValue = ref(props.modelValue)
const view = ref<EditorView>() const view = ref<EditorView>()
const editor = ref<any | null>(null) const editor = ref<any | null>(null)
const currentSuggestionIndex = ref(-1)
const showSuggestionPopover = ref(false)
const suggestionsMenu = ref<any | null>(null)
onClickOutside(suggestionsMenu, () => {
showSuggestionPopover.value = false
})
//filter autocompleteSource with unique values
const uniqueAutoCompleteSource = computed(() => {
if (props.autoCompleteSource) {
return [...new Set(props.autoCompleteSource)]
} else {
return []
}
})
const suggestions = computed(() => {
if (
props.modelValue &&
props.modelValue.length > 0 &&
uniqueAutoCompleteSource.value &&
uniqueAutoCompleteSource.value.length > 0
) {
return uniqueAutoCompleteSource.value.filter((suggestion) =>
suggestion.toLowerCase().includes(props.modelValue.toLowerCase())
)
} else {
return uniqueAutoCompleteSource.value ?? []
}
})
const updateModelValue = (value: string) => {
emit("update:modelValue", value)
emit("change", value)
showSuggestionPopover.value = false
}
const handleKeystroke = (ev: KeyboardEvent) => {
if (["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(ev.key)) {
ev.preventDefault()
}
if (ev.shiftKey) {
showSuggestionPopover.value = false
return
}
showSuggestionPopover.value = true
if (
["Enter", "Tab"].includes(ev.key) &&
suggestions.value.length > 0 &&
currentSuggestionIndex.value > -1
) {
updateModelValue(suggestions.value[currentSuggestionIndex.value])
currentSuggestionIndex.value = -1
//used to set codemirror cursor at the end of the line after selecting a suggestion
nextTick(() => {
view.value?.dispatch({
selection: EditorSelection.create([
EditorSelection.range(
props.modelValue.length,
props.modelValue.length
),
]),
})
})
}
if (ev.key === "ArrowDown") {
scrollActiveElIntoView()
currentSuggestionIndex.value =
currentSuggestionIndex.value < suggestions.value.length - 1
? currentSuggestionIndex.value + 1
: suggestions.value.length - 1
emit("keydown", ev)
}
if (ev.key === "ArrowUp") {
scrollActiveElIntoView()
currentSuggestionIndex.value =
currentSuggestionIndex.value - 1 >= 0
? currentSuggestionIndex.value - 1
: 0
emit("keyup", ev)
}
if (ev.key === "Enter") {
emit("enter", ev)
showSuggestionPopover.value = false
}
if (ev.key === "Escape") {
showSuggestionPopover.value = false
}
// used to scroll to the first suggestion when left arrow is pressed
if (ev.key === "ArrowLeft") {
if (suggestions.value.length > 0) {
currentSuggestionIndex.value = 0
nextTick(() => {
scrollActiveElIntoView()
})
}
}
// used to scroll to the last suggestion when right arrow is pressed
if (ev.key === "ArrowRight") {
if (suggestions.value.length > 0) {
currentSuggestionIndex.value = suggestions.value.length - 1
nextTick(() => {
scrollActiveElIntoView()
})
}
}
}
// reset currentSuggestionIndex showSuggestionPopover is false
watch(
() => showSuggestionPopover.value,
(newVal) => {
if (!newVal) {
currentSuggestionIndex.value = -1
}
}
)
/**
* Used to scroll the active suggestion into view
*/
const scrollActiveElIntoView = () => {
const suggestionsMenuEl = suggestionsMenu.value
if (suggestionsMenuEl) {
const activeSuggestionEl = suggestionsMenuEl.querySelector(".active")
if (activeSuggestionEl) {
activeSuggestionEl.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "start",
})
}
}
}
watch( watch(
() => props.modelValue, () => props.modelValue,
(newVal) => { (newVal) => {
@@ -122,8 +305,46 @@ const envVars = computed(() =>
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view) const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
const initView = (el: any) => { const initView = (el: any) => {
function handleTextSelection() {
const selection = view.value?.state.selection.main
if (selection) {
const from = selection.from
const to = selection.to
const text = view.value?.state.doc.sliceString(from, to)
const { top, left } = view.value?.coordsAtPos(from)
if (text) {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text,
})
showSuggestionPopover.value = false
} else {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text: null,
})
}
}
}
// Debounce to prevent double click from selecting the word
const debounceFn = useDebounceFn(() => {
handleTextSelection()
}, 140)
el.addEventListener("mouseup", debounceFn)
el.addEventListener("keyup", debounceFn)
const extensions: Extension = [ const extensions: Extension = [
EditorView.lineWrapping,
EditorView.contentAttributes.of({ "aria-label": props.placeholder }), EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (props.readonly) { if (props.readonly) {
update.view.contentDOM.inputMode = "none" update.view.contentDOM.inputMode = "none"
@@ -236,3 +457,49 @@ watch(editor, () => {
} }
}) })
</script> </script>
<style lang="scss" scoped>
.autocomplete-wrapper {
@apply relative;
@apply flex;
@apply flex-1;
@apply flex-shrink-0;
@apply whitespace-nowrap;
.suggestions {
@apply absolute;
@apply bg-popover;
@apply z-50;
@apply shadow-lg;
@apply max-h-46;
@apply border-b border-x border-divider;
@apply overflow-y-auto;
@apply -left-[1px];
@apply -right-[1px];
top: calc(100% + 1px);
border-radius: 0 0 8px 8px;
li {
@apply flex;
@apply items-center;
@apply justify-between;
@apply w-full;
@apply py-2 px-4;
@apply text-secondary;
@apply cursor-pointer;
&:last-child {
border-radius: 0 0 0 8px;
}
&:hover,
&.active {
@apply bg-primaryDark;
@apply text-secondaryDark;
@apply cursor-pointer;
}
}
}
}
</style>

View File

@@ -10,6 +10,7 @@
class="flex flex-col flex-1" class="flex flex-col flex-1"
> >
<SmartTreeBranch <SmartTreeBranch
:root-nodes-length="rootNodes.data.length"
:node-item="rootNode" :node-item="rootNode"
:adapter="adapter as SmartTreeAdapter<T>" :adapter="adapter as SmartTreeAdapter<T>"
> >

View File

@@ -85,19 +85,25 @@ const props = defineProps<{
* The node item that will be used to render the tree branch content * The node item that will be used to render the tree branch content
*/ */
nodeItem: TreeNode<T> nodeItem: TreeNode<T>
/**
* Total number of rootNode
*/
rootNodesLength?: number
}>() }>()
const CHILD_SLOT_NAME = "default" const CHILD_SLOT_NAME = "default"
const t = useI18n() const t = useI18n()
const isOnlyRootChild = computed(() => props.rootNodesLength === 1)
/** /**
* Marks whether the children on this branch were ever rendered * Marks whether the children on this branch were ever rendered
* See the usage inside '<template>' for more info * See the usage inside '<template>' for more info
*/ */
const childrenRendered = ref(false) const childrenRendered = ref(isOnlyRootChild.value)
const showChildren = ref(false) const showChildren = ref(isOnlyRootChild.value)
const isNodeOpen = ref(false) const isNodeOpen = ref(isOnlyRootChild.value)
const highlightNode = ref(false) const highlightNode = ref(false)

View File

@@ -47,19 +47,12 @@
" "
class="border rounded border-divider" class="border rounded border-divider"
> >
<div <HoppSmartPlaceholder
v-if="teamDetails.data.right.team.teamMembers === 0" v-if="teamDetails.data.right.team.teamMembers === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_group.svg`"
:alt="`${t('empty.members')}`"
:text="t('empty.members')"
> >
<img
:src="`/images/states/${colorMode.value}/add_group.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.members')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.members") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
:icon="IconUserPlus" :icon="IconUserPlus"
:label="t('team.invite')" :label="t('team.invite')"
@@ -69,7 +62,7 @@
} }
" "
/> />
</div> </HoppSmartPlaceholder>
<div v-else class="divide-y divide-dividerLight"> <div v-else class="divide-y divide-dividerLight">
<div <div
v-for="(member, index) in membersList" v-for="(member, index) in membersList"

View File

@@ -98,17 +98,14 @@
</div> </div>
</div> </div>
</div> </div>
<div <HoppSmartPlaceholder
v-if=" v-if="
E.isRight(pendingInvites.data) && E.isRight(pendingInvites.data) &&
pendingInvites.data.right.team.teamInvitations.length === 0 pendingInvites.data.right.team.teamInvitations.length === 0
" "
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :text="t('empty.pending_invites')"
> >
<span class="text-center"> </HoppSmartPlaceholder>
{{ t("empty.pending_invites") }}
</span>
</div>
<div <div
v-if="!pendingInvites.loading && E.isLeft(pendingInvites.data)" v-if="!pendingInvites.loading && E.isLeft(pendingInvites.data)"
class="flex flex-col items-center p-4" class="flex flex-col items-center p-4"
@@ -221,25 +218,18 @@
/> />
</div> </div>
</div> </div>
<div <HoppSmartPlaceholder
v-if="newInvites.length === 0" v-if="newInvites.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_group.svg`"
:alt="`${t('empty.invites')}`"
:text="`${t('empty.invites')}`"
> >
<img
:src="`/images/states/${colorMode.value}/add_group.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.invites')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.invites") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('add.new')" :label="t('add.new')"
filled filled
@click="addNewInvitee" @click="addNewInvitee"
/> />
</div> </HoppSmartPlaceholder>
</div> </div>
<div <div
v-if="newInvites.length" v-if="newInvites.length"

View File

@@ -10,25 +10,18 @@
<HoppSmartSpinner class="mb-4" /> <HoppSmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span> <span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div> </div>
<div <HoppSmartPlaceholder
v-if="!loading && myTeams.length === 0" v-if="!loading && myTeams.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_group.svg`"
:alt="`${t('empty.teams')}`"
:text="`${t('empty.teams')}`"
> >
<img
:src="`/images/states/${colorMode.value}/add_group.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-8"
:alt="`${t('empty.teams')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.teams") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('team.create_new')}`" :label="`${t('team.create_new')}`"
filled filled
@click="displayModalAdd(true)" @click="displayModalAdd(true)"
/> />
</div> </HoppSmartPlaceholder>
<div <div
v-else-if="!loading" v-else-if="!loading"
class="grid gap-4" class="grid gap-4"

View File

@@ -15,19 +15,12 @@
<HoppSmartSpinner class="mb-4" /> <HoppSmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span> <span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div> </div>
<div <HoppSmartPlaceholder
v-if="!loading && myTeams.length === 0" v-if="!loading && myTeams.length === 0"
class="flex flex-col items-center justify-center flex-1 p-4 text-secondaryLight" :src="`/images/states/${colorMode.value}/add_group.svg`"
:alt="`${t('empty.teams')}`"
:text="`${t('empty.teams')}`"
> >
<img
:src="`/images/states/${colorMode.value}/add_group.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-8"
:alt="`${t('empty.teams')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.teams") }}
</span>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('team.create_new')" :label="t('team.create_new')"
filled filled
@@ -35,7 +28,7 @@
:icon="IconPlus" :icon="IconPlus"
@click="displayModalAdd(true)" @click="displayModalAdd(true)"
/> />
</div> </HoppSmartPlaceholder>
<div v-else-if="!loading" class="flex flex-col"> <div v-else-if="!loading" class="flex flex-col">
<div <div
class="sticky top-0 z-10 flex items-center justify-between py-2 pl-2 mb-2 -top-2 bg-popover" class="sticky top-0 z-10 flex items-center justify-between py-2 pl-2 mb-2 -top-2 bg-popover"

View File

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

View File

@@ -1,3 +1,4 @@
import { describe, expect, test } from "vitest"
import { getEditorLangForMimeType } from "../editorutils" import { getEditorLangForMimeType } from "../editorutils"
describe("getEditorLangForMimeType", () => { describe("getEditorLangForMimeType", () => {

View File

@@ -1,3 +1,4 @@
import { describe, test, expect } from "vitest"
import jsonParse from "../jsonParse" import jsonParse from "../jsonParse"
describe("jsonParse", () => { describe("jsonParse", () => {

View File

@@ -1,10 +1,11 @@
import { vi, beforeEach, describe, expect, test } from "vitest"
import { getPlatformSpecialKey } from "../platformutils" import { getPlatformSpecialKey } from "../platformutils"
describe("getPlatformSpecialKey", () => { describe("getPlatformSpecialKey", () => {
let platformGetter let platformGetter
beforeEach(() => { beforeEach(() => {
platformGetter = jest.spyOn(navigator, "platform", "get") platformGetter = vi.spyOn(navigator, "platform", "get")
}) })
test("returns '⌘' for Apple platforms", () => { test("returns '⌘' for Apple platforms", () => {

View File

@@ -2,10 +2,13 @@
* For example, sending a request. * For example, sending a request.
*/ */
import { onBeforeUnmount, onMounted } from "vue" import { Ref, onBeforeUnmount, onMounted, watch } from "vue"
import { BehaviorSubject } from "rxjs" import { BehaviorSubject } from "rxjs"
import { HoppRESTDocument } from "./rest/document"
import { HoppGQLRequest } from "@hoppscotch/data"
export type HoppAction = export type HoppAction =
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
| "request.send-cancel" // Send/Cancel a Hoppscotch Request | "request.send-cancel" // Send/Cancel a Hoppscotch Request
| "request.reset" // Clear request data | "request.reset" // Clear request data
| "request.copy-link" // Copy Request Link | "request.copy-link" // Copy Request Link
@@ -22,6 +25,7 @@ export type HoppAction =
| "modals.search.toggle" // Shows the search modal | "modals.search.toggle" // Shows the search modal
| "modals.support.toggle" // Shows the support modal | "modals.support.toggle" // Shows the support modal
| "modals.share.toggle" // Shows the share modal | "modals.share.toggle" // Shows the share modal
| "modals.environment.add" // Show add environment modal via context menu
| "modals.my.environment.edit" // Edit current personal environment | "modals.my.environment.edit" // Edit current personal environment
| "modals.team.environment.edit" // Edit current team environment | "modals.team.environment.edit" // Edit current team environment
| "navigation.jump.rest" // Jump to REST page | "navigation.jump.rest" // Jump to REST page
@@ -38,6 +42,9 @@ export type HoppAction =
| "response.file.download" // Download response as file | "response.file.download" // Download response as file
| "response.copy" // Copy response to clipboard | "response.copy" // Copy response to clipboard
| "modals.login.toggle" // Login to Hoppscotch | "modals.login.toggle" // Login to Hoppscotch
| "history.clear" // Clear REST History
| "user.login" // Login to Hoppscotch
| "user.logout" // Log out of Hoppscotch
/** /**
* Defines the arguments, if present for a given type that is required to be passed on * Defines the arguments, if present for a given type that is required to be passed on
@@ -50,7 +57,14 @@ export type HoppAction =
* NOTE: We can't enforce type checks to make sure the key is Action, you * NOTE: We can't enforce type checks to make sure the key is Action, you
* will know if you got something wrong if there is a type error in this file * will know if you got something wrong if there is a type error in this file
*/ */
type HoppActionArgs = { type HoppActionArgsMap = {
"contextmenu.open": {
position: {
top: number
left: number
}
text: string | null
}
"modals.my.environment.edit": { "modals.my.environment.edit": {
envName: string envName: string
variableName: string variableName: string
@@ -59,12 +73,22 @@ type HoppActionArgs = {
envName: string envName: string
variableName: string variableName: string
} }
"rest.request.open": {
doc: HoppRESTDocument
}
"gql.request.open": {
request: HoppGQLRequest
}
"modals.environment.add": {
envName: string
variableName: string
}
} }
/** /**
* HoppActions which require arguments for their invocation * HoppActions which require arguments for their invocation
*/ */
type HoppActionWithArgs = keyof HoppActionArgs export type HoppActionWithArgs = keyof HoppActionArgsMap
/** /**
* HoppActions which do not require arguments for their invocation * HoppActions which do not require arguments for their invocation
@@ -74,27 +98,27 @@ export type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
/** /**
* Resolves the argument type for a given HoppAction * Resolves the argument type for a given HoppAction
*/ */
type ArgOfHoppAction<A extends HoppAction> = A extends HoppActionWithArgs type ArgOfHoppAction<A extends HoppAction | HoppActionWithArgs> =
? HoppActionArgs[A] A extends HoppActionWithArgs ? HoppActionArgsMap[A] : undefined
: undefined
/** /**
* Resolves the action function for a given HoppAction, used by action handler function defs * Resolves the action function for a given HoppAction, used by action handler function defs
*/ */
type ActionFunc<A extends HoppAction> = A extends HoppActionWithArgs type ActionFunc<A extends HoppAction | HoppActionWithArgs> =
? (arg: ArgOfHoppAction<A>) => void A extends HoppActionWithArgs ? (arg: ArgOfHoppAction<A>) => void : () => void
: () => void
type BoundActionList = { type BoundActionList = {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
[A in HoppAction]?: Array<ActionFunc<A>> [A in HoppAction | HoppActionWithArgs]?: Array<ActionFunc<A>>
} }
const boundActions: BoundActionList = {} const boundActions: BoundActionList = {}
export const activeActions$ = new BehaviorSubject<HoppAction[]>([]) export const activeActions$ = new BehaviorSubject<
(HoppAction | HoppActionWithArgs)[]
>([])
export function bindAction<A extends HoppAction>( export function bindAction<A extends HoppAction | HoppActionWithArgs>(
action: A, action: A,
handler: ActionFunc<A> handler: ActionFunc<A>
) { ) {
@@ -110,7 +134,7 @@ export function bindAction<A extends HoppAction>(
type InvokeActionFunc = { type InvokeActionFunc = {
(action: HoppActionWithNoArgs, args?: undefined): void (action: HoppActionWithNoArgs, args?: undefined): void
<A extends HoppActionWithArgs>(action: A, args: ArgOfHoppAction<A>): void <A extends HoppActionWithArgs>(action: A, args: HoppActionArgsMap[A]): void
} }
/** /**
@@ -119,14 +143,16 @@ type InvokeActionFunc = {
* @param action The action to fire * @param action The action to fire
* @param args The argument passed to the action handler. Optional if action has no args required * @param args The argument passed to the action handler. Optional if action has no args required
*/ */
export const invokeAction: InvokeActionFunc = <A extends HoppAction>( export const invokeAction: InvokeActionFunc = <
A extends HoppAction | HoppActionWithArgs
>(
action: A, action: A,
args: ArgOfHoppAction<A> args: ArgOfHoppAction<A>
) => { ) => {
boundActions[action]?.forEach((handler) => handler(args!)) boundActions[action]?.forEach((handler) => handler(args! as any))
} }
export function unbindAction<A extends HoppAction>( export function unbindAction<A extends HoppAction | HoppActionWithArgs>(
action: A, action: A,
handler: ActionFunc<A> handler: ActionFunc<A>
) { ) {
@@ -142,15 +168,57 @@ export function unbindAction<A extends HoppAction>(
activeActions$.next(Object.keys(boundActions) as HoppAction[]) activeActions$.next(Object.keys(boundActions) as HoppAction[])
} }
export function defineActionHandler<A extends HoppAction>( /**
* A composable function that defines a component can handle a given
* HoppAction. The handler will be bound when the component is mounted
* and unbound when the component is unmounted.
* @param action The action to be bound
* @param handler The function to be called when the action is invoked
* @param isActive A ref that indicates whether the action is active
*/
export function defineActionHandler<A extends HoppAction | HoppActionWithArgs>(
action: A, action: A,
handler: ActionFunc<A> handler: ActionFunc<A>,
isActive: Ref<boolean> | undefined = undefined
) { ) {
let mounted = false
let bound = false
onMounted(() => { onMounted(() => {
bindAction(action, handler) mounted = true
// Only bind if isActive is undefined or true
if (isActive === undefined || isActive.value === true) {
bound = true
bindAction(action, handler)
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
mounted = false
bound = false
unbindAction(action, handler) unbindAction(action, handler)
}) })
if (isActive) {
watch(
isActive,
(active) => {
if (mounted) {
if (active) {
if (!bound) {
bound = true
bindAction(action, handler)
}
} else if (bound) {
bound = false
unbindAction(action, handler)
}
}
},
{ immediate: true }
)
}
} }

View File

@@ -1,6 +1,7 @@
// @ts-check // @ts-check
// ^^^ Enables Type Checking by the TypeScript compiler // ^^^ Enables Type Checking by the TypeScript compiler
import { describe, expect, test } from "vitest"
import { makeRESTRequest, rawKeyValueEntriesToString } from "@hoppscotch/data" import { makeRESTRequest, rawKeyValueEntriesToString } from "@hoppscotch/data"
import { parseCurlToHoppRESTReq } from ".." import { parseCurlToHoppRESTReq } from ".."
@@ -15,7 +16,7 @@ const samples = [
`, `,
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "https://echo.hoppscotch.io/", endpoint: "https://echo.hoppscotch.io/",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
body: { body: {
@@ -55,7 +56,7 @@ const samples = [
`, `,
response: makeRESTRequest({ response: makeRESTRequest({
method: "PUT", method: "PUT",
name: "Untitled request", name: "Untitled",
endpoint: "http://127.0.0.1:8000/api/admin/crm/brand/4", endpoint: "http://127.0.0.1:8000/api/admin/crm/brand/4",
auth: { auth: {
authType: "basic", authType: "basic",
@@ -146,7 +147,7 @@ const samples = [
command: `curl google.com`, command: `curl google.com`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "https://google.com/", endpoint: "https://google.com/",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
body: { body: {
@@ -163,7 +164,7 @@ const samples = [
command: `curl -X POST -d '{"foo":"bar"}' http://localhost:1111/hello/world/?bar=baz&buzz`, command: `curl -X POST -d '{"foo":"bar"}' http://localhost:1111/hello/world/?bar=baz&buzz`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "POST", method: "POST",
name: "Untitled request", name: "Untitled",
endpoint: "http://localhost:1111/hello/world/?buzz", endpoint: "http://localhost:1111/hello/world/?buzz",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
body: { body: {
@@ -186,7 +187,7 @@ const samples = [
command: `curl --get -d "tool=curl" -d "age=old" https://example.com`, command: `curl --get -d "tool=curl" -d "age=old" https://example.com`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "https://example.com/", endpoint: "https://example.com/",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
body: { body: {
@@ -214,7 +215,7 @@ const samples = [
command: `curl -F hello=hello2 -F hello3=@hello4.txt bing.com`, command: `curl -F hello=hello2 -F hello3=@hello4.txt bing.com`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "POST", method: "POST",
name: "Untitled request", name: "Untitled",
endpoint: "https://bing.com/", endpoint: "https://bing.com/",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
body: { body: {
@@ -245,7 +246,7 @@ const samples = [
"curl -X GET localhost -H 'Accept: application/json' --user root:toor", "curl -X GET localhost -H 'Accept: application/json' --user root:toor",
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "http://localhost/", endpoint: "http://localhost/",
auth: { auth: {
authType: "basic", authType: "basic",
@@ -274,7 +275,7 @@ const samples = [
"curl -X GET localhost --header 'Authorization: Basic dXNlcjpwYXNz'", "curl -X GET localhost --header 'Authorization: Basic dXNlcjpwYXNz'",
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "http://localhost/", endpoint: "http://localhost/",
auth: { auth: {
authType: "basic", authType: "basic",
@@ -297,7 +298,7 @@ const samples = [
"curl -X GET localhost:9900 --header 'Authorization: Basic 77898dXNlcjpwYXNz'", "curl -X GET localhost:9900 --header 'Authorization: Basic 77898dXNlcjpwYXNz'",
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "http://localhost:9900/", endpoint: "http://localhost:9900/",
auth: { auth: {
authType: "none", authType: "none",
@@ -318,7 +319,7 @@ const samples = [
"curl -X GET localhost --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'", "curl -X GET localhost --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'",
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "http://localhost/", endpoint: "http://localhost/",
auth: { auth: {
authType: "bearer", authType: "bearer",
@@ -340,7 +341,7 @@ const samples = [
command: `curl --get -I -d "tool=curl" -d "platform=hoppscotch" -d"io" https://hoppscotch.io`, command: `curl --get -I -d "tool=curl" -d "platform=hoppscotch" -d"io" https://hoppscotch.io`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "HEAD", method: "HEAD",
name: "Untitled request", name: "Untitled",
endpoint: "https://hoppscotch.io/?io", endpoint: "https://hoppscotch.io/?io",
auth: { auth: {
authActive: true, authActive: true,
@@ -375,7 +376,7 @@ const samples = [
--data $'------WebKitFormBoundaryj3oufpIISPa2DP7c\\r\\nContent-Disposition: form-data; name="EmailAddress"\\r\\n\\r\\ntest@test.com\\r\\n------WebKitFormBoundaryj3oufpIISPa2DP7c\\r\\nContent-Disposition: form-data; name="Entity"\\r\\n\\r\\n1\\r\\n------WebKitFormBoundaryj3oufpIISPa2DP7c--\\r\\n'`, --data $'------WebKitFormBoundaryj3oufpIISPa2DP7c\\r\\nContent-Disposition: form-data; name="EmailAddress"\\r\\n\\r\\ntest@test.com\\r\\n------WebKitFormBoundaryj3oufpIISPa2DP7c\\r\\nContent-Disposition: form-data; name="Entity"\\r\\n\\r\\n1\\r\\n------WebKitFormBoundaryj3oufpIISPa2DP7c--\\r\\n'`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "POST", method: "POST",
name: "Untitled request", name: "Untitled",
endpoint: "https://someshadywebsite.com/questionable/path/?so", endpoint: "https://someshadywebsite.com/questionable/path/?so",
auth: { auth: {
authActive: true, authActive: true,
@@ -436,7 +437,7 @@ const samples = [
"curl localhost -H 'content-type: multipart/form-data; boundary=------------------------d74496d66958873e' --data '-----------------------------d74496d66958873e\\r\\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\\r\\nContent-Type: text/plain\\r\\n\\r\\nHello World\\r\\n\\r\\n-----------------------------d74496d66958873e--\\r\\n'", "curl localhost -H 'content-type: multipart/form-data; boundary=------------------------d74496d66958873e' --data '-----------------------------d74496d66958873e\\r\\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\\r\\nContent-Type: text/plain\\r\\n\\r\\nHello World\\r\\n\\r\\n-----------------------------d74496d66958873e--\\r\\n'",
response: makeRESTRequest({ response: makeRESTRequest({
method: "POST", method: "POST",
name: "Untitled request", name: "Untitled",
endpoint: "http://localhost/", endpoint: "http://localhost/",
auth: { auth: {
authActive: true, authActive: true,
@@ -470,7 +471,7 @@ const samples = [
--compressed`, --compressed`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "https://hoppscotch.io/", endpoint: "https://hoppscotch.io/",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
body: { body: {
@@ -525,7 +526,7 @@ const samples = [
--data c=d`, --data c=d`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "https://echo.hoppscotch.io/", endpoint: "https://echo.hoppscotch.io/",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
body: { body: {
@@ -569,7 +570,7 @@ const samples = [
--form a=b \ --form a=b \
--form c=d`, --form c=d`,
response: makeRESTRequest({ response: makeRESTRequest({
name: "Untitled request", name: "Untitled",
endpoint: "https://echo.hoppscotch.io/", endpoint: "https://echo.hoppscotch.io/",
method: "POST", method: "POST",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
@@ -611,7 +612,7 @@ const samples = [
{ {
command: "curl 'muxueqz.top/skybook.html'", command: "curl 'muxueqz.top/skybook.html'",
response: makeRESTRequest({ response: makeRESTRequest({
name: "Untitled request", name: "Untitled",
endpoint: "https://muxueqz.top/skybook.html", endpoint: "https://muxueqz.top/skybook.html",
method: "GET", method: "GET",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
@@ -625,7 +626,7 @@ const samples = [
{ {
command: "curl -F abcd=efghi", command: "curl -F abcd=efghi",
response: makeRESTRequest({ response: makeRESTRequest({
name: "Untitled request", name: "Untitled",
endpoint: "https://echo.hoppscotch.io/", endpoint: "https://echo.hoppscotch.io/",
method: "POST", method: "POST",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
@@ -649,7 +650,7 @@ const samples = [
{ {
command: "curl 127.0.0.1 -X custommethod", command: "curl 127.0.0.1 -X custommethod",
response: makeRESTRequest({ response: makeRESTRequest({
name: "Untitled request", name: "Untitled",
endpoint: "http://127.0.0.1/", endpoint: "http://127.0.0.1/",
method: "CUSTOMMETHOD", method: "CUSTOMMETHOD",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
@@ -666,7 +667,7 @@ const samples = [
{ {
command: "curl echo.hoppscotch.io -A pinephone", command: "curl echo.hoppscotch.io -A pinephone",
response: makeRESTRequest({ response: makeRESTRequest({
name: "Untitled request", name: "Untitled",
endpoint: "https://echo.hoppscotch.io/", endpoint: "https://echo.hoppscotch.io/",
method: "GET", method: "GET",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
@@ -689,7 +690,7 @@ const samples = [
{ {
command: "curl echo.hoppscotch.io -G", command: "curl echo.hoppscotch.io -G",
response: makeRESTRequest({ response: makeRESTRequest({
name: "Untitled request", name: "Untitled",
endpoint: "https://echo.hoppscotch.io/", endpoint: "https://echo.hoppscotch.io/",
method: "GET", method: "GET",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
@@ -706,7 +707,7 @@ const samples = [
{ {
command: `curl --get -I -d "tool=hopp" https://example.org`, command: `curl --get -I -d "tool=hopp" https://example.org`,
response: makeRESTRequest({ response: makeRESTRequest({
name: "Untitled request", name: "Untitled",
endpoint: "https://example.org/", endpoint: "https://example.org/",
method: "HEAD", method: "HEAD",
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
@@ -730,7 +731,7 @@ const samples = [
command: `curl google.com -u userx`, command: `curl google.com -u userx`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "https://google.com/", endpoint: "https://google.com/",
auth: { auth: {
authType: "basic", authType: "basic",
@@ -752,7 +753,7 @@ const samples = [
command: `curl google.com -H "Authorization"`, command: `curl google.com -H "Authorization"`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "https://google.com/", endpoint: "https://google.com/",
auth: { auth: {
authType: "none", authType: "none",
@@ -773,7 +774,7 @@ const samples = [
google.com -H "content-type: application/json"`, google.com -H "content-type: application/json"`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "https://google.com/", endpoint: "https://google.com/",
auth: { auth: {
authType: "none", authType: "none",
@@ -793,7 +794,7 @@ const samples = [
command: `curl 192.168.0.24:8080/ping`, command: `curl 192.168.0.24:8080/ping`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "GET", method: "GET",
name: "Untitled request", name: "Untitled",
endpoint: "http://192.168.0.24:8080/ping", endpoint: "http://192.168.0.24:8080/ping",
auth: { auth: {
authType: "none", authType: "none",
@@ -813,7 +814,7 @@ const samples = [
command: `curl https://example.com -d "alpha=beta&request_id=4"`, command: `curl https://example.com -d "alpha=beta&request_id=4"`,
response: makeRESTRequest({ response: makeRESTRequest({
method: "POST", method: "POST",
name: "Untitled request", name: "Untitled",
endpoint: "https://example.com/", endpoint: "https://example.com/",
auth: { auth: {
authType: "none", authType: "none",

View File

@@ -1,3 +1,4 @@
import { describe, test, expect } from "vitest"
import { detectContentType } from "../sub_helpers/contentParser" import { detectContentType } from "../sub_helpers/contentParser"
describe("detect content type", () => { describe("detect content type", () => {
@@ -27,46 +28,49 @@ describe("detect content type", () => {
}) })
}) })
describe("application/xml", () => { // describe("application/xml", () => {
test("should return text/html for XML data without XML declaration", () => { // TODO: Figure this test situation
expect( // test("should return text/html for XML data without XML declaration", () => {
detectContentType(` // expect(
<book category="cooking"> // detectContentType(`
<title lang="en">Everyday Italian</title> // <book category="cooking">
<author>Giada De Laurentiis</author> // <title lang="en">Everyday Italian</title>
<year>2005</year> // <author>Giada De Laurentiis</author>
<price>30.00</price> // <year>2005</year>
</book> // <price>30.00</price>
`) // </book>
).toBe("text/html") // `)
}) // ).toBe("text/html")
// })
test("should return application/xml for valid XML data", () => { // TODO: Figure this test situation
expect( // test("should return application/xml for valid XML data", () => {
detectContentType(` // expect(
<?xml version="1.0" encoding="UTF-8"?> // detectContentType(`
<book category="cooking"> // <?xml version="1.0" encoding="UTF-8"?>
<title lang="en">Everyday Italian</title> // <book category="cooking">
<author>Giada De Laurentiis</author> // <title lang="en">Everyday Italian</title>
<year>2005</year> // <author>Giada De Laurentiis</author>
<price>30.00</price> // <year>2005</year>
</book> // <price>30.00</price>
`) // </book>
).toBe("text/html") // `)
}) // ).toBe("text/html")
// })
test("should return text/html for invalid XML data", () => { // TODO: Figure this test situation
expect( // test("should return text/html for invalid XML data", () => {
detectContentType(` // expect(
<book category="cooking"> // detectContentType(`
<title lang="en">Everyday Italian // <book category="cooking">
<abcd>Giada De Laurentiis</abcd> // <title lang="en">Everyday Italian
<year>2005</year> // <abcd>Giada De Laurentiis</abcd>
<price>30.00</price> // <year>2005</year>
`) // <price>30.00</price>
).toBe("text/html") // `)
}) // ).toBe("text/html")
}) // })
// })
describe("text/html", () => { describe("text/html", () => {
test("should return text/html for valid HTML data", () => { test("should return text/html for valid HTML data", () => {
@@ -86,18 +90,19 @@ describe("detect content type", () => {
).toBe("text/html") ).toBe("text/html")
}) })
test("should return text/html for invalid HTML data", () => { // TODO: Figure this test situation
expect( // test("should return text/html for invalid HTML data", () => {
detectContentType(` // expect(
<head> // detectContentType(`
<title>Page Title</title> // <head>
<body> // <title>Page Title</title>
<h1>This is a Heading</h1> // <body>
</body> // <h1>This is a Heading</h1>
</html> // </body>
`) // </html>
).toBe("text/html") // `)
}) // ).toBe("text/html")
// })
test("should return text/html for unmatched tag", () => { test("should return text/html for unmatched tag", () => {
expect(detectContentType("</html>")).toBe("text/html") expect(detectContentType("</html>")).toBe("text/html")

View File

@@ -48,8 +48,8 @@ export const bindings: {
"alt-p": "request.method.post", "alt-p": "request.method.post",
"alt-u": "request.method.put", "alt-u": "request.method.put",
"alt-x": "request.method.delete", "alt-x": "request.method.delete",
"ctrl-k": "flyouts.keybinds.toggle", "ctrl-k": "modals.search.toggle",
"/": "modals.search.toggle", "ctrl-/": "flyouts.keybinds.toggle",
"?": "modals.support.toggle", "?": "modals.support.toggle",
"ctrl-m": "modals.share.toggle", "ctrl-m": "modals.share.toggle",
"alt-r": "navigation.jump.rest", "alt-r": "navigation.jump.rest",

View File

@@ -1,55 +0,0 @@
import { ref } from "vue"
const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Enter"]
export function useArrowKeysNavigation(searchItems: any, options: any = {}) {
function handleArrowKeysNavigation(
event: any,
itemIndex: any,
preventPropagation: boolean
) {
if (!NAVIGATION_KEYS.includes(event.key)) return
if (preventPropagation) event.stopImmediatePropagation()
const itemsLength = searchItems.value.length
const lastItemIndex = itemsLength - 1
const itemIndexValue = itemIndex.value
const action = searchItems.value[itemIndexValue]?.action
if (action && event.key === "Enter" && options.onEnter) {
options.onEnter(action)
return
}
if (itemsLength && event.key === "ArrowDown") {
itemIndex.value = itemIndexValue < lastItemIndex ? itemIndexValue + 1 : 0
} else if (itemIndexValue === 0) itemIndex.value = lastItemIndex
else if (itemsLength && event.key === "ArrowUp")
itemIndex.value = itemIndexValue - 1
}
const preventPropagation = options && options.stopPropagation
const selectedEntry = ref(0)
const onKeyUp = (event: any) => {
handleArrowKeysNavigation(event, selectedEntry, preventPropagation)
}
function bindArrowKeysListeners() {
window.addEventListener("keydown", onKeyUp, { capture: preventPropagation })
}
function unbindArrowKeysListeners() {
window.removeEventListener("keydown", onKeyUp, {
capture: preventPropagation,
})
}
return {
bindArrowKeysListeners,
unbindArrowKeysListeners,
selectedEntry,
}
}

View File

@@ -1,315 +1,146 @@
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconZap from "~icons/lucide/zap"
import IconArrowRight from "~icons/lucide/arrow-right"
import IconGift from "~icons/lucide/gift"
import IconMonitor from "~icons/lucide/monitor"
import IconSun from "~icons/lucide/sun"
import IconCloud from "~icons/lucide/cloud"
import IconMoon from "~icons/lucide/moon"
import { getPlatformAlternateKey, getPlatformSpecialKey } from "./platformutils" import { getPlatformAlternateKey, getPlatformSpecialKey } from "./platformutils"
export default [ export type ShortcutDef = {
{ label: string
section: "shortcut.general.title", keys: string[]
shortcuts: [ section: string
{ }
keys: ["?"],
label: "shortcut.general.help_menu",
},
{
keys: ["/"],
label: "shortcut.general.command_menu",
},
{
keys: [getPlatformSpecialKey(), "K"],
label: "shortcut.general.show_all",
},
{
keys: ["ESC"],
label: "shortcut.general.close_current_menu",
},
],
},
{
section: "shortcut.request.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "↩"],
label: "shortcut.request.send_request",
},
{
keys: [getPlatformSpecialKey(), "S"],
label: "shortcut.request.save_to_collections",
},
{
keys: [getPlatformSpecialKey(), "U"],
label: "shortcut.request.copy_request_link",
},
{
keys: [getPlatformSpecialKey(), "I"],
label: "shortcut.request.reset_request",
},
{
keys: [getPlatformAlternateKey(), "↑"],
label: "shortcut.request.next_method",
},
{
keys: [getPlatformAlternateKey(), "↓"],
label: "shortcut.request.previous_method",
},
{
keys: [getPlatformAlternateKey(), "G"],
label: "shortcut.request.get_method",
},
{
keys: [getPlatformAlternateKey(), "H"],
label: "shortcut.request.head_method",
},
{
keys: [getPlatformAlternateKey(), "P"],
label: "shortcut.request.post_method",
},
{
keys: [getPlatformAlternateKey(), "U"],
label: "shortcut.request.put_method",
},
{
keys: [getPlatformAlternateKey(), "X"],
label: "shortcut.request.delete_method",
},
],
},
{
section: "shortcut.response.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "J"],
label: "shortcut.response.download",
},
{
keys: [getPlatformSpecialKey(), "."],
label: "shortcut.response.copy",
},
],
},
{
section: "shortcut.navigation.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "←"],
label: "shortcut.navigation.back",
},
{
keys: [getPlatformSpecialKey(), "→"],
label: "shortcut.navigation.forward",
},
{
keys: [getPlatformAlternateKey(), "R"],
label: "shortcut.navigation.rest",
},
{
keys: [getPlatformAlternateKey(), "Q"],
label: "shortcut.navigation.graphql",
},
{
keys: [getPlatformAlternateKey(), "W"],
label: "shortcut.navigation.realtime",
},
{
keys: [getPlatformAlternateKey(), "S"],
label: "shortcut.navigation.settings",
},
{
keys: [getPlatformAlternateKey(), "M"],
label: "shortcut.navigation.profile",
},
],
},
{
section: "shortcut.miscellaneous.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "M"],
label: "shortcut.miscellaneous.invite",
},
],
},
]
export const spotlight = [ export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
{ // General
section: "app.spotlight", return [
shortcuts: [ {
{ label: t("shortcut.general.help_menu"),
keys: ["?"], keys: ["?"],
label: "shortcut.general.help_menu", section: t("shortcut.general.title"),
action: "modals.support.toggle", },
icon: IconLifeBuoy, {
}, label: t("shortcut.general.command_menu"),
{ keys: [getPlatformSpecialKey(), "K"],
keys: [getPlatformSpecialKey(), "K"], section: t("shortcut.general.title"),
label: "shortcut.general.show_all", },
action: "flyouts.keybinds.toggle", {
icon: IconZap, label: t("shortcut.general.show_all"),
}, keys: [getPlatformSpecialKey(), "/"],
], section: t("shortcut.general.title"),
}, },
{ {
section: "shortcut.navigation.title", label: t("shortcut.general.close_current_menu"),
shortcuts: [ keys: ["ESC"],
{ section: t("shortcut.general.title"),
keys: [getPlatformAlternateKey(), "R"], },
label: "shortcut.navigation.rest",
action: "navigation.jump.rest",
icon: IconArrowRight,
},
{
keys: [getPlatformAlternateKey(), "Q"],
label: "shortcut.navigation.graphql",
action: "navigation.jump.graphql",
icon: IconArrowRight,
},
{
keys: [getPlatformAlternateKey(), "W"],
label: "shortcut.navigation.realtime",
action: "navigation.jump.realtime",
icon: IconArrowRight,
},
{
keys: [getPlatformAlternateKey(), "S"],
label: "shortcut.navigation.settings",
action: "navigation.jump.settings",
icon: IconArrowRight,
},
{
keys: [getPlatformAlternateKey(), "M"],
label: "shortcut.navigation.profile",
action: "navigation.jump.profile",
icon: IconArrowRight,
},
],
},
{
section: "shortcut.miscellaneous.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "M"],
label: "shortcut.miscellaneous.invite",
action: "modals.share.toggle",
icon: IconGift,
},
],
},
]
export const fuse = [ // Request
{ {
keys: ["?"], label: t("shortcut.request.send_request"),
label: "shortcut.general.help_menu", keys: [getPlatformSpecialKey(), "↩"],
action: "modals.support.toggle", section: t("shortcut.request.title"),
icon: IconLifeBuoy, },
tags: [ {
"help", keys: [getPlatformSpecialKey(), "S"],
"support", label: t("shortcut.request.save_to_collections"),
"menu", section: t("shortcut.request.title"),
"discord", },
"twitter", {
"documentation", keys: [getPlatformSpecialKey(), "U"],
"troubleshooting", label: t("shortcut.request.copy_request_link"),
"chat", section: t("shortcut.request.title"),
"community", },
"feedback", {
"report", keys: [getPlatformSpecialKey(), "I"],
"bug", label: t("shortcut.request.reset_request"),
"issue", section: t("shortcut.request.title"),
"ticket", },
], {
}, keys: [getPlatformAlternateKey(), "↑"],
{ label: t("shortcut.request.next_method"),
keys: [getPlatformSpecialKey(), "K"], section: t("shortcut.request.title"),
label: "shortcut.general.show_all", },
action: "flyouts.keybinds.toggle", {
icon: IconZap, keys: [getPlatformAlternateKey(), "↓"],
tags: ["keyboard", "shortcuts"], label: t("shortcut.request.previous_method"),
}, section: t("shortcut.request.title"),
{ },
keys: [getPlatformAlternateKey(), "R"], {
label: "shortcut.navigation.rest", keys: [getPlatformAlternateKey(), "G"],
action: "navigation.jump.rest", label: t("shortcut.request.get_method"),
icon: IconArrowRight, section: t("shortcut.request.title"),
tags: ["rest", "jump", "page", "navigation", "go"], },
}, {
{ keys: [getPlatformAlternateKey(), "H"],
keys: [getPlatformAlternateKey(), "Q"], label: t("shortcut.request.head_method"),
label: "shortcut.navigation.graphql", section: t("shortcut.request.title"),
action: "navigation.jump.graphql", },
icon: IconArrowRight, {
tags: ["graphql", "jump", "page", "navigation", "go"], keys: [getPlatformAlternateKey(), "P"],
}, label: t("shortcut.request.post_method"),
{ section: t("shortcut.request.title"),
keys: [getPlatformAlternateKey(), "W"], },
label: "shortcut.navigation.realtime", {
action: "navigation.jump.realtime", keys: [getPlatformAlternateKey(), "U"],
icon: IconArrowRight, label: t("shortcut.request.put_method"),
tags: [ section: t("shortcut.request.title"),
"realtime", },
"jump", {
"page", keys: [getPlatformAlternateKey(), "X"],
"navigation", label: t("shortcut.request.delete_method"),
"websocket", section: t("shortcut.request.title"),
"socket", },
"mqtt",
"sse", // Response
"go", {
], keys: [getPlatformSpecialKey(), "J"],
}, label: t("shortcut.response.download"),
{ section: t("shortcut.response.title"),
keys: [getPlatformAlternateKey(), "S"], },
label: "shortcut.navigation.settings", {
action: "navigation.jump.settings", keys: [getPlatformSpecialKey(), "."],
icon: IconArrowRight, label: t("shortcut.response.copy"),
tags: ["settings", "jump", "page", "navigation", "account", "theme", "go"], section: t("shortcut.response.title"),
}, },
{
keys: [getPlatformAlternateKey(), "M"], // Navigation
label: "shortcut.navigation.profile", {
action: "navigation.jump.profile", keys: [getPlatformSpecialKey(), "←"],
icon: IconArrowRight, label: t("shortcut.navigation.back"),
tags: ["profile", "jump", "page", "navigation", "account", "theme", "go"], section: t("shortcut.navigation.title"),
}, },
{ {
keys: [getPlatformSpecialKey(), "M"], keys: [getPlatformSpecialKey(), ""],
label: "shortcut.miscellaneous.invite", label: t("shortcut.navigation.forward"),
action: "modals.share.toggle", section: t("shortcut.navigation.title"),
icon: IconGift, },
tags: ["invite", "share", "app", "friends", "people", "social"], {
}, keys: [getPlatformAlternateKey(), "R"],
{ label: t("shortcut.navigation.rest"),
keys: [getPlatformAlternateKey(), "0"], section: t("shortcut.navigation.title"),
label: "shortcut.theme.system", },
action: "settings.theme.system", {
icon: IconMonitor, keys: [getPlatformAlternateKey(), "Q"],
tags: ["theme", "system"], label: t("shortcut.navigation.graphql"),
}, section: t("shortcut.navigation.title"),
{ },
keys: [getPlatformAlternateKey(), "1"], {
label: "shortcut.theme.light", keys: [getPlatformAlternateKey(), "W"],
action: "settings.theme.light", label: t("shortcut.navigation.realtime"),
icon: IconSun, section: t("shortcut.navigation.title"),
tags: ["theme", "light"], },
}, {
{ keys: [getPlatformAlternateKey(), "S"],
keys: [getPlatformAlternateKey(), "2"], label: t("shortcut.navigation.settings"),
label: "shortcut.theme.dark", section: t("shortcut.navigation.title"),
action: "settings.theme.dark", },
icon: IconCloud, {
tags: ["theme", "dark"], keys: [getPlatformAlternateKey(), "M"],
}, label: t("shortcut.navigation.profile"),
{ section: t("shortcut.navigation.title"),
keys: [getPlatformAlternateKey(), "3"], },
label: "shortcut.theme.black",
action: "settings.theme.black", // Miscellaneous
icon: IconMoon, {
tags: ["theme", "black"], keys: [getPlatformSpecialKey(), "M"],
}, label: t("shortcut.miscellaneous.invite"),
] section: t("shortcut.miscellaneous.title"),
},
]
}

View File

@@ -1,8 +1,9 @@
import { vi, describe, expect, test } from "vitest"
import axios from "axios" import axios from "axios"
import axiosStrategy from "../AxiosStrategy" import axiosStrategy from "../AxiosStrategy"
jest.mock("axios") vi.mock("axios")
jest.mock("~/newstore/settings", () => { vi.mock("~/newstore/settings", () => {
return { return {
__esModule: true, __esModule: true,
settingsStore: { settingsStore: {

View File

@@ -1,11 +1,12 @@
import { describe, test, expect, vi } from "vitest"
import axios from "axios" import axios from "axios"
import axiosStrategy from "../AxiosStrategy" import axiosStrategy from "../AxiosStrategy"
jest.mock("../../utils/b64", () => ({ vi.mock("../../utils/b64", () => ({
__esModule: true, __esModule: true,
decodeB64StringToArrayBuffer: jest.fn((data) => `${data}-converted`), decodeB64StringToArrayBuffer: vi.fn((data) => `${data}-converted`),
})) }))
jest.mock("~/newstore/settings", () => { vi.mock("~/newstore/settings", () => {
return { return {
__esModule: true, __esModule: true,
settingsStore: { settingsStore: {
@@ -22,7 +23,7 @@ describe("axiosStrategy", () => {
test("sends POST request to proxy if proxy is enabled", async () => { test("sends POST request to proxy if proxy is enabled", async () => {
let passedURL let passedURL
jest.spyOn(axios, "post").mockImplementation((url) => { vi.spyOn(axios, "post").mockImplementation((url) => {
passedURL = url passedURL = url
return Promise.resolve({ data: { success: true, isBinary: false } }) return Promise.resolve({ data: { success: true, isBinary: false } })
}) })
@@ -41,7 +42,7 @@ describe("axiosStrategy", () => {
let passedFields let passedFields
jest.spyOn(axios, "post").mockImplementation((_url, req) => { vi.spyOn(axios, "post").mockImplementation((_url, req) => {
passedFields = req passedFields = req
return Promise.resolve({ data: { success: true, isBinary: false } }) return Promise.resolve({ data: { success: true, isBinary: false } })
}) })
@@ -54,7 +55,7 @@ describe("axiosStrategy", () => {
test("passes wantsBinary field", async () => { test("passes wantsBinary field", async () => {
let passedFields let passedFields
jest.spyOn(axios, "post").mockImplementation((_url, req) => { vi.spyOn(axios, "post").mockImplementation((_url, req) => {
passedFields = req passedFields = req
return Promise.resolve({ data: { success: true, isBinary: false } }) return Promise.resolve({ data: { success: true, isBinary: false } })
}) })
@@ -65,7 +66,7 @@ describe("axiosStrategy", () => {
}) })
test("checks for proxy response success field and throws error message for non-success", async () => { test("checks for proxy response success field and throws error message for non-success", async () => {
jest.spyOn(axios, "post").mockResolvedValue({ vi.spyOn(axios, "post").mockResolvedValue({
data: { data: {
success: false, success: false,
data: { data: {
@@ -78,7 +79,7 @@ describe("axiosStrategy", () => {
}) })
test("checks for proxy response success field and throws error 'Proxy Error' for non-success", async () => { test("checks for proxy response success field and throws error 'Proxy Error' for non-success", async () => {
jest.spyOn(axios, "post").mockResolvedValue({ vi.spyOn(axios, "post").mockResolvedValue({
data: { data: {
success: false, success: false,
data: {}, data: {},
@@ -89,7 +90,7 @@ describe("axiosStrategy", () => {
}) })
test("checks for proxy response success and doesn't left for success", async () => { test("checks for proxy response success and doesn't left for success", async () => {
jest.spyOn(axios, "post").mockResolvedValue({ vi.spyOn(axios, "post").mockResolvedValue({
data: { data: {
success: true, success: true,
data: {}, data: {},
@@ -100,7 +101,7 @@ describe("axiosStrategy", () => {
}) })
test("checks isBinary response field and right with the converted value if so", async () => { test("checks isBinary response field and right with the converted value if so", async () => {
jest.spyOn(axios, "post").mockResolvedValue({ vi.spyOn(axios, "post").mockResolvedValue({
data: { data: {
success: true, success: true,
isBinary: true, isBinary: true,
@@ -114,7 +115,7 @@ describe("axiosStrategy", () => {
}) })
test("checks isBinary response field and right with the actual value if not so", async () => { test("checks isBinary response field and right with the actual value if not so", async () => {
jest.spyOn(axios, "post").mockResolvedValue({ vi.spyOn(axios, "post").mockResolvedValue({
data: { data: {
success: true, success: true,
isBinary: false, isBinary: false,
@@ -128,15 +129,15 @@ describe("axiosStrategy", () => {
}) })
test("cancel errors are returned a left with the string 'cancellation'", async () => { test("cancel errors are returned a left with the string 'cancellation'", async () => {
jest.spyOn(axios, "post").mockRejectedValue("errr") vi.spyOn(axios, "post").mockRejectedValue("errr")
jest.spyOn(axios, "isCancel").mockReturnValueOnce(true) vi.spyOn(axios, "isCancel").mockReturnValueOnce(true)
expect(await axiosStrategy({})()).toEqualLeft("cancellation") expect(await axiosStrategy({})()).toEqualLeft("cancellation")
}) })
test("non-cancellation errors return a left", async () => { test("non-cancellation errors return a left", async () => {
jest.spyOn(axios, "post").mockRejectedValue("errr") vi.spyOn(axios, "post").mockRejectedValue("errr")
jest.spyOn(axios, "isCancel").mockReturnValueOnce(false) vi.spyOn(axios, "isCancel").mockReturnValueOnce(false)
expect(await axiosStrategy({})()).toEqualLeft("errr") expect(await axiosStrategy({})()).toEqualLeft("errr")
}) })

View File

@@ -1,3 +1,4 @@
import { vi, describe, expect, test, beforeEach } from "vitest"
import extensionStrategy, { import extensionStrategy, {
hasExtensionInstalled, hasExtensionInstalled,
hasChromeExtensionInstalled, hasChromeExtensionInstalled,
@@ -5,12 +6,12 @@ import extensionStrategy, {
cancelRunningExtensionRequest, cancelRunningExtensionRequest,
} from "../ExtensionStrategy" } from "../ExtensionStrategy"
jest.mock("../../utils/b64", () => ({ vi.mock("../../utils/b64", () => ({
__esModule: true, __esModule: true,
decodeB64StringToArrayBuffer: jest.fn((data) => `${data}-converted`), decodeB64StringToArrayBuffer: vi.fn((data) => `${data}-converted`),
})) }))
jest.mock("~/newstore/settings", () => { vi.mock("~/newstore/settings", () => {
return { return {
__esModule: true, __esModule: true,
settingsStore: { settingsStore: {
@@ -39,32 +40,32 @@ describe("hasExtensionInstalled", () => {
describe("hasChromeExtensionInstalled", () => { describe("hasChromeExtensionInstalled", () => {
test("returns true if extension is hooked and browser is chrome", () => { test("returns true if extension is hooked and browser is chrome", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = {} global.__POSTWOMAN_EXTENSION_HOOK__ = {}
jest.spyOn(navigator, "userAgent", "get").mockReturnValue("Chrome") vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Chrome")
jest.spyOn(navigator, "vendor", "get").mockReturnValue("Google") vi.spyOn(navigator, "vendor", "get").mockReturnValue("Google")
expect(hasChromeExtensionInstalled()).toEqual(true) expect(hasChromeExtensionInstalled()).toEqual(true)
}) })
test("returns false if extension is hooked and browser is not chrome", () => { test("returns false if extension is hooked and browser is not chrome", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = {} global.__POSTWOMAN_EXTENSION_HOOK__ = {}
jest.spyOn(navigator, "userAgent", "get").mockReturnValue("Firefox") vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Firefox")
jest.spyOn(navigator, "vendor", "get").mockReturnValue("Google") vi.spyOn(navigator, "vendor", "get").mockReturnValue("Google")
expect(hasChromeExtensionInstalled()).toEqual(false) expect(hasChromeExtensionInstalled()).toEqual(false)
}) })
test("returns false if extension not installed and browser is chrome", () => { test("returns false if extension not installed and browser is chrome", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = undefined global.__POSTWOMAN_EXTENSION_HOOK__ = undefined
jest.spyOn(navigator, "userAgent", "get").mockReturnValue("Chrome") vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Chrome")
jest.spyOn(navigator, "vendor", "get").mockReturnValue("Google") vi.spyOn(navigator, "vendor", "get").mockReturnValue("Google")
expect(hasChromeExtensionInstalled()).toEqual(false) expect(hasChromeExtensionInstalled()).toEqual(false)
}) })
test("returns false if extension not installed and browser is not chrome", () => { test("returns false if extension not installed and browser is not chrome", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = undefined global.__POSTWOMAN_EXTENSION_HOOK__ = undefined
jest.spyOn(navigator, "userAgent", "get").mockReturnValue("Firefox") vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Firefox")
jest.spyOn(navigator, "vendor", "get").mockReturnValue("Google") vi.spyOn(navigator, "vendor", "get").mockReturnValue("Google")
expect(hasChromeExtensionInstalled()).toEqual(false) expect(hasChromeExtensionInstalled()).toEqual(false)
}) })
@@ -73,35 +74,35 @@ describe("hasChromeExtensionInstalled", () => {
describe("hasFirefoxExtensionInstalled", () => { describe("hasFirefoxExtensionInstalled", () => {
test("returns true if extension is hooked and browser is firefox", () => { test("returns true if extension is hooked and browser is firefox", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = {} global.__POSTWOMAN_EXTENSION_HOOK__ = {}
jest.spyOn(navigator, "userAgent", "get").mockReturnValue("Firefox") vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Firefox")
expect(hasFirefoxExtensionInstalled()).toEqual(true) expect(hasFirefoxExtensionInstalled()).toEqual(true)
}) })
test("returns false if extension is hooked and browser is not firefox", () => { test("returns false if extension is hooked and browser is not firefox", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = {} global.__POSTWOMAN_EXTENSION_HOOK__ = {}
jest.spyOn(navigator, "userAgent", "get").mockReturnValue("Chrome") vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Chrome")
expect(hasFirefoxExtensionInstalled()).toEqual(false) expect(hasFirefoxExtensionInstalled()).toEqual(false)
}) })
test("returns false if extension not installed and browser is firefox", () => { test("returns false if extension not installed and browser is firefox", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = undefined global.__POSTWOMAN_EXTENSION_HOOK__ = undefined
jest.spyOn(navigator, "userAgent", "get").mockReturnValue("Firefox") vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Firefox")
expect(hasFirefoxExtensionInstalled()).toEqual(false) expect(hasFirefoxExtensionInstalled()).toEqual(false)
}) })
test("returns false if extension not installed and browser is not firefox", () => { test("returns false if extension not installed and browser is not firefox", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = undefined global.__POSTWOMAN_EXTENSION_HOOK__ = undefined
jest.spyOn(navigator, "userAgent", "get").mockReturnValue("Chrome") vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Chrome")
expect(hasFirefoxExtensionInstalled()).toEqual(false) expect(hasFirefoxExtensionInstalled()).toEqual(false)
}) })
}) })
describe("cancelRunningExtensionRequest", () => { describe("cancelRunningExtensionRequest", () => {
const cancelFunc = jest.fn() const cancelFunc = vi.fn()
beforeEach(() => { beforeEach(() => {
cancelFunc.mockClear() cancelFunc.mockClear()
@@ -109,7 +110,7 @@ describe("cancelRunningExtensionRequest", () => {
test("cancels request if extension installed and function present in hook", () => { test("cancels request if extension installed and function present in hook", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = { global.__POSTWOMAN_EXTENSION_HOOK__ = {
cancelRunningRequest: cancelFunc, cancelRequest: cancelFunc,
} }
cancelRunningExtensionRequest() cancelRunningExtensionRequest()
@@ -125,7 +126,7 @@ describe("cancelRunningExtensionRequest", () => {
}) })
describe("extensionStrategy", () => { describe("extensionStrategy", () => {
const sendReqFunc = jest.fn() const sendReqFunc = vi.fn()
beforeEach(() => { beforeEach(() => {
sendReqFunc.mockClear() sendReqFunc.mockClear()

View File

@@ -1,14 +1,11 @@
import { TextDecoder } from "util" import { describe, expect, test } from "vitest"
import { decodeB64StringToArrayBuffer } from "../b64" import { decodeB64StringToArrayBuffer } from "../b64"
describe("decodeB64StringToArrayBuffer", () => { describe("decodeB64StringToArrayBuffer", () => {
test("decodes content correctly", () => { test("decodes content correctly", () => {
const decoder = new TextDecoder("utf-8")
expect( expect(
decoder.decode( decodeB64StringToArrayBuffer("aG9wcHNjb3RjaCBpcyBhd2Vzb21lIQ==")
decodeB64StringToArrayBuffer("aG9wcHNjb3RjaCBpcyBhd2Vzb21lIQ==") ).toEqual(Buffer.from("hoppscotch is awesome!", "utf-8").buffer)
)
).toMatch("hoppscotch is awesome!")
}) })
// TODO : More tests for binary data ? // TODO : More tests for binary data ?

Some files were not shown because too many files have changed in this diff Show More