Compare commits
12 Commits
feat-cherr
...
test/backe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c188f865a2 | ||
|
|
a2a675dd86 | ||
|
|
b867ba9139 | ||
|
|
d2ca631492 | ||
|
|
39a4fd8ab2 | ||
|
|
6928eb7992 | ||
|
|
76d52a3b05 | ||
|
|
b83cc38a1c | ||
|
|
db42073d42 | ||
|
|
6c928e72d4 | ||
|
|
ddd0a67da3 | ||
|
|
295304feeb |
@@ -13,7 +13,6 @@ SESSION_SECRET='add some secret here'
|
||||
# Hoppscotch App Domain Config
|
||||
REDIRECT_URL="http://localhost:3000"
|
||||
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
||||
ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
|
||||
|
||||
# Google Auth Config
|
||||
GOOGLE_CLIENT_ID="************************************************"
|
||||
|
||||
24
packages/dioc/.gitignore
vendored
24
packages/dioc/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,141 +0,0 @@
|
||||
# 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
2
packages/dioc/index.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export { default } from "./dist/main.d.ts"
|
||||
export * from "./dist/main.d.ts"
|
||||
@@ -1,147 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./container"
|
||||
export * from "./service"
|
||||
@@ -1,65 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
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
2
packages/dioc/testing.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export { default } from "./dist/testing.d.ts"
|
||||
export * from "./dist/testing.d.ts"
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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'],
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
|
||||
}
|
||||
})
|
||||
2
packages/dioc/vue.d.ts
vendored
2
packages/dioc/vue.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export { default } from "./dist/vue.d.ts"
|
||||
export * from "./dist/vue.d.ts"
|
||||
@@ -411,23 +411,6 @@ export class AdminResolver {
|
||||
return deletedTeam.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Revoke a team Invite by Invite ID',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async revokeTeamInviteByAdmin(
|
||||
@Args({
|
||||
name: 'inviteID',
|
||||
description: 'Team Invite ID',
|
||||
type: () => ID,
|
||||
})
|
||||
inviteID: string,
|
||||
): Promise<boolean> {
|
||||
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
|
||||
if (E.isLeft(invite)) throwErr(invite.left);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Subscriptions */
|
||||
|
||||
@Subscription(() => InvitedUser, {
|
||||
|
||||
@@ -10,11 +10,23 @@ import { TeamInvitationService } from '../team-invitation/team-invitation.servic
|
||||
import { TeamCollectionService } from '../team-collection/team-collection.service';
|
||||
import { MailerService } from '../mailer/mailer.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { User as DbUser } from '@prisma/client';
|
||||
import {
|
||||
DUPLICATE_EMAIL,
|
||||
INVALID_EMAIL,
|
||||
ONLY_ONE_ADMIN_ACCOUNT,
|
||||
TEAM_INVITE_ALREADY_MEMBER,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
USER_ALREADY_INVITED,
|
||||
USER_IS_ADMIN,
|
||||
USER_NOT_FOUND,
|
||||
} from '../errors';
|
||||
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
|
||||
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import * as utils from 'src/utils';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
@@ -52,7 +64,582 @@ const invitedUsers: InvitedUsers[] = [
|
||||
invitedOn: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const allUsers: DbUser[] = [
|
||||
{
|
||||
uid: 'uid1',
|
||||
displayName: 'user1',
|
||||
email: 'user1@hoppscotch.io',
|
||||
photoURL: 'https://hoppscotch.io',
|
||||
isAdmin: true,
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: null,
|
||||
currentGQLSession: null,
|
||||
createdOn: new Date(),
|
||||
},
|
||||
{
|
||||
uid: 'uid2',
|
||||
displayName: 'user2',
|
||||
email: 'user2@hoppscotch.io',
|
||||
photoURL: 'https://hoppscotch.io',
|
||||
isAdmin: false,
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: null,
|
||||
currentGQLSession: null,
|
||||
createdOn: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const teamMembers: TeamMember[] = [
|
||||
{
|
||||
membershipID: 'teamMember1',
|
||||
userUid: allUsers[0].uid,
|
||||
role: TeamMemberRole.OWNER,
|
||||
},
|
||||
];
|
||||
|
||||
const teams: Team[] = [
|
||||
{
|
||||
id: 'team1',
|
||||
name: 'team1',
|
||||
},
|
||||
{
|
||||
id: 'team2',
|
||||
name: 'team2',
|
||||
},
|
||||
];
|
||||
|
||||
const teamInvitations: TeamInvitation[] = [
|
||||
{
|
||||
id: 'teamInvitation1',
|
||||
teamID: 'team1',
|
||||
creatorUid: 'uid1',
|
||||
inviteeEmail: '',
|
||||
inviteeRole: TeamMemberRole.OWNER,
|
||||
},
|
||||
];
|
||||
|
||||
describe('AdminService', () => {
|
||||
describe('fetchUsers', () => {
|
||||
test('should resolve right and return an array of users if cursorID is null', async () => {
|
||||
mockUserService.fetchAllUsers.mockResolvedValueOnce(allUsers);
|
||||
|
||||
const result = await adminService.fetchUsers(null, 10);
|
||||
|
||||
expect(result).toEqual(allUsers);
|
||||
expect(mockUserService.fetchAllUsers).toHaveBeenCalledWith(null, 10);
|
||||
});
|
||||
test('should resolve right and return an array of users if cursorID is not null', async () => {
|
||||
mockUserService.fetchAllUsers.mockResolvedValueOnce([allUsers[1]]);
|
||||
|
||||
const cursorID = allUsers[0].uid;
|
||||
const result = await adminService.fetchUsers(cursorID, 10);
|
||||
|
||||
expect(result).toEqual([allUsers[1]]);
|
||||
expect(mockUserService.fetchAllUsers).toHaveBeenCalledWith(cursorID, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllTeams', () => {
|
||||
test('should resolve right and return an array of teams if cursorID is null', async () => {
|
||||
mockTeamService.fetchAllTeams.mockResolvedValueOnce(teams);
|
||||
|
||||
const result = await adminService.fetchAllTeams(null, 10);
|
||||
|
||||
expect(result).toEqual(teams);
|
||||
expect(mockTeamService.fetchAllTeams).toHaveBeenCalledWith(null, 10);
|
||||
});
|
||||
test('should resolve right and return an array of teams if cursorID is not null', async () => {
|
||||
mockTeamService.fetchAllTeams.mockResolvedValueOnce([teams[1]]);
|
||||
|
||||
const cursorID = teams[0].id;
|
||||
const result = await adminService.fetchAllTeams(cursorID, 10);
|
||||
|
||||
expect(result).toEqual([teams[1]]);
|
||||
expect(mockTeamService.fetchAllTeams).toHaveBeenCalledWith(cursorID, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('membersCountInTeam', () => {
|
||||
test('should resolve right and return the count of members in a team', async () => {
|
||||
mockTeamService.getCountOfMembersInTeam.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await adminService.membersCountInTeam('team1');
|
||||
|
||||
expect(result).toEqual(10);
|
||||
expect(mockTeamService.getCountOfMembersInTeam).toHaveBeenCalledWith(
|
||||
'team1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectionCountInTeam', () => {
|
||||
test('should resolve right and return the count of collections in a team', async () => {
|
||||
mockTeamCollectionService.totalCollectionsInTeam.mockResolvedValueOnce(
|
||||
10,
|
||||
);
|
||||
|
||||
const result = await adminService.collectionCountInTeam('team1');
|
||||
|
||||
expect(result).toEqual(10);
|
||||
expect(
|
||||
mockTeamCollectionService.totalCollectionsInTeam,
|
||||
).toHaveBeenCalledWith('team1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestCountInTeam', () => {
|
||||
test('should resolve right and return the count of requests in a team', async () => {
|
||||
mockTeamRequestService.totalRequestsInATeam.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await adminService.requestCountInTeam('team1');
|
||||
|
||||
expect(result).toEqual(10);
|
||||
expect(mockTeamRequestService.totalRequestsInATeam).toHaveBeenCalledWith(
|
||||
'team1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environmentCountInTeam', () => {
|
||||
test('should resolve right and return the count of environments in a team', async () => {
|
||||
mockTeamEnvironmentsService.totalEnvsInTeam.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await adminService.environmentCountInTeam('team1');
|
||||
|
||||
expect(result).toEqual(10);
|
||||
expect(mockTeamEnvironmentsService.totalEnvsInTeam).toHaveBeenCalledWith(
|
||||
'team1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pendingInvitationCountInTeam', () => {
|
||||
test('should resolve right and return the count of pending invitations in a team', async () => {
|
||||
mockTeamInvitationService.getTeamInvitations.mockResolvedValueOnce(
|
||||
teamInvitations,
|
||||
);
|
||||
|
||||
const result = await adminService.pendingInvitationCountInTeam('team1');
|
||||
|
||||
expect(result).toEqual(teamInvitations);
|
||||
expect(
|
||||
mockTeamInvitationService.getTeamInvitations,
|
||||
).toHaveBeenCalledWith('team1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeRoleOfUserTeam', () => {
|
||||
test('should resolve right and return the count of pending invitations in a team', async () => {
|
||||
const teamMember = teamMembers[0];
|
||||
|
||||
mockTeamService.updateTeamMemberRole.mockResolvedValueOnce(
|
||||
E.right(teamMember),
|
||||
);
|
||||
|
||||
const result = await adminService.changeRoleOfUserTeam(
|
||||
teamMember.userUid,
|
||||
'team1',
|
||||
teamMember.role,
|
||||
);
|
||||
|
||||
expect(result).toEqualRight(teamMember);
|
||||
expect(mockTeamService.updateTeamMemberRole).toHaveBeenCalledWith(
|
||||
'team1',
|
||||
teamMember.userUid,
|
||||
teamMember.role,
|
||||
);
|
||||
});
|
||||
|
||||
test('should resolve left and return the error if any error occurred', async () => {
|
||||
const teamMember = teamMembers[0];
|
||||
const errorMessage = 'Team member not found';
|
||||
|
||||
mockTeamService.updateTeamMemberRole.mockResolvedValueOnce(
|
||||
E.left(errorMessage),
|
||||
);
|
||||
|
||||
const result = await adminService.changeRoleOfUserTeam(
|
||||
teamMember.userUid,
|
||||
'team1',
|
||||
teamMember.role,
|
||||
);
|
||||
|
||||
expect(result).toEqualLeft(errorMessage);
|
||||
expect(mockTeamService.updateTeamMemberRole).toHaveBeenCalledWith(
|
||||
'team1',
|
||||
teamMember.userUid,
|
||||
teamMember.role,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUserFromTeam', () => {
|
||||
test('should resolve right and remove user from a team', async () => {
|
||||
const teamMember = teamMembers[0];
|
||||
|
||||
mockTeamService.leaveTeam.mockResolvedValueOnce(E.right(true));
|
||||
|
||||
const result = await adminService.removeUserFromTeam(
|
||||
teamMember.userUid,
|
||||
'team1',
|
||||
);
|
||||
|
||||
expect(result).toEqualRight(true);
|
||||
expect(mockTeamService.leaveTeam).toHaveBeenCalledWith(
|
||||
'team1',
|
||||
teamMember.userUid,
|
||||
);
|
||||
});
|
||||
|
||||
test('should resolve left and return the error if any error occurred', async () => {
|
||||
const teamMember = teamMembers[0];
|
||||
const errorMessage = 'Team member not found';
|
||||
|
||||
mockTeamService.leaveTeam.mockResolvedValueOnce(E.left(errorMessage));
|
||||
|
||||
const result = await adminService.removeUserFromTeam(
|
||||
teamMember.userUid,
|
||||
'team1',
|
||||
);
|
||||
|
||||
expect(result).toEqualLeft(errorMessage);
|
||||
expect(mockTeamService.leaveTeam).toHaveBeenCalledWith(
|
||||
'team1',
|
||||
teamMember.userUid,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUserToTeam', () => {
|
||||
test('should return INVALID_EMAIL when email is invalid', async () => {
|
||||
const teamID = 'team1';
|
||||
const userEmail = 'invalidEmail';
|
||||
const role = TeamMemberRole.EDITOR;
|
||||
|
||||
const mockValidateEmail = jest.spyOn(utils, 'validateEmail');
|
||||
mockValidateEmail.mockReturnValueOnce(false);
|
||||
|
||||
const result = await adminService.addUserToTeam(teamID, userEmail, role);
|
||||
|
||||
expect(result).toEqual(E.left(INVALID_EMAIL));
|
||||
expect(mockValidateEmail).toHaveBeenCalledWith(userEmail);
|
||||
expect(mockUserService.findUserByEmail).not.toHaveBeenCalled();
|
||||
expect(mockTeamService.getTeamMemberTE).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return USER_NOT_FOUND when user is not found', async () => {
|
||||
const teamID = 'team1';
|
||||
const userEmail = 'u@example.com';
|
||||
const role = TeamMemberRole.EDITOR;
|
||||
|
||||
const mockValidateEmail = jest.spyOn(utils, 'validateEmail');
|
||||
mockValidateEmail.mockReturnValueOnce(true);
|
||||
mockUserService.findUserByEmail.mockResolvedValue(O.none);
|
||||
|
||||
const result = await adminService.addUserToTeam(teamID, userEmail, role);
|
||||
|
||||
expect(result).toEqual(E.left(USER_NOT_FOUND));
|
||||
expect(mockValidateEmail).toHaveBeenCalledWith(userEmail);
|
||||
});
|
||||
|
||||
test('should return TEAM_INVITE_ALREADY_MEMBER when user is already a member of the team', async () => {
|
||||
const teamID = 'team1';
|
||||
const userEmail = allUsers[0].email;
|
||||
const role = TeamMemberRole.EDITOR;
|
||||
|
||||
const mockValidateEmail = jest.spyOn(utils, 'validateEmail');
|
||||
mockValidateEmail.mockReturnValueOnce(true);
|
||||
mockUserService.findUserByEmail.mockResolvedValueOnce(
|
||||
O.some(allUsers[0]),
|
||||
);
|
||||
mockTeamService.getTeamMemberTE.mockReturnValueOnce(
|
||||
TE.right(teamMembers[0]),
|
||||
);
|
||||
|
||||
const result = await adminService.addUserToTeam(teamID, userEmail, role);
|
||||
|
||||
expect(result).toEqual(E.left(TEAM_INVITE_ALREADY_MEMBER));
|
||||
expect(mockValidateEmail).toHaveBeenCalledWith(userEmail);
|
||||
expect(mockUserService.findUserByEmail).toHaveBeenCalledWith(userEmail);
|
||||
expect(mockTeamService.getTeamMemberTE).toHaveBeenCalledWith(
|
||||
teamID,
|
||||
allUsers[0].uid,
|
||||
);
|
||||
});
|
||||
|
||||
test('should add user to the team and return the result when user is not a member of the team', async () => {
|
||||
const teamID = 'team1';
|
||||
const userEmail = allUsers[0].email;
|
||||
const role = TeamMemberRole.EDITOR;
|
||||
|
||||
const mockValidateEmail = jest.spyOn(utils, 'validateEmail');
|
||||
mockValidateEmail.mockReturnValueOnce(true);
|
||||
mockUserService.findUserByEmail.mockResolvedValueOnce(
|
||||
O.some(allUsers[0]),
|
||||
);
|
||||
mockTeamService.getTeamMemberTE.mockReturnValueOnce(
|
||||
TE.left(TEAM_MEMBER_NOT_FOUND),
|
||||
);
|
||||
mockTeamService.addMemberToTeamWithEmail.mockResolvedValueOnce(
|
||||
E.right(teamMembers[0]),
|
||||
);
|
||||
mockTeamInvitationService.getTeamInviteByEmailAndTeamID.mockResolvedValueOnce(
|
||||
E.right(teamInvitations[0])
|
||||
);
|
||||
|
||||
const result = await adminService.addUserToTeam(teamID, userEmail, role);
|
||||
|
||||
expect(result).toEqual(E.right(teamMembers[0]));
|
||||
expect(mockValidateEmail).toHaveBeenCalledWith(userEmail);
|
||||
expect(mockUserService.findUserByEmail).toHaveBeenCalledWith(userEmail);
|
||||
expect(mockTeamService.getTeamMemberTE).toHaveBeenCalledWith(
|
||||
teamID,
|
||||
allUsers[0].uid,
|
||||
);
|
||||
expect(mockTeamService.addMemberToTeamWithEmail).toHaveBeenCalledWith(
|
||||
teamID,
|
||||
allUsers[0].email,
|
||||
role,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createATeam', () => {
|
||||
test('should return USER_NOT_FOUND when user is not found', async () => {
|
||||
const userUid = allUsers[0].uid;
|
||||
const teamName = 'team1';
|
||||
|
||||
mockUserService.findUserById.mockResolvedValue(O.none);
|
||||
|
||||
const result = await adminService.createATeam(userUid, teamName);
|
||||
|
||||
expect(result).toEqual(E.left(USER_NOT_FOUND));
|
||||
expect(mockUserService.findUserById).toHaveBeenCalledWith(userUid);
|
||||
expect(mockTeamService.createTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should create a team and return the result when the team is created successfully', async () => {
|
||||
const user = allUsers[0];
|
||||
const team = teams[0];
|
||||
|
||||
mockUserService.findUserById.mockResolvedValueOnce(O.some(user));
|
||||
mockTeamService.createTeam.mockResolvedValueOnce(E.right(team));
|
||||
|
||||
const result = await adminService.createATeam(user.uid, team.name);
|
||||
|
||||
expect(result).toEqual(E.right(team));
|
||||
expect(mockUserService.findUserById).toHaveBeenCalledWith(user.uid);
|
||||
expect(mockTeamService.createTeam).toHaveBeenCalledWith(
|
||||
team.name,
|
||||
user.uid,
|
||||
);
|
||||
});
|
||||
|
||||
test('should return the error when the team creation fails', async () => {
|
||||
const user = allUsers[0];
|
||||
const team = teams[0];
|
||||
const errorMessage = 'error';
|
||||
|
||||
mockUserService.findUserById.mockResolvedValueOnce(O.some(user));
|
||||
mockTeamService.createTeam.mockResolvedValueOnce(E.left(errorMessage));
|
||||
|
||||
const result = await adminService.createATeam(user.uid, team.name);
|
||||
|
||||
expect(result).toEqual(E.left(errorMessage));
|
||||
expect(mockUserService.findUserById).toHaveBeenCalledWith(user.uid);
|
||||
expect(mockTeamService.createTeam).toHaveBeenCalledWith(
|
||||
team.name,
|
||||
user.uid,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameATeam', () => {
|
||||
test('should rename a team and return the result when the team is renamed successfully', async () => {
|
||||
const team = teams[0];
|
||||
const newName = 'new name';
|
||||
|
||||
mockTeamService.renameTeam.mockResolvedValueOnce(E.right(team));
|
||||
|
||||
const result = await adminService.renameATeam(team.id, newName);
|
||||
|
||||
expect(result).toEqual(E.right(team));
|
||||
expect(mockTeamService.renameTeam).toHaveBeenCalledWith(team.id, newName);
|
||||
});
|
||||
|
||||
test('should return the error when the team renaming fails', async () => {
|
||||
const team = teams[0];
|
||||
const newName = 'new name';
|
||||
const errorMessage = 'error';
|
||||
|
||||
mockTeamService.renameTeam.mockResolvedValueOnce(E.left(errorMessage));
|
||||
|
||||
const result = await adminService.renameATeam(team.id, newName);
|
||||
|
||||
expect(result).toEqual(E.left(errorMessage));
|
||||
expect(mockTeamService.renameTeam).toHaveBeenCalledWith(team.id, newName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteATeam', () => {
|
||||
test('should delete a team and return the result when the team is deleted successfully', async () => {
|
||||
const team = teams[0];
|
||||
|
||||
mockTeamService.deleteTeam.mockResolvedValueOnce(E.right(true));
|
||||
|
||||
const result = await adminService.deleteATeam(team.id);
|
||||
|
||||
expect(result).toEqual(E.right(true));
|
||||
expect(mockTeamService.deleteTeam).toHaveBeenCalledWith(team.id);
|
||||
});
|
||||
|
||||
test('should return the error when the team deletion fails', async () => {
|
||||
const team = teams[0];
|
||||
const errorMessage = 'error';
|
||||
|
||||
mockTeamService.deleteTeam.mockResolvedValueOnce(E.left(errorMessage));
|
||||
|
||||
const result = await adminService.deleteATeam(team.id);
|
||||
|
||||
expect(result).toEqual(E.left(errorMessage));
|
||||
expect(mockTeamService.deleteTeam).toHaveBeenCalledWith(team.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAdmins', () => {
|
||||
test('should return the list of admin users', async () => {
|
||||
const adminUsers = [];
|
||||
mockUserService.fetchAdminUsers.mockResolvedValueOnce(adminUsers);
|
||||
const result = await adminService.fetchAdmins();
|
||||
|
||||
expect(result).toEqual(adminUsers);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchUserInfo', () => {
|
||||
test('should return the user info when the user is found', async () => {
|
||||
const user = allUsers[0];
|
||||
mockUserService.findUserById.mockResolvedValueOnce(O.some(user));
|
||||
const result = await adminService.fetchUserInfo(user.uid);
|
||||
|
||||
expect(result).toEqual(E.right(user));
|
||||
});
|
||||
|
||||
test('should return USER_NOT_FOUND when the user is not found', async () => {
|
||||
const user = allUsers[0];
|
||||
mockUserService.findUserById.mockResolvedValueOnce(O.none);
|
||||
const result = await adminService.fetchUserInfo(user.uid);
|
||||
|
||||
expect(result).toEqual(E.left(USER_NOT_FOUND));
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUserAccount', () => {
|
||||
test('should return USER_NOT_FOUND when the user is not found', async () => {
|
||||
const user = allUsers[0];
|
||||
mockUserService.findUserById.mockResolvedValueOnce(O.none);
|
||||
const result = await adminService.removeUserAccount(user.uid);
|
||||
|
||||
expect(result).toEqual(E.left(USER_NOT_FOUND));
|
||||
});
|
||||
|
||||
test('should return USER_IS_ADMIN when the user is an admin', async () => {
|
||||
const user = allUsers[0];
|
||||
|
||||
mockUserService.findUserById.mockResolvedValueOnce(O.some(user));
|
||||
const result = await adminService.removeUserAccount(user.uid);
|
||||
|
||||
expect(result).toEqual(E.left(USER_IS_ADMIN));
|
||||
});
|
||||
|
||||
test('should remove the user account and return the result when the user is not an admin', async () => {
|
||||
const user = allUsers[1];
|
||||
|
||||
mockUserService.findUserById.mockResolvedValueOnce(O.some(user));
|
||||
mockUserService.deleteUserByUID.mockReturnValueOnce(TE.right(true));
|
||||
const result = await adminService.removeUserAccount(user.uid);
|
||||
|
||||
expect(result).toEqual(E.right(true));
|
||||
});
|
||||
|
||||
test('should return the error when the user account deletion fails', async () => {
|
||||
const user = allUsers[1];
|
||||
const errorMessage = 'error';
|
||||
|
||||
mockUserService.findUserById.mockResolvedValueOnce(O.some(user));
|
||||
mockUserService.deleteUserByUID.mockReturnValueOnce(
|
||||
TE.left(errorMessage),
|
||||
);
|
||||
const result = await adminService.removeUserAccount(user.uid);
|
||||
|
||||
expect(result).toEqual(E.left(errorMessage));
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeUserAdmin', () => {
|
||||
test('should make the user an admin and return true when the operation is successful', async () => {
|
||||
const user = allUsers[0];
|
||||
|
||||
mockUserService.makeAdmin.mockResolvedValueOnce(E.right(user));
|
||||
const result = await adminService.makeUserAdmin(user.uid);
|
||||
|
||||
expect(result).toEqual(E.right(true));
|
||||
});
|
||||
|
||||
test('should return the error when making the user an admin fails', async () => {
|
||||
const user = allUsers[0];
|
||||
|
||||
mockUserService.makeAdmin.mockResolvedValueOnce(E.left(USER_NOT_FOUND));
|
||||
const result = await adminService.makeUserAdmin(user.uid);
|
||||
|
||||
expect(result).toEqual(E.left(USER_NOT_FOUND));
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUserAsAdmin', () => {
|
||||
test('should return ONLY_ONE_ADMIN_ACCOUNT when there is only one admin account', async () => {
|
||||
const user = allUsers[0];
|
||||
|
||||
mockUserService.fetchAdminUsers.mockResolvedValueOnce([user]);
|
||||
const result = await adminService.removeUserAsAdmin(user.uid);
|
||||
|
||||
expect(result).toEqual(E.left(ONLY_ONE_ADMIN_ACCOUNT));
|
||||
});
|
||||
|
||||
test('should remove the user as an admin and return true when the operation is successful', async () => {
|
||||
const user = allUsers[0];
|
||||
|
||||
mockUserService.fetchAdminUsers.mockResolvedValueOnce(allUsers);
|
||||
mockUserService.removeUserAsAdmin.mockResolvedValueOnce(E.right(user));
|
||||
const result = await adminService.removeUserAsAdmin(user.uid);
|
||||
|
||||
expect(result).toEqual(E.right(true));
|
||||
});
|
||||
|
||||
test('should return the error when removing the user as an admin fails', async () => {
|
||||
const user = allUsers[0];
|
||||
|
||||
mockUserService.fetchAdminUsers.mockResolvedValueOnce(allUsers);
|
||||
mockUserService.removeUserAsAdmin.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
const result = await adminService.removeUserAsAdmin(user.uid);
|
||||
|
||||
expect(result).toEqual(E.left(USER_NOT_FOUND));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamInfo', () => {
|
||||
test('should return the team info when the team is found', async () => {
|
||||
const team = teams[0];
|
||||
mockTeamService.getTeamWithIDTE.mockReturnValue(TE.right(team));
|
||||
const result = await adminService.getTeamInfo(team.id);
|
||||
|
||||
expect(result).toEqual(E.right(team));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchInvitedUsers', () => {
|
||||
test('should resolve right and return an array of invited users', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
INVALID_EMAIL,
|
||||
ONLY_ONE_ADMIN_ACCOUNT,
|
||||
TEAM_INVITE_ALREADY_MEMBER,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
USER_ALREADY_INVITED,
|
||||
USER_IS_ADMIN,
|
||||
USER_NOT_FOUND,
|
||||
@@ -241,6 +240,7 @@ export class AdminService {
|
||||
teamID,
|
||||
user.value.uid,
|
||||
)();
|
||||
|
||||
if (E.isLeft(teamMember)) {
|
||||
const addedUser = await this.teamService.addMemberToTeamWithEmail(
|
||||
teamID,
|
||||
@@ -417,19 +417,4 @@ export class AdminService {
|
||||
if (E.isLeft(team)) return E.left(team.left);
|
||||
return E.right(team.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a team invite by ID
|
||||
* @param inviteID Team Invite ID
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async revokeTeamInviteByID(inviteID: string) {
|
||||
const teamInvite = await this.teamInvitationService.revokeInvitation(
|
||||
inviteID,
|
||||
);
|
||||
|
||||
if (E.isLeft(teamInvite)) return E.left(teamInvite.left);
|
||||
|
||||
return E.right(teamInvite.right);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
InternalServerErrorException,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
@@ -19,18 +19,12 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
||||
import {
|
||||
AuthProvider,
|
||||
authCookieHandler,
|
||||
authProviderCheck,
|
||||
throwHTTPErr,
|
||||
} from './helper';
|
||||
import { authCookieHandler, throwHTTPErr } from './helper';
|
||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
||||
import { GithubSSOGuard } from './guards/github-sso.guard';
|
||||
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'auth', version: '1' })
|
||||
@@ -45,9 +39,6 @@ export class AuthController {
|
||||
@Body() authData: SignInMagicDto,
|
||||
@Query('origin') origin: string,
|
||||
) {
|
||||
if (!authProviderCheck(AuthProvider.EMAIL))
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
|
||||
const deviceIdToken = await this.authService.signInMagicLink(
|
||||
authData.email,
|
||||
origin,
|
||||
|
||||
@@ -11,7 +11,6 @@ import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
import { GithubStrategy } from './strategies/github.strategy';
|
||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
||||
import { AuthProvider, authProviderCheck } from './helper';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -27,9 +26,9 @@ import { AuthProvider, authProviderCheck } from './helper';
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
RTJwtStrategy,
|
||||
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
|
||||
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
|
||||
...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
|
||||
GoogleStrategy,
|
||||
GithubStrategy,
|
||||
MicrosoftStrategy,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
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);
|
||||
}
|
||||
|
||||
export class GithubSSOGuard extends AuthGuard('github') {
|
||||
getAuthenticateOptions(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
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);
|
||||
}
|
||||
|
||||
export class GoogleSSOGuard extends AuthGuard('google') {
|
||||
getAuthenticateOptions(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
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);
|
||||
}
|
||||
|
||||
export class MicrosoftSSOGuard extends AuthGuard('microsoft') {
|
||||
getAuthenticateOptions(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ForbiddenException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AuthError } from 'src/types/AuthError';
|
||||
import { AuthTokens } from 'src/types/AuthTokens';
|
||||
import { Response } from 'express';
|
||||
import * as cookie from 'cookie';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { COOKIES_NOT_FOUND } from 'src/errors';
|
||||
|
||||
enum AuthTokenType {
|
||||
ACCESS_TOKEN = 'access_token',
|
||||
@@ -17,13 +16,6 @@ export enum Origin {
|
||||
APP = 'app',
|
||||
}
|
||||
|
||||
export enum AuthProvider {
|
||||
GOOGLE = 'GOOGLE',
|
||||
GITHUB = 'GITHUB',
|
||||
MICROSOFT = 'MICROSOFT',
|
||||
EMAIL = 'EMAIL',
|
||||
}
|
||||
|
||||
/**
|
||||
* This function allows throw to be used as an expression
|
||||
* @param errMessage Message present in the error message
|
||||
@@ -105,25 +97,3 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -22,30 +22,6 @@ export const AUTH_FAIL = 'auth/fail';
|
||||
*/
|
||||
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.
|
||||
* (FirebaseService)
|
||||
|
||||
@@ -5,14 +5,11 @@ import * as cookieParser from 'cookie-parser';
|
||||
import { VersioningType } from '@nestjs/common';
|
||||
import * as session from 'express-session';
|
||||
import { emitGQLSchemaFile } from './gql-schema';
|
||||
import { checkEnvironmentAuthProvider } from './utils';
|
||||
|
||||
async function bootstrap() {
|
||||
console.log(`Running in production: ${process.env.PRODUCTION}`);
|
||||
console.log(`Port: ${process.env.PORT}`);
|
||||
|
||||
checkEnvironmentAuthProvider();
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.use(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Team, TeamCollection as DBTeamCollection } from '@prisma/client';
|
||||
import { mock, mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import {
|
||||
Team,
|
||||
TeamCollection as DBTeamCollection,
|
||||
TeamRequest as DBTeamRequest,
|
||||
} from '@prisma/client';
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import {
|
||||
TEAM_COLL_DEST_SAME,
|
||||
TEAM_COLL_INVALID_JSON,
|
||||
@@ -17,9 +21,8 @@ import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { TeamCollectionService } from './team-collection.service';
|
||||
import { TeamCollection } from './team-collection.model';
|
||||
import { TeamCollectionModule } from './team-collection.module';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
@@ -276,11 +279,188 @@ const childTeamCollectionList: DBTeamCollection[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const teamRequestList: DBTeamRequest[] = [
|
||||
{
|
||||
id: 'req1',
|
||||
collectionID: childTeamCollection.id,
|
||||
teamID: team.id,
|
||||
title: 'request 1',
|
||||
request: {},
|
||||
orderIndex: 1,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(mockPrisma);
|
||||
mockPubSub.publish.mockClear();
|
||||
});
|
||||
|
||||
describe('exportCollectionsToJSON', () => {
|
||||
test('should export collections to JSON string successfully for structure-1', async () => {
|
||||
/*
|
||||
Assuming collection and request structure is as follows:
|
||||
|
||||
rootTeamCollection
|
||||
|-> childTeamCollection
|
||||
| |-> <no request of child coll>
|
||||
|-> <no request of root coll>
|
||||
|
||||
*/
|
||||
|
||||
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
|
||||
rootTeamCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 1: Inside exportCollectionsToJSON.exportCollectionToJSONObject for Root Collection
|
||||
jest
|
||||
.spyOn(teamCollectionService, 'getCollection')
|
||||
.mockResolvedValueOnce(E.right(rootTeamCollection));
|
||||
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
|
||||
childTeamCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 2: Inside exportCollectionsToJSON.exportCollectionToJSONObject for Child Collection
|
||||
jest
|
||||
.spyOn(teamCollectionService, 'getCollection')
|
||||
.mockResolvedValueOnce(E.right(childTeamCollection));
|
||||
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]);
|
||||
mockPrisma.teamRequest.findMany.mockResolvedValueOnce([]);
|
||||
// return { name: childTeamCollection.title, folders: [], requests: [], };
|
||||
|
||||
// Back to RCV CALL 1
|
||||
mockPrisma.teamRequest.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const returnedValue: CollectionFolder = {
|
||||
name: rootTeamCollection.title,
|
||||
folders: [
|
||||
{
|
||||
name: childTeamCollection.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
},
|
||||
],
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const result = await teamCollectionService.exportCollectionsToJSON(team.id);
|
||||
expect(result).toEqualRight(JSON.stringify([returnedValue]));
|
||||
});
|
||||
|
||||
test('should export collections to JSON string successfully for structure-2', async () => {
|
||||
/*
|
||||
Assuming collection and request structure is as follows:
|
||||
|
||||
rootTeamCollection
|
||||
|-> childTeamCollection
|
||||
| |-> request1
|
||||
|-> <no request of root coll>
|
||||
|
||||
*/
|
||||
|
||||
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
|
||||
rootTeamCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 1: Inside exportCollectionsToJSON.exportCollectionToJSONObject for Root Collection
|
||||
jest
|
||||
.spyOn(teamCollectionService, 'getCollection')
|
||||
.mockResolvedValueOnce(E.right(rootTeamCollection));
|
||||
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
|
||||
childTeamCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 2: Inside exportCollectionsToJSON.exportCollectionToJSONObject for Child Collection
|
||||
jest
|
||||
.spyOn(teamCollectionService, 'getCollection')
|
||||
.mockResolvedValueOnce(E.right(childTeamCollection));
|
||||
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]);
|
||||
mockPrisma.teamRequest.findMany.mockResolvedValueOnce(teamRequestList);
|
||||
// return { name: childTeamCollection.title, folders: [], requests: teamRequestList, };
|
||||
|
||||
// Back to RCV CALL 1
|
||||
mockPrisma.teamRequest.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const returnedValue: CollectionFolder = {
|
||||
name: rootTeamCollection.title,
|
||||
folders: [
|
||||
{
|
||||
name: childTeamCollection.title,
|
||||
folders: [],
|
||||
requests: teamRequestList.map((req) => req.request),
|
||||
},
|
||||
],
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const result = await teamCollectionService.exportCollectionsToJSON(team.id);
|
||||
expect(result).toEqualRight(JSON.stringify([returnedValue]));
|
||||
});
|
||||
|
||||
test('should export collections to JSON string successfully for structure-3', async () => {
|
||||
/*
|
||||
Assuming collection and request structure is as follows:
|
||||
|
||||
rootTeamCollection
|
||||
|-> childTeamCollection
|
||||
| |-> child-request1
|
||||
|-> root-request1
|
||||
|
||||
*/
|
||||
|
||||
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
|
||||
rootTeamCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 1: Inside exportCollectionsToJSON.exportCollectionToJSONObject for Root Collection
|
||||
jest
|
||||
.spyOn(teamCollectionService, 'getCollection')
|
||||
.mockResolvedValueOnce(E.right(rootTeamCollection));
|
||||
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([
|
||||
childTeamCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 2: Inside exportCollectionsToJSON.exportCollectionToJSONObject for Child Collection
|
||||
jest
|
||||
.spyOn(teamCollectionService, 'getCollection')
|
||||
.mockResolvedValueOnce(E.right(childTeamCollection));
|
||||
mockPrisma.teamCollection.findMany.mockResolvedValueOnce([]);
|
||||
mockPrisma.teamRequest.findMany.mockResolvedValueOnce(teamRequestList);
|
||||
// return { name: childTeamCollection.title, folders: [], requests: teamRequestList, };
|
||||
|
||||
// Back to RCV CALL 1
|
||||
mockPrisma.teamRequest.findMany.mockResolvedValueOnce(teamRequestList);
|
||||
|
||||
const returnedValue: CollectionFolder = {
|
||||
name: rootTeamCollection.title,
|
||||
folders: [
|
||||
{
|
||||
name: childTeamCollection.title,
|
||||
folders: [],
|
||||
requests: teamRequestList.map((req) => req.request),
|
||||
},
|
||||
],
|
||||
requests: teamRequestList.map((req) => req.request),
|
||||
};
|
||||
|
||||
const result = await teamCollectionService.exportCollectionsToJSON(team.id);
|
||||
expect(result).toEqualRight(JSON.stringify([returnedValue]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCollectionCount', () => {
|
||||
test('should return the count of collections successfully', async () => {
|
||||
const count = 10;
|
||||
|
||||
mockPrisma.teamCollection.count.mockResolvedValueOnce(count);
|
||||
const result = await teamCollectionService.getCollectionCount(
|
||||
rootTeamCollection.id,
|
||||
);
|
||||
expect(result).toEqual(count);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamOfCollection', () => {
|
||||
test('should return the team of a collection successfully with valid collectionID', async () => {
|
||||
mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({
|
||||
@@ -1460,5 +1640,3 @@ describe('totalCollectionsInTeam', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
//ToDo: write test cases for exportCollectionsToJSON
|
||||
|
||||
@@ -306,8 +306,8 @@ describe('TeamEnvironmentsService', () => {
|
||||
);
|
||||
|
||||
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
|
||||
...teamEnvironment,
|
||||
id: 'newid',
|
||||
...teamEnvironment,
|
||||
});
|
||||
|
||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
||||
@@ -337,8 +337,8 @@ describe('TeamEnvironmentsService', () => {
|
||||
);
|
||||
|
||||
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
|
||||
...teamEnvironment,
|
||||
id: 'newid',
|
||||
...teamEnvironment,
|
||||
});
|
||||
|
||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
||||
|
||||
@@ -15,7 +15,7 @@ import { TeamService } from 'src/team/team.service';
|
||||
* This guard only allows user to execute the resolver
|
||||
* 1. If user is invitee, allow
|
||||
* 2. Or else, if user is team member, allow
|
||||
*
|
||||
*
|
||||
* TLDR: Allow if user is invitee or team member
|
||||
*/
|
||||
@Injectable()
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TEAM_REQ_NOT_FOUND,
|
||||
TEAM_REQ_REORDERING_FAILED,
|
||||
TEAM_COLL_NOT_FOUND,
|
||||
JSON_INVALID,
|
||||
} from 'src/errors';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
@@ -239,7 +240,7 @@ describe('deleteTeamRequest', () => {
|
||||
});
|
||||
|
||||
describe('createTeamRequest', () => {
|
||||
test('rejects for invalid collection id', async () => {
|
||||
test('should rejects for invalid collection id', async () => {
|
||||
mockTeamCollectionService.getTeamOfCollection.mockResolvedValue(
|
||||
E.left(TEAM_INVALID_COLL_ID),
|
||||
);
|
||||
@@ -255,7 +256,42 @@ describe('createTeamRequest', () => {
|
||||
expect(mockPrisma.teamRequest.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('resolves for valid collection id', async () => {
|
||||
test('should rejects for invalid team ID', async () => {
|
||||
mockTeamCollectionService.getTeamOfCollection.mockResolvedValue(
|
||||
E.right(team),
|
||||
);
|
||||
|
||||
const response = await teamRequestService.createTeamRequest(
|
||||
'testcoll',
|
||||
'invalidteamid',
|
||||
'Test Request',
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(response).toEqualLeft(TEAM_INVALID_ID);
|
||||
expect(mockPrisma.teamRequest.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should reject for invalid request body', async () => {
|
||||
mockTeamCollectionService.getTeamOfCollection.mockResolvedValue(
|
||||
E.right(team),
|
||||
);
|
||||
teamRequestService.getRequestsCountInCollection = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(0);
|
||||
|
||||
const response = await teamRequestService.createTeamRequest(
|
||||
'testcoll',
|
||||
team.id,
|
||||
'Test Request',
|
||||
'invalidjson',
|
||||
);
|
||||
|
||||
expect(response).toEqualLeft(JSON_INVALID);
|
||||
expect(mockPrisma.teamRequest.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should resolves and create team request', async () => {
|
||||
const dbRequest = dbTeamRequests[0];
|
||||
const teamRequest = teamRequests[0];
|
||||
|
||||
@@ -536,6 +572,52 @@ describe('findRequestAndNextRequest', () => {
|
||||
|
||||
expect(result).resolves.toEqualLeft(TEAM_REQ_NOT_FOUND);
|
||||
});
|
||||
test('should resolve left if the next request and given destCollId are different', () => {
|
||||
const args: MoveTeamRequestArgs = {
|
||||
srcCollID: teamRequests[0].collectionID,
|
||||
destCollID: 'different_coll_id',
|
||||
requestID: teamRequests[0].id,
|
||||
nextRequestID: teamRequests[4].id,
|
||||
};
|
||||
|
||||
mockPrisma.teamRequest.findFirst
|
||||
.mockResolvedValueOnce(dbTeamRequests[0])
|
||||
.mockResolvedValueOnce(dbTeamRequests[4]);
|
||||
|
||||
const result = teamRequestService.findRequestAndNextRequest(
|
||||
args.srcCollID,
|
||||
args.requestID,
|
||||
args.destCollID,
|
||||
args.nextRequestID,
|
||||
);
|
||||
|
||||
expect(result).resolves.toEqualLeft(TEAM_REQ_INVALID_TARGET_COLL_ID);
|
||||
});
|
||||
test('should resolve left if the request and the next request are from different teams', async () => {
|
||||
const args: MoveTeamRequestArgs = {
|
||||
srcCollID: teamRequests[0].collectionID,
|
||||
destCollID: teamRequests[4].collectionID,
|
||||
requestID: teamRequests[0].id,
|
||||
nextRequestID: teamRequests[4].id,
|
||||
};
|
||||
|
||||
const request = {
|
||||
...dbTeamRequests[0],
|
||||
teamID: 'different_team_id',
|
||||
};
|
||||
mockPrisma.teamRequest.findFirst
|
||||
.mockResolvedValueOnce(request)
|
||||
.mockResolvedValueOnce(dbTeamRequests[4]);
|
||||
|
||||
const result = await teamRequestService.findRequestAndNextRequest(
|
||||
args.srcCollID,
|
||||
args.requestID,
|
||||
args.destCollID,
|
||||
args.nextRequestID,
|
||||
);
|
||||
|
||||
expect(result).toEqualLeft(TEAM_REQ_INVALID_TARGET_COLL_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveRequest', () => {
|
||||
@@ -725,13 +807,12 @@ describe('totalRequestsInATeam', () => {
|
||||
});
|
||||
expect(result).toEqual(0);
|
||||
});
|
||||
});
|
||||
describe('getTeamRequestsCount', () => {
|
||||
test('should return count of all Team Collections in the organization', async () => {
|
||||
mockPrisma.teamRequest.count.mockResolvedValueOnce(10);
|
||||
|
||||
describe('getTeamRequestsCount', () => {
|
||||
test('should return count of all Team Collections in the organization', async () => {
|
||||
mockPrisma.teamRequest.count.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await teamRequestService.getTeamRequestsCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
const result = await teamRequestService.getTeamRequestsCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserCollection } from '@prisma/client';
|
||||
import { UserCollection, UserRequest as DbUserRequest } from '@prisma/client';
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import {
|
||||
USER_COLL_DEST_SAME,
|
||||
@@ -11,12 +11,17 @@ import {
|
||||
USER_COLL_SHORT_TITLE,
|
||||
USER_COLL_ALREADY_ROOT,
|
||||
USER_NOT_OWNER,
|
||||
USER_NOT_FOUND,
|
||||
USER_COLL_INVALID_JSON,
|
||||
} from 'src/errors';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { ReqType } from 'src/types/RequestTypes';
|
||||
import { UserCollectionService } from './user-collection.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
import { UserCollectionExportJSONData } from './user-collections.model';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
@@ -341,11 +346,485 @@ const rootGQLGQLUserCollectionList: UserCollection[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const userRESTRequestList: DbUserRequest[] = [
|
||||
{
|
||||
id: '123',
|
||||
collectionID: rootRESTUserCollection.id,
|
||||
userUid: user.uid,
|
||||
title: 'Request 1',
|
||||
request: {},
|
||||
type: ReqType.REST,
|
||||
orderIndex: 1,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(mockPrisma);
|
||||
mockPubSub.publish.mockClear();
|
||||
});
|
||||
|
||||
describe('importCollectionsFromJSON', () => {
|
||||
test('should resolve left for invalid JSON string', async () => {
|
||||
const result = await userCollectionService.importCollectionsFromJSON(
|
||||
'invalidJSONString',
|
||||
user.uid,
|
||||
rootRESTUserCollection.id,
|
||||
ReqType.REST,
|
||||
);
|
||||
|
||||
expect(result).toEqual(E.left(USER_COLL_INVALID_JSON));
|
||||
});
|
||||
test('should resolve left if JSON string is not an array', async () => {
|
||||
const result = await userCollectionService.importCollectionsFromJSON(
|
||||
JSON.stringify({}),
|
||||
user.uid,
|
||||
rootRESTUserCollection.id,
|
||||
ReqType.REST,
|
||||
);
|
||||
|
||||
expect(result).toEqual(E.left(USER_COLL_INVALID_JSON));
|
||||
});
|
||||
test('should resolve left if destCollectionID is invalid', async () => {
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.left(USER_COLL_NOT_FOUND));
|
||||
|
||||
const result = await userCollectionService.importCollectionsFromJSON(
|
||||
JSON.stringify([]),
|
||||
user.uid,
|
||||
'invalidID',
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqual(E.left(USER_COLL_NOT_FOUND));
|
||||
});
|
||||
test('should resolve left if destCollectionID is not owned by this user', async () => {
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(rootRESTUserCollection));
|
||||
|
||||
const result = await userCollectionService.importCollectionsFromJSON(
|
||||
JSON.stringify([]),
|
||||
'anotherUserUid',
|
||||
rootRESTUserCollection.id,
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqual(E.left(USER_NOT_OWNER));
|
||||
});
|
||||
test('should resolve left if destCollection type miss match', async () => {
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(rootRESTUserCollection));
|
||||
|
||||
const result = await userCollectionService.importCollectionsFromJSON(
|
||||
JSON.stringify([]),
|
||||
user.uid,
|
||||
rootRESTUserCollection.id,
|
||||
ReqType.GQL,
|
||||
);
|
||||
expect(result).toEqual(E.left(USER_COLL_NOT_SAME_TYPE));
|
||||
});
|
||||
test('should resolve right for valid JSON and destCollectionID provided', async () => {
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(rootRESTUserCollection));
|
||||
|
||||
// private getChildCollectionsCount function call
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
mockPrisma.$transaction.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await userCollectionService.importCollectionsFromJSON(
|
||||
JSON.stringify([]),
|
||||
user.uid,
|
||||
rootRESTUserCollection.id,
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqual(E.right(true));
|
||||
});
|
||||
test('should resolve right for importing in root directory (destCollectionID == null)', async () => {
|
||||
// private getChildCollectionsCount function call
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
mockPrisma.$transaction.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await userCollectionService.importCollectionsFromJSON(
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'collection-name',
|
||||
folders: [],
|
||||
requests: [{ name: 'request-name' }],
|
||||
},
|
||||
]),
|
||||
user.uid,
|
||||
null,
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqual(E.right(true));
|
||||
});
|
||||
test('should resolve right and publish event', async () => {
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(rootRESTUserCollection));
|
||||
|
||||
// private getChildCollectionsCount function call
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
mockPrisma.$transaction.mockResolvedValueOnce([{}]);
|
||||
|
||||
const result = await userCollectionService.importCollectionsFromJSON(
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'collection-name',
|
||||
folders: [],
|
||||
requests: [{ name: 'request-name' }],
|
||||
},
|
||||
]),
|
||||
user.uid,
|
||||
rootRESTUserCollection.id,
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqual(E.right(true));
|
||||
expect(mockPubSub.publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportUserCollectionsToJSON', () => {
|
||||
test('should return a list of user collections successfully for valid collectionID input and structure - 1', async () => {
|
||||
/*
|
||||
Assuming collection and request structure is as follows:
|
||||
|
||||
rootTeamCollection (id: 1 [exporting this collection])
|
||||
|-> childTeamCollection
|
||||
| |-> <no request of root coll>
|
||||
|-> <no request of root coll>
|
||||
*/
|
||||
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
|
||||
childRESTUserCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 1: exportUserCollectionToJSONObject
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(childRESTUserCollection));
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([]);
|
||||
mockPrisma.userRequest.findMany.mockResolvedValueOnce([]);
|
||||
const returnFromCallee: CollectionFolder = {
|
||||
id: childRESTUserCollection.id,
|
||||
name: childRESTUserCollection.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
};
|
||||
|
||||
// Back to exportUserCollectionsToJSON
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(rootRESTUserCollection));
|
||||
mockPrisma.userRequest.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const returnedValue: UserCollectionExportJSONData = {
|
||||
exportedCollection: JSON.stringify({
|
||||
id: rootRESTUserCollection.id,
|
||||
name: rootRESTUserCollection.title,
|
||||
folders: [returnFromCallee],
|
||||
requests: [],
|
||||
}),
|
||||
collectionType: ReqType.REST,
|
||||
};
|
||||
|
||||
const result = await userCollectionService.exportUserCollectionsToJSON(
|
||||
user.uid,
|
||||
rootRESTUserCollection.id,
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqualRight(returnedValue);
|
||||
});
|
||||
|
||||
test('should return a list of user collections successfully for valid collectionID input and structure - 2', async () => {
|
||||
/*
|
||||
Assuming collection and request structure is as follows:
|
||||
|
||||
rootTeamCollection (id: 1 [exporting this collection])
|
||||
|-> childTeamCollection
|
||||
| |-> request1
|
||||
|-> <no request of root coll>
|
||||
*/
|
||||
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
|
||||
childRESTUserCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 1: exportUserCollectionToJSONObject
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(childRESTUserCollection));
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([]);
|
||||
mockPrisma.userRequest.findMany.mockResolvedValueOnce(userRESTRequestList);
|
||||
const returnFromCallee: CollectionFolder = {
|
||||
id: childRESTUserCollection.id,
|
||||
name: childRESTUserCollection.title,
|
||||
folders: [],
|
||||
requests: userRESTRequestList.map((r) => {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.title,
|
||||
...(r.request as Record<string, unknown>),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Back to exportUserCollectionsToJSON
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(rootRESTUserCollection));
|
||||
mockPrisma.userRequest.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const returnedValue: UserCollectionExportJSONData = {
|
||||
exportedCollection: JSON.stringify({
|
||||
id: rootRESTUserCollection.id,
|
||||
name: rootRESTUserCollection.title,
|
||||
folders: [returnFromCallee],
|
||||
requests: [],
|
||||
}),
|
||||
collectionType: ReqType.REST,
|
||||
};
|
||||
|
||||
const result = await userCollectionService.exportUserCollectionsToJSON(
|
||||
user.uid,
|
||||
rootRESTUserCollection.id,
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqualRight(returnedValue);
|
||||
});
|
||||
test('should return a list of user collections successfully for valid collectionID input and structure - 3', async () => {
|
||||
/*
|
||||
Assuming collection and request structure is as follows:
|
||||
|
||||
rootTeamCollection (id: 1 [exporting this collection])
|
||||
|-> childTeamCollection
|
||||
| |-> request1
|
||||
|-> request2
|
||||
*/
|
||||
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
|
||||
childRESTUserCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 1: exportUserCollectionToJSONObject
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(childRESTUserCollection));
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([]);
|
||||
mockPrisma.userRequest.findMany.mockResolvedValueOnce(userRESTRequestList);
|
||||
const returnFromCallee: CollectionFolder = {
|
||||
id: childRESTUserCollection.id,
|
||||
name: childRESTUserCollection.title,
|
||||
folders: [],
|
||||
requests: userRESTRequestList.map((r) => {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.title,
|
||||
...(r.request as Record<string, unknown>),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Back to exportUserCollectionsToJSON
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(rootRESTUserCollection));
|
||||
mockPrisma.userRequest.findMany.mockResolvedValueOnce(userRESTRequestList);
|
||||
|
||||
const returnedValue: UserCollectionExportJSONData = {
|
||||
exportedCollection: JSON.stringify({
|
||||
id: rootRESTUserCollection.id,
|
||||
name: rootRESTUserCollection.title,
|
||||
folders: [returnFromCallee],
|
||||
requests: userRESTRequestList.map((x) => {
|
||||
return {
|
||||
id: x.id,
|
||||
name: x.title,
|
||||
...(x.request as Record<string, unknown>), // type casting x.request of type Prisma.JSONValue to an object to enable spread
|
||||
};
|
||||
}),
|
||||
}),
|
||||
collectionType: ReqType.REST,
|
||||
};
|
||||
|
||||
const result = await userCollectionService.exportUserCollectionsToJSON(
|
||||
user.uid,
|
||||
rootRESTUserCollection.id,
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqualRight(returnedValue);
|
||||
});
|
||||
test('should return a list of user collections successfully for collectionID == null', async () => {
|
||||
/*
|
||||
Assuming collection and request structure is as follows:
|
||||
|
||||
rootTeamCollection (id: 1 [exporting this collection])
|
||||
|-> childTeamCollection
|
||||
| |-> request1
|
||||
|-> request2
|
||||
*/
|
||||
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
|
||||
childRESTUserCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 1: exportUserCollectionToJSONObject
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.right(childRESTUserCollection));
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([]);
|
||||
mockPrisma.userRequest.findMany.mockResolvedValueOnce(userRESTRequestList);
|
||||
const returnFromCallee: CollectionFolder = {
|
||||
id: childRESTUserCollection.id,
|
||||
name: childRESTUserCollection.title,
|
||||
folders: [],
|
||||
requests: userRESTRequestList.map((r) => {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.title,
|
||||
...(r.request as Record<string, unknown>),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Back to exportUserCollectionsToJSON
|
||||
|
||||
const returnedValue: UserCollectionExportJSONData = {
|
||||
exportedCollection: JSON.stringify([returnFromCallee]),
|
||||
collectionType: ReqType.REST,
|
||||
};
|
||||
|
||||
const result = await userCollectionService.exportUserCollectionsToJSON(
|
||||
user.uid,
|
||||
null,
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqualRight(returnedValue);
|
||||
});
|
||||
test('should return USER_COLL_NOT_FOUND if collectionID or its child not found in DB', async () => {
|
||||
/*
|
||||
Assuming collection and request structure is as follows:
|
||||
|
||||
rootTeamCollection (id: 1 [exporting this collection])
|
||||
|-> childTeamCollection
|
||||
| |-> request1 <NOT FOUND IN DATABASE>
|
||||
|-> request2
|
||||
*/
|
||||
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([
|
||||
childRESTUserCollection,
|
||||
]);
|
||||
|
||||
// RCV CALL 1: exportUserCollectionToJSONObject
|
||||
jest
|
||||
.spyOn(userCollectionService, 'getUserCollection')
|
||||
.mockResolvedValueOnce(E.left(USER_COLL_NOT_FOUND));
|
||||
|
||||
// Back to exportUserCollectionsToJSON
|
||||
|
||||
const result = await userCollectionService.exportUserCollectionsToJSON(
|
||||
user.uid,
|
||||
null,
|
||||
ReqType.REST,
|
||||
);
|
||||
expect(result).toEqualLeft(USER_COLL_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserOfCollection', () => {
|
||||
test('should return a user successfully with valid collectionID', async () => {
|
||||
mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...rootRESTUserCollection,
|
||||
user: user,
|
||||
} as any);
|
||||
|
||||
const result = await userCollectionService.getUserOfCollection(
|
||||
rootRESTUserCollection.id,
|
||||
);
|
||||
expect(result).toEqualRight(user);
|
||||
});
|
||||
test('should return null with invalid collectionID', async () => {
|
||||
mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValue('error');
|
||||
|
||||
const result = await userCollectionService.getUserOfCollection('invalidId');
|
||||
expect(result).toEqualLeft(USER_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserChildCollections', () => {
|
||||
test('should return a list of child collections successfully with valid collectionID and userID', async () => {
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce(
|
||||
childRESTUserCollectionList,
|
||||
);
|
||||
|
||||
const result = await userCollectionService.getUserChildCollections(
|
||||
user,
|
||||
rootRESTUserCollection.id,
|
||||
null,
|
||||
10,
|
||||
ReqType.REST,
|
||||
);
|
||||
|
||||
expect(result).toEqual(childRESTUserCollectionList);
|
||||
expect(mockPrisma.userCollection.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userUid: user.uid,
|
||||
parentID: rootRESTUserCollection.id,
|
||||
type: ReqType.REST,
|
||||
},
|
||||
take: 10,
|
||||
skip: 0,
|
||||
cursor: undefined,
|
||||
});
|
||||
});
|
||||
test('should return an empty list if no child collections found', async () => {
|
||||
mockPrisma.userCollection.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await userCollectionService.getUserChildCollections(
|
||||
user,
|
||||
rootRESTUserCollection.id,
|
||||
null,
|
||||
10,
|
||||
ReqType.REST,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockPrisma.userCollection.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userUid: user.uid,
|
||||
parentID: rootRESTUserCollection.id,
|
||||
type: ReqType.REST,
|
||||
},
|
||||
take: 10,
|
||||
skip: 0,
|
||||
cursor: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCollectionCount', () => {
|
||||
test('should return the count of collections', async () => {
|
||||
const collectionID = 'collection123';
|
||||
const count = 5;
|
||||
|
||||
mockPrisma.userCollection.count.mockResolvedValueOnce(count);
|
||||
|
||||
const result = await userCollectionService.getCollectionCount(collectionID);
|
||||
|
||||
expect(result).toEqual(count);
|
||||
expect(mockPrisma.userCollection.count).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrisma.userCollection.count).toHaveBeenCalledWith({
|
||||
where: { parentID: collectionID },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParentOfUserCollection', () => {
|
||||
test('should return a user-collection successfully with valid collectionID', async () => {
|
||||
mockPrisma.userCollection.findUnique.mockResolvedValueOnce({
|
||||
|
||||
@@ -140,13 +140,15 @@ describe('UserHistoryService', () => {
|
||||
});
|
||||
describe('createUserHistory', () => {
|
||||
test('Should resolve right and create a REST request to users history and return a `UserHistory` object', async () => {
|
||||
const executedOn = new Date();
|
||||
|
||||
mockPrisma.userHistory.create.mockResolvedValueOnce({
|
||||
userUid: 'abc',
|
||||
id: '1',
|
||||
request: [{}],
|
||||
responseMetadata: [{}],
|
||||
reqType: ReqType.REST,
|
||||
executedOn: new Date(),
|
||||
executedOn,
|
||||
isStarred: false,
|
||||
});
|
||||
|
||||
@@ -156,7 +158,7 @@ describe('UserHistoryService', () => {
|
||||
request: JSON.stringify([{}]),
|
||||
responseMetadata: JSON.stringify([{}]),
|
||||
reqType: ReqType.REST,
|
||||
executedOn: new Date(),
|
||||
executedOn,
|
||||
isStarred: false,
|
||||
};
|
||||
|
||||
@@ -170,13 +172,15 @@ describe('UserHistoryService', () => {
|
||||
).toEqualRight(userHistory);
|
||||
});
|
||||
test('Should resolve right and create a GQL request to users history and return a `UserHistory` object', async () => {
|
||||
const executedOn = new Date();
|
||||
|
||||
mockPrisma.userHistory.create.mockResolvedValueOnce({
|
||||
userUid: 'abc',
|
||||
id: '1',
|
||||
request: [{}],
|
||||
responseMetadata: [{}],
|
||||
reqType: ReqType.GQL,
|
||||
executedOn: new Date(),
|
||||
executedOn,
|
||||
isStarred: false,
|
||||
});
|
||||
|
||||
@@ -186,7 +190,7 @@ describe('UserHistoryService', () => {
|
||||
request: JSON.stringify([{}]),
|
||||
responseMetadata: JSON.stringify([{}]),
|
||||
reqType: ReqType.GQL,
|
||||
executedOn: new Date(),
|
||||
executedOn,
|
||||
isStarred: false,
|
||||
};
|
||||
|
||||
@@ -210,13 +214,15 @@ describe('UserHistoryService', () => {
|
||||
).toEqualLeft(USER_HISTORY_INVALID_REQ_TYPE);
|
||||
});
|
||||
test('Should create a GQL request to users history and publish a created subscription', async () => {
|
||||
const executedOn = new Date();
|
||||
|
||||
mockPrisma.userHistory.create.mockResolvedValueOnce({
|
||||
userUid: 'abc',
|
||||
id: '1',
|
||||
request: [{}],
|
||||
responseMetadata: [{}],
|
||||
reqType: ReqType.GQL,
|
||||
executedOn: new Date(),
|
||||
executedOn,
|
||||
isStarred: false,
|
||||
});
|
||||
|
||||
@@ -226,7 +232,7 @@ describe('UserHistoryService', () => {
|
||||
request: JSON.stringify([{}]),
|
||||
responseMetadata: JSON.stringify([{}]),
|
||||
reqType: ReqType.GQL,
|
||||
executedOn: new Date(),
|
||||
executedOn,
|
||||
isStarred: false,
|
||||
};
|
||||
|
||||
@@ -243,13 +249,15 @@ describe('UserHistoryService', () => {
|
||||
);
|
||||
});
|
||||
test('Should create a REST request to users history and publish a created subscription', async () => {
|
||||
const executedOn = new Date();
|
||||
|
||||
mockPrisma.userHistory.create.mockResolvedValueOnce({
|
||||
userUid: 'abc',
|
||||
id: '1',
|
||||
request: [{}],
|
||||
responseMetadata: [{}],
|
||||
reqType: ReqType.REST,
|
||||
executedOn: new Date(),
|
||||
executedOn,
|
||||
isStarred: false,
|
||||
});
|
||||
|
||||
@@ -259,7 +267,7 @@ describe('UserHistoryService', () => {
|
||||
request: JSON.stringify([{}]),
|
||||
responseMetadata: JSON.stringify([{}]),
|
||||
reqType: ReqType.REST,
|
||||
executedOn: new Date(),
|
||||
executedOn,
|
||||
isStarred: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import {
|
||||
JSON_INVALID,
|
||||
USER_COLLECTION_NOT_FOUND,
|
||||
USER_COLL_NOT_FOUND,
|
||||
USER_REQUEST_INVALID_TYPE,
|
||||
USER_REQUEST_NOT_FOUND,
|
||||
USER_REQUEST_REORDERING_FAILED,
|
||||
} from 'src/errors';
|
||||
@@ -373,6 +376,101 @@ describe('UserRequestService', () => {
|
||||
|
||||
expect(result).resolves.toEqualLeft(JSON_INVALID);
|
||||
});
|
||||
test('Should resolve left for invalid collection ID', () => {
|
||||
const args: CreateUserRequestArgs = {
|
||||
collectionID: 'invalid-collection-id',
|
||||
title: userRequests[0].title,
|
||||
request: userRequests[0].request,
|
||||
type: userRequests[0].type,
|
||||
};
|
||||
|
||||
mockPrisma.userRequest.count.mockResolvedValue(
|
||||
dbUserRequests[0].orderIndex - 1,
|
||||
);
|
||||
mockUserCollectionService.getUserCollection.mockResolvedValue(
|
||||
E.left(USER_COLL_NOT_FOUND),
|
||||
);
|
||||
|
||||
const result = userRequestService.createRequest(
|
||||
args.collectionID,
|
||||
args.title,
|
||||
args.request,
|
||||
args.type,
|
||||
user,
|
||||
);
|
||||
|
||||
expect(result).resolves.toEqualLeft(USER_COLL_NOT_FOUND);
|
||||
});
|
||||
test('Should resolve left for wrong collection ID (using other users collection ID)', () => {
|
||||
const args: CreateUserRequestArgs = {
|
||||
collectionID: userRequests[0].collectionID,
|
||||
title: userRequests[0].title,
|
||||
request: userRequests[0].request,
|
||||
type: userRequests[0].type,
|
||||
};
|
||||
|
||||
mockPrisma.userRequest.count.mockResolvedValue(
|
||||
dbUserRequests[0].orderIndex - 1,
|
||||
);
|
||||
mockUserCollectionService.getUserCollection.mockResolvedValue(
|
||||
E.right({ type: userRequests[0].type, userUid: 'another-user' } as any),
|
||||
);
|
||||
|
||||
const result = userRequestService.createRequest(
|
||||
args.collectionID,
|
||||
args.title,
|
||||
args.request,
|
||||
args.type,
|
||||
user,
|
||||
);
|
||||
|
||||
expect(result).resolves.toEqualLeft(USER_COLLECTION_NOT_FOUND);
|
||||
});
|
||||
test('Should resolve left for collection type and request type miss match', () => {
|
||||
const args: CreateUserRequestArgs = {
|
||||
collectionID: userRequests[0].collectionID,
|
||||
title: userRequests[0].title,
|
||||
request: userRequests[0].request,
|
||||
type: userRequests[0].type,
|
||||
};
|
||||
|
||||
mockUserCollectionService.getUserCollection.mockResolvedValue(
|
||||
E.right({ type: 'invalid-type', userUid: user.uid } as any),
|
||||
);
|
||||
|
||||
const result = userRequestService.createRequest(
|
||||
args.collectionID,
|
||||
args.title,
|
||||
args.request,
|
||||
args.type,
|
||||
user,
|
||||
);
|
||||
|
||||
expect(result).resolves.toEqualLeft(USER_REQUEST_INVALID_TYPE);
|
||||
});
|
||||
test('Should resolve left if DB request type and parameter type is different', () => {
|
||||
const args: CreateUserRequestArgs = {
|
||||
collectionID: userRequests[0].collectionID,
|
||||
title: userRequests[0].title,
|
||||
request: userRequests[0].request,
|
||||
type: userRequests[0].type,
|
||||
};
|
||||
|
||||
mockPrisma.userRequest.count.mockResolvedValue(
|
||||
dbUserRequests[0].orderIndex - 1,
|
||||
);
|
||||
mockPrisma.userRequest.create.mockResolvedValue(dbUserRequests[0]);
|
||||
|
||||
const result = userRequestService.createRequest(
|
||||
args.collectionID,
|
||||
args.title,
|
||||
args.request,
|
||||
ReqType.GQL,
|
||||
user,
|
||||
);
|
||||
|
||||
expect(result).resolves.toEqualLeft(USER_REQUEST_INVALID_TYPE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRequest', () => {
|
||||
|
||||
@@ -9,8 +9,7 @@ import * as E from 'fp-ts/Either';
|
||||
import * as A from 'fp-ts/Array';
|
||||
import { TeamMemberRole } from './team/team.model';
|
||||
import { User } from './user/user.model';
|
||||
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';
|
||||
import { JSON_INVALID } from './errors';
|
||||
|
||||
/**
|
||||
* A workaround to throw an exception in an expression.
|
||||
@@ -153,31 +152,3 @@ export function isValidLength(title: string, length: number) {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
@apply after:backface-hidden;
|
||||
@apply selection:bg-accentDark;
|
||||
@apply selection:text-accentContrast;
|
||||
@apply overscroll-none;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"cancel": "Cancel",
|
||||
"choose_file": "Choose a file",
|
||||
"clear": "Clear",
|
||||
"clear_history": "Clear All History",
|
||||
"clear_all": "Clear all",
|
||||
"close": "Close",
|
||||
"connect": "Connect",
|
||||
@@ -151,11 +150,6 @@
|
||||
"save_unsaved_tab": "Do you want to save changes made in this tab?",
|
||||
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
|
||||
},
|
||||
"context_menu": {
|
||||
"set_environment_variable": "Set as variable",
|
||||
"add_parameter": "Add to parameter",
|
||||
"open_link_in_new_tab": "Open link in new tab"
|
||||
},
|
||||
"count": {
|
||||
"header": "Header {count}",
|
||||
"message": "Message {count}",
|
||||
@@ -179,7 +173,6 @@
|
||||
"folder": "Folder is empty",
|
||||
"headers": "This request does not have any headers",
|
||||
"history": "History is empty",
|
||||
"history_suggestions": "History does not have any matching entries",
|
||||
"invites": "Invite list is empty",
|
||||
"members": "Team is empty",
|
||||
"parameters": "This request does not have any parameters",
|
||||
@@ -200,23 +193,16 @@
|
||||
"created": "Environment created",
|
||||
"deleted": "Environment deletion",
|
||||
"edit": "Edit Environment",
|
||||
"global": "Global",
|
||||
"invalid_name": "Please provide a name for the environment",
|
||||
"my_environments": "My Environments",
|
||||
"name": "Name",
|
||||
"nested_overflow": "nested environment variables are limited to 10 levels",
|
||||
"new": "New Environment",
|
||||
"no_environment": "No environment",
|
||||
"no_environment_description": "No environments were selected. Choose what to do with the following variables.",
|
||||
"replace_with_variable": "Replace with variable",
|
||||
"scope": "Scope",
|
||||
"select": "Select environment",
|
||||
"set_as_environment": "Set as environment",
|
||||
"team_environments": "Team Environments",
|
||||
"title": "Environments",
|
||||
"updated": "Environment updated",
|
||||
"value": "Value",
|
||||
"variable": "Variable",
|
||||
"variable_list": "Variable List"
|
||||
},
|
||||
"error": {
|
||||
@@ -596,11 +582,6 @@
|
||||
"log": "Log",
|
||||
"url": "URL"
|
||||
},
|
||||
"spotlight": {
|
||||
"section": {
|
||||
"user": "User"
|
||||
}
|
||||
},
|
||||
"sse": {
|
||||
"event_type": "Event type",
|
||||
"log": "Log",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"edit": "編輯",
|
||||
"filter": "篩選回應",
|
||||
"go_back": "返回",
|
||||
"go_forward": "Go forward",
|
||||
"go_forward": "向前",
|
||||
"group_by": "分組方式",
|
||||
"label": "標籤",
|
||||
"learn_more": "瞭解更多",
|
||||
@@ -117,37 +117,37 @@
|
||||
"username": "使用者名稱"
|
||||
},
|
||||
"collection": {
|
||||
"created": "組合已建立",
|
||||
"different_parent": "Cannot reorder collection with different parent",
|
||||
"edit": "編輯組合",
|
||||
"invalid_name": "請提供有效的組合名稱",
|
||||
"invalid_root_move": "Collection already in the root",
|
||||
"moved": "Moved Successfully",
|
||||
"my_collections": "我的組合",
|
||||
"name": "我的新組合",
|
||||
"name_length_insufficient": "組合名稱至少要有 3 個字元。",
|
||||
"new": "建立組合",
|
||||
"order_changed": "Collection Order Updated",
|
||||
"renamed": "組合已重新命名",
|
||||
"created": "集合已建立",
|
||||
"different_parent": "無法為父集合不同的集合重新排序",
|
||||
"edit": "編輯集合",
|
||||
"invalid_name": "請提供有效的集合名稱",
|
||||
"invalid_root_move": "集合已在根目錄",
|
||||
"moved": "移動成功",
|
||||
"my_collections": "我的集合",
|
||||
"name": "我的新集合",
|
||||
"name_length_insufficient": "集合名稱至少要有 3 個字元。",
|
||||
"new": "建立集合",
|
||||
"order_changed": "集合順序已更新",
|
||||
"renamed": "集合已重新命名",
|
||||
"request_in_use": "請求正在使用中",
|
||||
"save_as": "另存為",
|
||||
"select": "選擇一個組合",
|
||||
"select": "選擇一個集合",
|
||||
"select_location": "選擇位置",
|
||||
"select_team": "選擇一個團隊",
|
||||
"team_collections": "團隊組合"
|
||||
"team_collections": "團隊集合"
|
||||
},
|
||||
"confirm": {
|
||||
"exit_team": "您確定要離開此團隊嗎?",
|
||||
"logout": "您確定要登出嗎?",
|
||||
"remove_collection": "您確定要永久刪除該組合嗎?",
|
||||
"remove_collection": "您確定要永久刪除該集合嗎?",
|
||||
"remove_environment": "您確定要永久刪除該環境嗎?",
|
||||
"remove_folder": "您確定要永久刪除該資料夾嗎?",
|
||||
"remove_history": "您確定要永久刪除全部歷史記錄嗎?",
|
||||
"remove_request": "您確定要永久刪除該請求嗎?",
|
||||
"remove_team": "您確定要刪除該團隊嗎?",
|
||||
"remove_telemetry": "您確定要退出遙測服務嗎?",
|
||||
"request_change": "您確定要捨棄當前請求嗎?未儲存的變更將遺失。",
|
||||
"save_unsaved_tab": "Do you want to save changes made in this tab?",
|
||||
"request_change": "您確定要捨棄目前的請求嗎?未儲存的變更將遺失。",
|
||||
"save_unsaved_tab": "您要儲存在此分頁做出的改動嗎?",
|
||||
"sync": "您想從雲端恢復您的工作區嗎?這將丟棄您的本地進度。"
|
||||
},
|
||||
"count": {
|
||||
@@ -160,13 +160,13 @@
|
||||
},
|
||||
"documentation": {
|
||||
"generate": "產生文件",
|
||||
"generate_message": "匯入 Hoppscotch 組合以隨時隨地產生 API 文件。"
|
||||
"generate_message": "匯入 Hoppscotch 集合以隨時隨地產生 API 文件。"
|
||||
},
|
||||
"empty": {
|
||||
"authorization": "該請求沒有使用任何授權",
|
||||
"body": "該請求沒有任何請求主體",
|
||||
"collection": "組合為空",
|
||||
"collections": "組合為空",
|
||||
"collection": "集合為空",
|
||||
"collections": "集合為空",
|
||||
"documentation": "連線到 GraphQL 端點以檢視文件",
|
||||
"endpoint": "端點不能留空",
|
||||
"environments": "環境為空",
|
||||
@@ -209,7 +209,7 @@
|
||||
"browser_support_sse": "此瀏覽器似乎不支援 SSE。",
|
||||
"check_console_details": "檢查控制台日誌以獲悉詳情",
|
||||
"curl_invalid_format": "cURL 格式不正確",
|
||||
"danger_zone": "Danger zone",
|
||||
"danger_zone": "危險地帶",
|
||||
"delete_account": "您的帳號目前為這些團隊的擁有者:",
|
||||
"delete_account_description": "您在刪除帳號前必須先將您自己從團隊中移除、轉移擁有權,或是刪除團隊。",
|
||||
"empty_req_name": "空請求名稱",
|
||||
@@ -277,38 +277,38 @@
|
||||
"tests": "編寫測試指令碼以自動除錯。"
|
||||
},
|
||||
"hide": {
|
||||
"collection": "隱藏組合面板",
|
||||
"collection": "隱藏集合面板",
|
||||
"more": "隱藏更多",
|
||||
"preview": "隱藏預覽",
|
||||
"sidebar": "隱藏側邊欄"
|
||||
},
|
||||
"import": {
|
||||
"collections": "匯入組合",
|
||||
"collections": "匯入集合",
|
||||
"curl": "匯入 cURL",
|
||||
"failed": "匯入失敗",
|
||||
"from_gist": "從 Gist 匯入",
|
||||
"from_gist_description": "從 Gist 網址匯入",
|
||||
"from_insomnia": "從 Insomnia 匯入",
|
||||
"from_insomnia_description": "從 Insomnia 組合匯入",
|
||||
"from_insomnia_description": "從 Insomnia 集合匯入",
|
||||
"from_json": "從 Hoppscotch 匯入",
|
||||
"from_json_description": "從 Hoppscotch 組合檔匯入",
|
||||
"from_my_collections": "從我的組合匯入",
|
||||
"from_my_collections_description": "從我的組合檔匯入",
|
||||
"from_json_description": "從 Hoppscotch 集合檔匯入",
|
||||
"from_my_collections": "從我的集合匯入",
|
||||
"from_my_collections_description": "從我的集合檔匯入",
|
||||
"from_openapi": "從 OpenAPI 匯入",
|
||||
"from_openapi_description": "從 OpenAPI 規格檔 (YML/JSON) 匯入",
|
||||
"from_postman": "從 Postman 匯入",
|
||||
"from_postman_description": "從 Postman 組合匯入",
|
||||
"from_postman_description": "從 Postman 集合匯入",
|
||||
"from_url": "從網址匯入",
|
||||
"gist_url": "輸入 Gist 網址",
|
||||
"import_from_url_invalid_fetch": "無法從網址取得資料",
|
||||
"import_from_url_invalid_file_format": "匯入組合時發生錯誤",
|
||||
"import_from_url_invalid_file_format": "匯入集合時發生錯誤",
|
||||
"import_from_url_invalid_type": "不支援此類型。可接受的值為 'hoppscotch'、'openapi'、'postman'、'insomnia'",
|
||||
"import_from_url_success": "已匯入組合",
|
||||
"json_description": "從 Hoppscotch 組合 JSON 檔匯入組合",
|
||||
"import_from_url_success": "已匯入集合",
|
||||
"json_description": "從 Hoppscotch 集合 JSON 檔匯入集合",
|
||||
"title": "匯入"
|
||||
},
|
||||
"layout": {
|
||||
"collapse_collection": "隱藏或顯示組合",
|
||||
"collapse_collection": "隱藏或顯示集合",
|
||||
"collapse_sidebar": "隱藏或顯示側邊欄",
|
||||
"column": "垂直版面",
|
||||
"name": "配置",
|
||||
@@ -316,8 +316,8 @@
|
||||
"zen_mode": "專注模式"
|
||||
},
|
||||
"modal": {
|
||||
"close_unsaved_tab": "You have unsaved changes",
|
||||
"collections": "組合",
|
||||
"close_unsaved_tab": "您有未儲存的改動",
|
||||
"collections": "集合",
|
||||
"confirm": "確認",
|
||||
"edit_request": "編輯請求",
|
||||
"import_export": "匯入/匯出"
|
||||
@@ -374,9 +374,9 @@
|
||||
"email_verification_mail": "已將驗證信寄送至您的電子郵件地址。請點擊信中連結以驗證您的電子郵件地址。",
|
||||
"no_permission": "您沒有權限執行此操作。",
|
||||
"owner": "擁有者",
|
||||
"owner_description": "擁有者可以新增、編輯和刪除請求、組合和團隊成員。",
|
||||
"owner_description": "擁有者可以新增、編輯和刪除請求、集合和團隊成員。",
|
||||
"roles": "角色",
|
||||
"roles_description": "角色用來控制對共用組合的存取權。",
|
||||
"roles_description": "角色用來控制對共用集合的存取權。",
|
||||
"updated": "已更新個人檔案",
|
||||
"viewer": "檢視者",
|
||||
"viewer_description": "檢視者只能檢視和使用請求。"
|
||||
@@ -396,8 +396,8 @@
|
||||
"text": "文字"
|
||||
},
|
||||
"copy_link": "複製連結",
|
||||
"different_collection": "Cannot reorder requests from different collections",
|
||||
"duplicated": "Request duplicated",
|
||||
"different_collection": "無法重新排列來自不同集合的請求",
|
||||
"duplicated": "已複製請求",
|
||||
"duration": "持續時間",
|
||||
"enter_curl": "輸入 cURL",
|
||||
"generate_code": "產生程式碼",
|
||||
@@ -405,10 +405,10 @@
|
||||
"header_list": "請求標頭列表",
|
||||
"invalid_name": "請提供請求名稱",
|
||||
"method": "方法",
|
||||
"moved": "Request moved",
|
||||
"moved": "已移動請求",
|
||||
"name": "請求名稱",
|
||||
"new": "新請求",
|
||||
"order_changed": "Request Order Updated",
|
||||
"order_changed": "已更新請求順序",
|
||||
"override": "覆寫",
|
||||
"override_help": "在標頭設置 <kbd>Content-Type</kbd>",
|
||||
"overriden": "已覆寫",
|
||||
@@ -432,7 +432,7 @@
|
||||
"view_my_links": "檢視我的連結"
|
||||
},
|
||||
"response": {
|
||||
"audio": "Audio",
|
||||
"audio": "音訊",
|
||||
"body": "回應本體",
|
||||
"filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)",
|
||||
"headers": "回應標頭",
|
||||
@@ -446,7 +446,7 @@
|
||||
"status": "狀態",
|
||||
"time": "時間",
|
||||
"title": "回應",
|
||||
"video": "Video",
|
||||
"video": "視訊",
|
||||
"waiting_for_connection": "等待連線",
|
||||
"xml": "XML"
|
||||
},
|
||||
@@ -494,7 +494,7 @@
|
||||
"short_codes_description": "我們為您打造的快捷碼。",
|
||||
"sidebar_on_left": "左側邊欄",
|
||||
"sync": "同步",
|
||||
"sync_collections": "組合",
|
||||
"sync_collections": "集合",
|
||||
"sync_description": "這些設定會同步到雲端。",
|
||||
"sync_environments": "環境",
|
||||
"sync_history": "歷史",
|
||||
@@ -551,7 +551,7 @@
|
||||
"previous_method": "選擇上一個方法",
|
||||
"put_method": "選擇 PUT 方法",
|
||||
"reset_request": "重置請求",
|
||||
"save_to_collections": "儲存到組合",
|
||||
"save_to_collections": "儲存到集合",
|
||||
"send_request": "傳送請求",
|
||||
"title": "請求"
|
||||
},
|
||||
@@ -570,7 +570,7 @@
|
||||
},
|
||||
"show": {
|
||||
"code": "顯示程式碼",
|
||||
"collection": "顯示組合面板",
|
||||
"collection": "顯示集合面板",
|
||||
"more": "顯示更多",
|
||||
"sidebar": "顯示側邊欄"
|
||||
},
|
||||
@@ -639,9 +639,9 @@
|
||||
"tab": {
|
||||
"authorization": "授權",
|
||||
"body": "請求本體",
|
||||
"collections": "組合",
|
||||
"collections": "集合",
|
||||
"documentation": "幫助文件",
|
||||
"environments": "Environments",
|
||||
"environments": "環境",
|
||||
"headers": "請求標頭",
|
||||
"history": "歷史記錄",
|
||||
"mqtt": "MQTT",
|
||||
@@ -666,7 +666,7 @@
|
||||
"email_do_not_match": "電子信箱與您的帳號資料不一致。請聯絡您的團隊擁有者。",
|
||||
"exit": "退出團隊",
|
||||
"exit_disabled": "團隊擁有者無法退出團隊",
|
||||
"invalid_coll_id": "Invalid collection ID",
|
||||
"invalid_coll_id": "集合 ID 無效",
|
||||
"invalid_email_format": "電子信箱格式無效",
|
||||
"invalid_id": "團隊 ID 無效。請聯絡您的團隊擁有者。",
|
||||
"invalid_invite_link": "邀請連結無效",
|
||||
@@ -690,21 +690,21 @@
|
||||
"member_removed": "使用者已移除",
|
||||
"member_role_updated": "使用者角色已更新",
|
||||
"members": "成員",
|
||||
"more_members": "+{count} more",
|
||||
"more_members": "還有 {count} 位",
|
||||
"name_length_insufficient": "團隊名稱至少為 6 個字元",
|
||||
"name_updated": "團隊名稱已更新",
|
||||
"new": "新團隊",
|
||||
"new_created": "已建立新團隊",
|
||||
"new_name": "我的新團隊",
|
||||
"no_access": "您沒有編輯組合的許可權",
|
||||
"no_access": "您沒有編輯集合的許可權",
|
||||
"no_invite_found": "未找到邀請。請聯絡您的團隊擁有者。",
|
||||
"no_request_found": "Request not found.",
|
||||
"no_request_found": "找不到請求。",
|
||||
"not_found": "找不到團隊。請聯絡您的團隊擁有者。",
|
||||
"not_valid_viewer": "您不是一個有效的檢視者。請聯絡您的團隊擁有者。",
|
||||
"parent_coll_move": "Cannot move collection to a child collection",
|
||||
"parent_coll_move": "無法將集合移動至子集合",
|
||||
"pending_invites": "待定邀請",
|
||||
"permissions": "許可權",
|
||||
"same_target_destination": "Same target and destination",
|
||||
"same_target_destination": "目標和目的地相同",
|
||||
"saved": "團隊已儲存",
|
||||
"select_a_team": "選擇團隊",
|
||||
"title": "團隊",
|
||||
@@ -734,9 +734,9 @@
|
||||
"url": "網址"
|
||||
},
|
||||
"workspace": {
|
||||
"change": "Change workspace",
|
||||
"personal": "My Workspace",
|
||||
"team": "Team Workspace",
|
||||
"title": "Workspaces"
|
||||
"change": "切換工作區",
|
||||
"personal": "我的工作區",
|
||||
"team": "團隊工作區",
|
||||
"title": "工作區"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
"version": "2023.4.7",
|
||||
"scripts": {
|
||||
"dev": "pnpm exec npm-run-all -p -l dev:*",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"dev:vite": "vite",
|
||||
"dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml --watch dotenv_config_path=\"../../.env\"",
|
||||
"lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .",
|
||||
@@ -15,7 +13,6 @@
|
||||
"preview": "vite preview",
|
||||
"gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"",
|
||||
"postinstall": "pnpm run gql-codegen",
|
||||
"do-test": "pnpm run test",
|
||||
"do-lint": "pnpm run prod-lint",
|
||||
"do-typecheck": "pnpm run lint",
|
||||
"do-lintfix": "pnpm run lintfix"
|
||||
@@ -46,12 +43,11 @@
|
||||
"@urql/exchange-auth": "^0.1.7",
|
||||
"@urql/exchange-graphcache": "^4.4.3",
|
||||
"@vitejs/plugin-legacy": "^2.3.0",
|
||||
"@vueuse/core": "^8.9.4",
|
||||
"@vueuse/core": "^8.7.5",
|
||||
"@vueuse/head": "^0.7.9",
|
||||
"acorn-walk": "^8.2.0",
|
||||
"axios": "^0.21.4",
|
||||
"buffer": "^6.0.3",
|
||||
"dioc": "workspace:^",
|
||||
"esprima": "^4.0.1",
|
||||
"events": "^3.3.0",
|
||||
"fp-ts": "^2.12.1",
|
||||
@@ -67,7 +63,6 @@
|
||||
"jsonpath-plus": "^7.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lossless-json": "^2.0.8",
|
||||
"minisearch": "^6.1.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"paho-mqtt": "^1.1.0",
|
||||
"path": "^0.12.7",
|
||||
@@ -89,6 +84,7 @@
|
||||
"util": "^0.12.4",
|
||||
"uuid": "^8.3.2",
|
||||
"vue": "^3.2.25",
|
||||
"vue-github-button": "^3.0.3",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-pdf-embed": "^1.1.4",
|
||||
"vue-router": "^4.0.16",
|
||||
@@ -110,9 +106,8 @@
|
||||
"@graphql-codegen/typescript-urql-graphcache": "^2.3.1",
|
||||
"@graphql-codegen/urql-introspection": "^2.2.0",
|
||||
"@graphql-typed-document-node/core": "^3.1.1",
|
||||
"@iconify-json/lucide": "^1.1.109",
|
||||
"@iconify-json/lucide": "^1.1.40",
|
||||
"@intlify/vite-plugin-vue-i18n": "^7.0.0",
|
||||
"@relmify/jest-fp-ts": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.1.4",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
@@ -147,11 +142,10 @@
|
||||
"vite-plugin-html-config": "^1.0.10",
|
||||
"vite-plugin-inspect": "^0.7.4",
|
||||
"vite-plugin-pages": "^0.26.0",
|
||||
"vite-plugin-pages-sitemap": "^1.4.5",
|
||||
"vite-plugin-pages-sitemap": "^1.4.0",
|
||||
"vite-plugin-pwa": "^0.13.1",
|
||||
"vite-plugin-vue-layouts": "^0.7.0",
|
||||
"vite-plugin-windicss": "^1.8.8",
|
||||
"vitest": "^0.32.2",
|
||||
"vue-tsc": "^0.38.2",
|
||||
"windicss": "^3.5.6"
|
||||
}
|
||||
|
||||
11
packages/hoppscotch-common/src/components.d.ts
vendored
11
packages/hoppscotch-common/src/components.d.ts
vendored
@@ -9,24 +9,22 @@ declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
||||
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
|
||||
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
|
||||
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
|
||||
AppFooter: typeof import('./components/app/Footer.vue')['default']
|
||||
AppFuse: typeof import('./components/app/Fuse.vue')['default']
|
||||
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
|
||||
AppHeader: typeof import('./components/app/Header.vue')['default']
|
||||
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
|
||||
AppLogo: typeof import('./components/app/Logo.vue')['default']
|
||||
AppOptions: typeof import('./components/app/Options.vue')['default']
|
||||
AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default']
|
||||
AppPowerSearch: typeof import('./components/app/PowerSearch.vue')['default']
|
||||
AppPowerSearchEntry: typeof import('./components/app/PowerSearchEntry.vue')['default']
|
||||
AppShare: typeof import('./components/app/Share.vue')['default']
|
||||
AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default']
|
||||
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
|
||||
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
|
||||
AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
|
||||
AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default']
|
||||
AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default']
|
||||
AppSpotlightEntryGQLHistory: typeof import('./components/app/spotlight/entry/GQLHistory.vue')['default']
|
||||
AppSpotlightEntryRESTHistory: typeof import('./components/app/spotlight/entry/RESTHistory.vue')['default']
|
||||
AppSupport: typeof import('./components/app/Support.vue')['default']
|
||||
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
|
||||
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default']
|
||||
@@ -55,7 +53,6 @@ declare module '@vue/runtime-core' {
|
||||
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
|
||||
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
|
||||
Environments: typeof import('./components/environments/index.vue')['default']
|
||||
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
|
||||
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
|
||||
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
|
||||
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
|
||||
@@ -134,7 +131,6 @@ declare module '@vue/runtime-core' {
|
||||
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
||||
@@ -172,7 +168,6 @@ declare module '@vue/runtime-core' {
|
||||
SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default']
|
||||
SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default']
|
||||
SmartPicture: typeof import('./../../hoppscotch-ui/src/components/smart/Picture.vue')['default']
|
||||
SmartPlaceholder: typeof import('./../../hoppscotch-ui/src/components/smart/Placeholder.vue')['default']
|
||||
SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
|
||||
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
|
||||
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default']
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<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>
|
||||
@@ -152,7 +152,7 @@
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'app.shortcuts'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>/</kbd>`"
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>K</kbd>`"
|
||||
:icon="IconZap"
|
||||
@click="invokeAction('flyouts.keybinds.toggle')"
|
||||
/>
|
||||
|
||||
70
packages/hoppscotch-common/src/components/app/Fuse.vue
Normal file
70
packages/hoppscotch-common/src/components/app/Fuse.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<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>
|
||||
@@ -15,21 +15,16 @@
|
||||
:label="t('app.name')"
|
||||
to="/"
|
||||
/>
|
||||
<!-- <AppGitHubStarButton class="mt-1.5 transition" /> -->
|
||||
</div>
|
||||
<div class="inline-flex items-center justify-center flex-1 space-x-2">
|
||||
<button
|
||||
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"
|
||||
<div class="inline-flex items-center space-x-2">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t('app.search')} <kbd>/</kbd>`"
|
||||
:icon="IconSearch"
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@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
|
||||
v-if="showInstallButton"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
@@ -47,8 +42,6 @@
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@click="invokeAction('modals.support.toggle')"
|
||||
/>
|
||||
</div>
|
||||
<div class="inline-flex items-center justify-end flex-1 space-x-2">
|
||||
<div
|
||||
v-if="currentUser === null"
|
||||
class="inline-flex items-center space-x-2"
|
||||
@@ -243,17 +236,17 @@ import IconDownload from "~icons/lucide/download"
|
||||
import IconUploadCloud from "~icons/lucide/upload-cloud"
|
||||
import IconUserPlus from "~icons/lucide/user-plus"
|
||||
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
||||
import IconSearch from "~icons/lucide/search"
|
||||
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
|
||||
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
||||
import { platform } from "~/platform"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { defineActionHandler, invokeAction } from "@helpers/actions"
|
||||
import { invokeAction } from "@helpers/actions"
|
||||
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
|
||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||
import { onLoggedIn } from "~/composables/auth"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { getPlatformSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -381,12 +374,4 @@ const profile = ref<any | null>(null)
|
||||
const settings = ref<any | null>(null)
|
||||
const logout = ref<any | null>(null)
|
||||
const accountActions = ref<any | null>(null)
|
||||
|
||||
defineActionHandler(
|
||||
"user.login",
|
||||
() => {
|
||||
invokeAction("modals.login.toggle")
|
||||
},
|
||||
computed(() => !currentUser.value)
|
||||
)
|
||||
</script>
|
||||
|
||||
122
packages/hoppscotch-common/src/components/app/PowerSearch.vue
Normal file
122
packages/hoppscotch-common/src/components/app/PowerSearch.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<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>
|
||||
@@ -0,0 +1,68 @@
|
||||
<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>
|
||||
@@ -14,17 +14,10 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-dividerLight">
|
||||
<HoppSmartPlaceholder
|
||||
v-if="isEmpty(shortcutsResults)"
|
||||
:text="`${t('state.nothing_found')} ‟${filterText}”`"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
</HoppSmartPlaceholder>
|
||||
<div v-if="filterText" class="flex flex-col divide-y divide-dividerLight">
|
||||
<details
|
||||
v-for="(sectionResults, sectionTitle) in shortcutsResults"
|
||||
v-else
|
||||
:key="`section-${sectionTitle}`"
|
||||
v-for="(map, mapIndex) in searchResults"
|
||||
:key="`map-${mapIndex}`"
|
||||
class="flex flex-col"
|
||||
open
|
||||
>
|
||||
@@ -35,28 +28,63 @@
|
||||
<span
|
||||
class="font-semibold truncate capitalize-first text-secondaryDark"
|
||||
>
|
||||
{{ sectionTitle }}
|
||||
{{ t(map.item.section) }}
|
||||
</span>
|
||||
</summary>
|
||||
<div class="flex flex-col px-6 pb-4 space-y-2">
|
||||
<AppShortcutsEntry
|
||||
v-for="(shortcut, index) in sectionResults"
|
||||
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" />
|
||||
<span class="my-2 text-center flex flex-col">
|
||||
{{ 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
|
||||
v-for="(map, mapIndex) in mappings"
|
||||
:key="`map-${mapIndex}`"
|
||||
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.section) }}
|
||||
</span>
|
||||
</summary>
|
||||
<div class="flex flex-col px-6 pb-4 space-y-2">
|
||||
<AppShortcutsEntry
|
||||
v-for="(shortcut, shortcutIndex) in map.shortcuts"
|
||||
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
|
||||
:shortcut="shortcut"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartSlideOver>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, ref } from "vue"
|
||||
import { ShortcutDef, getShortcuts } from "~/helpers/shortcuts"
|
||||
import MiniSearch from "minisearch"
|
||||
import { computed, ref } from "vue"
|
||||
import Fuse from "fuse.js"
|
||||
import mappings from "~/helpers/shortcuts"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { groupBy, isEmpty } from "lodash-es"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -64,33 +92,15 @@ defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const minisearch = new MiniSearch({
|
||||
fields: ["label", "keys", "section"],
|
||||
idField: "label",
|
||||
storeFields: ["label", "keys", "section"],
|
||||
searchOptions: {
|
||||
fuzzy: true,
|
||||
prefix: true,
|
||||
},
|
||||
})
|
||||
const options = {
|
||||
keys: ["shortcuts.label"],
|
||||
}
|
||||
|
||||
const shortcuts = getShortcuts(t)
|
||||
|
||||
onBeforeMount(() => {
|
||||
minisearch.addAllAsync(shortcuts)
|
||||
})
|
||||
const fuse = new Fuse(mappings, options)
|
||||
|
||||
const filterText = ref("")
|
||||
|
||||
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 searchResults = computed(() => fuse.search(filterText.value))
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex items-center py-1">
|
||||
<span class="flex flex-1 mr-4">
|
||||
{{ shortcut.label }}
|
||||
{{ t(shortcut.label) }}
|
||||
</span>
|
||||
<kbd
|
||||
v-for="(key, index) in shortcut.keys"
|
||||
@@ -14,9 +14,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ShortcutDef } from "~/helpers/shortcuts"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps<{
|
||||
shortcut: ShortcutDef
|
||||
shortcut: {
|
||||
label: string
|
||||
keys: string[]
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -22,11 +22,10 @@
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">/</kbd>
|
||||
<kbd class="shortcut-key">K</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">K</kbd>
|
||||
<kbd class="shortcut-key">/</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">?</kbd>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
<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>
|
||||
@@ -1,30 +0,0 @@
|
||||
<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>
|
||||
@@ -1,43 +0,0 @@
|
||||
<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>
|
||||
@@ -1,238 +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-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>
|
||||
@@ -193,7 +193,7 @@ import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { PropType, ref, computed, watch } from "vue"
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
@@ -209,36 +209,67 @@ type FolderType = "collection" | "folder"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id: string
|
||||
parentID?: string | null
|
||||
data: HoppCollection<HoppRESTRequest> | TeamCollection
|
||||
/**
|
||||
* Collection component can be used for both collections and folders.
|
||||
* folderType is used to determine which one it is.
|
||||
*/
|
||||
collectionsType: CollectionType
|
||||
folderType: FolderType
|
||||
isOpen: boolean
|
||||
isSelected?: boolean | null
|
||||
exportLoading?: boolean
|
||||
hasNoTeamAccess?: boolean
|
||||
collectionMoveLoading?: string[]
|
||||
isLastItem?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: "",
|
||||
parentID: null,
|
||||
collectionsType: "my-collections",
|
||||
folderType: "collection",
|
||||
isOpen: false,
|
||||
isSelected: false,
|
||||
exportLoading: false,
|
||||
hasNoTeamAccess: false,
|
||||
isLastItem: false,
|
||||
}
|
||||
)
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: true,
|
||||
},
|
||||
parentID: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
data: {
|
||||
type: Object as PropType<HoppCollection<HoppRESTRequest> | TeamCollection>,
|
||||
default: () => ({}),
|
||||
required: true,
|
||||
},
|
||||
collectionsType: {
|
||||
type: String as PropType<CollectionType>,
|
||||
default: "my-collections",
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* Collection component can be used for both collections and folders.
|
||||
* folderType is used to determine which one it is.
|
||||
*/
|
||||
folderType: {
|
||||
type: String as PropType<FolderType>,
|
||||
default: "collection",
|
||||
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<{
|
||||
(event: "toggle-children"): void
|
||||
@@ -417,13 +448,8 @@ const notSameDestination = computed(() => {
|
||||
})
|
||||
|
||||
const isCollLoading = computed(() => {
|
||||
const { collectionMoveLoading } = props
|
||||
if (
|
||||
collectionMoveLoading &&
|
||||
collectionMoveLoading.length > 0 &&
|
||||
props.data.id
|
||||
) {
|
||||
return collectionMoveLoading.includes(props.data.id)
|
||||
if (props.collectionMoveLoading.length > 0 && props.data.id) {
|
||||
return props.collectionMoveLoading.includes(props.data.id)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -243,33 +243,49 @@
|
||||
/>
|
||||
</template>
|
||||
<template #emptyNode="{ node }">
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="filterText.length !== 0 && filteredCollections.length === 0"
|
||||
:text="`${t('state.nothing_found')} ‟${filterText}”`"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
<HoppSmartPlaceholder
|
||||
v-else-if="node === null"
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
:alt="`${t('empty.collections')}`"
|
||||
:text="t('empty.collections')"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
:label="t('add.new')"
|
||||
filled
|
||||
outline
|
||||
@click="emit('display-modal-add')"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
<HoppSmartPlaceholder
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ filterText }}"
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="node === null">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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'"
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
:alt="`${t('empty.collections')}`"
|
||||
:text="t('empty.collections')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="t('add.new')"
|
||||
filled
|
||||
@@ -282,14 +298,21 @@
|
||||
})
|
||||
"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
<HoppSmartPlaceholder
|
||||
</div>
|
||||
<div
|
||||
v-else-if="node.data.type === 'folders'"
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
:alt="`${t('empty.folder')}`"
|
||||
:text="t('empty.folder')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<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.folder')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.folder") }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</SmartTree>
|
||||
</div>
|
||||
|
||||
@@ -262,53 +262,67 @@
|
||||
</template>
|
||||
<template #emptyNode="{ node }">
|
||||
<div v-if="node === null">
|
||||
<div @drop="(e) => e.stopPropagation()">
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
@drop="(e) => e.stopPropagation()"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
:alt="`${t('empty.collections')}`"
|
||||
:text="t('empty.collections')"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
v-if="hasNoTeamAccess"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
filled
|
||||
outline
|
||||
:title="t('team.no_access')"
|
||||
:label="t('action.new')"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-else
|
||||
:icon="IconPlus"
|
||||
:label="t('action.new')"
|
||||
filled
|
||||
outline
|
||||
@click="emit('display-modal-add')"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
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.collections") }}
|
||||
</span>
|
||||
<HoppButtonSecondary
|
||||
v-if="hasNoTeamAccess"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
filled
|
||||
outline
|
||||
:title="t('team.no_access')"
|
||||
:label="t('action.new')"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-else
|
||||
:icon="IconPlus"
|
||||
:label="t('action.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"
|
||||
@drop="(e) => e.stopPropagation()"
|
||||
>
|
||||
<HoppSmartPlaceholder
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
:alt="`${t('empty.collections')}`"
|
||||
:text="t('empty.collections')"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
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.collections") }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="node.data.type === 'folders'"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
@drop="(e) => e.stopPropagation()"
|
||||
>
|
||||
<HoppSmartPlaceholder
|
||||
<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.folder')}`"
|
||||
:text="t('empty.folder')"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.folder") }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</SmartTree>
|
||||
|
||||
@@ -171,14 +171,21 @@
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="
|
||||
collection.folders.length === 0 && collection.requests.length === 0
|
||||
"
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
:alt="`${t('empty.collection')}`"
|
||||
:text="t('empty.collection')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="t('add.new')"
|
||||
filled
|
||||
@@ -189,7 +196,7 @@
|
||||
})
|
||||
"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartConfirmModal
|
||||
|
||||
@@ -160,19 +160,25 @@
|
||||
@duplicate-request="emit('duplicate-request', $event)"
|
||||
@select="emit('select', $event)"
|
||||
/>
|
||||
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="
|
||||
folder.folders &&
|
||||
folder.folders.length === 0 &&
|
||||
folder.requests &&
|
||||
folder.requests.length === 0
|
||||
"
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
:alt="`${t('empty.folder')}`"
|
||||
:text="t('empty.folder')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<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.folder')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.folder") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartConfirmModal
|
||||
|
||||
@@ -60,27 +60,35 @@
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="collections.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
:alt="`${t('empty.collections')}`"
|
||||
:text="t('empty.collections')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="t('add.new')"
|
||||
filled
|
||||
outline
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
<HoppSmartPlaceholder
|
||||
</div>
|
||||
<div
|
||||
v-if="!(filteredCollections.length !== 0 || collections.length === 0)"
|
||||
:text="`${t('state.nothing_found')} ‟${filterText}”`"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ filterText }}"
|
||||
</span>
|
||||
</div>
|
||||
<CollectionsGraphqlAdd
|
||||
:show="showModalAdd"
|
||||
@hide-modal="displayModalAdd(false)"
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
<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>
|
||||
@@ -8,7 +8,7 @@
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${t('environment.select')}`"
|
||||
class="bg-transparent select-wrapper"
|
||||
class="bg-transparent border-b border-dividerLight select-wrapper"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
:icon="IconLayers"
|
||||
@@ -22,7 +22,6 @@
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
@@ -32,7 +31,6 @@
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<HoppSmartItem
|
||||
v-if="!isScopeSelector"
|
||||
:label="`${t('environment.no_environment')}`"
|
||||
:info-icon="
|
||||
selectedEnvironmentIndex.type === 'NO_ENV_SELECTED'
|
||||
@@ -49,21 +47,6 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
<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
|
||||
v-model="selectedEnvTab"
|
||||
styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary"
|
||||
@@ -78,25 +61,29 @@
|
||||
:key="`gen-${index}`"
|
||||
:icon="IconLayers"
|
||||
:label="gen.name"
|
||||
:info-icon="isEnvActive(index) ? IconCheck : undefined"
|
||||
:active-info-icon="isEnvActive(index)"
|
||||
:info-icon="index === selectedEnv.index ? IconCheck : undefined"
|
||||
:active-info-icon="index === selectedEnv.index"
|
||||
@click="
|
||||
() => {
|
||||
handleEnvironmentChange(index, {
|
||||
type: 'my-environment',
|
||||
environment: gen,
|
||||
})
|
||||
selectedEnvironmentIndex = { type: 'MY_ENV', index: index }
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="myEnvironments.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
:text="t('empty.environments')"
|
||||
class="flex flex-col items-center justify-center text-secondaryLight"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<img
|
||||
: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
|
||||
:id="'team-environments'"
|
||||
@@ -116,26 +103,36 @@
|
||||
:key="`gen-team-${index}`"
|
||||
:icon="IconLayers"
|
||||
:label="gen.environment.name"
|
||||
:info-icon="isEnvActive(gen.id) ? IconCheck : undefined"
|
||||
:active-info-icon="isEnvActive(gen.id)"
|
||||
:info-icon="
|
||||
gen.id === selectedEnv.teamEnvID ? IconCheck : undefined
|
||||
"
|
||||
:active-info-icon="gen.id === selectedEnv.teamEnvID"
|
||||
@click="
|
||||
() => {
|
||||
handleEnvironmentChange(index, {
|
||||
type: 'team-environment',
|
||||
environment: gen,
|
||||
})
|
||||
selectedEnvironmentIndex = {
|
||||
type: 'TEAM_ENV',
|
||||
teamEnvID: gen.id,
|
||||
teamID: gen.teamID,
|
||||
environment: gen.environment,
|
||||
}
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="teamEnvironmentList.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
:text="t('empty.environments')"
|
||||
class="flex flex-col items-center justify-center text-secondaryLight"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<img
|
||||
: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
|
||||
v-if="!teamListLoading && teamAdapterError"
|
||||
@@ -152,10 +149,9 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch } from "vue"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconLayers from "~icons/lucide/layers"
|
||||
import IconGlobe from "~icons/lucide/globe"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
@@ -173,31 +169,6 @@ import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
import { onLoggedIn } from "~/composables/auth"
|
||||
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 mdAndLarger = breakpoints.greater("md")
|
||||
@@ -212,39 +183,6 @@ const myEnvironments = useReadonlyStream(environments$, [])
|
||||
|
||||
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 teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false)
|
||||
const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null)
|
||||
@@ -279,152 +217,63 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const handleEnvironmentChange = (
|
||||
index: number,
|
||||
env?:
|
||||
| {
|
||||
type: "my-environment"
|
||||
environment: Environment
|
||||
}
|
||||
| {
|
||||
type: "team-environment"
|
||||
environment: TeamEnvironment
|
||||
}
|
||||
) => {
|
||||
if (props.isScopeSelector && env) {
|
||||
if (env.type === "my-environment") {
|
||||
emit("update:modelValue", {
|
||||
type: "my-environment",
|
||||
environment: env.environment,
|
||||
index,
|
||||
})
|
||||
} else if (env.type === "team-environment") {
|
||||
emit("update:modelValue", {
|
||||
type: "team-environment",
|
||||
environment: env.environment,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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",
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const selectedEnv = computed(() => {
|
||||
if (props.isScopeSelector) {
|
||||
if (props.modelValue?.type === "my-environment") {
|
||||
return {
|
||||
type: "MY_ENV",
|
||||
index: props.modelValue.index,
|
||||
name: props.modelValue.environment?.name,
|
||||
}
|
||||
} else if (props.modelValue?.type === "team-environment") {
|
||||
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: props.modelValue.environment.environment.name,
|
||||
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" }
|
||||
name: teamEnv.environment.name,
|
||||
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
|
||||
}
|
||||
} 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",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
return { type: "NO_ENV_SELECTED" }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -26,13 +26,6 @@
|
||||
:editing-variable-name="editingVariableName"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
/>
|
||||
<EnvironmentsAdd
|
||||
:show="showModalNew"
|
||||
:name="editingVariableName"
|
||||
:value="editingVariableValue"
|
||||
:position="position"
|
||||
@hide-modal="displayModalNew(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -168,18 +161,10 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const showModalNew = ref(false)
|
||||
const showModalDetails = ref(false)
|
||||
const action = ref<"new" | "edit">("edit")
|
||||
const editingEnvironmentIndex = ref<"Global" | null>(null)
|
||||
const editingVariableName = ref("")
|
||||
const editingVariableValue = ref("")
|
||||
|
||||
const position = ref({ top: 0, left: 0 })
|
||||
|
||||
const displayModalNew = (shouldDisplay: boolean) => {
|
||||
showModalNew.value = shouldDisplay
|
||||
}
|
||||
|
||||
const displayModalEdit = (shouldDisplay: boolean) => {
|
||||
action.value = "edit"
|
||||
@@ -248,10 +233,4 @@ watch(
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
defineActionHandler("modals.environment.add", ({ envName, variableName }) => {
|
||||
editingVariableName.value = envName
|
||||
editingVariableValue.value = variableName
|
||||
displayModalNew(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -79,19 +79,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="vars.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
:text="t('empty.environments')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
class="mb-4"
|
||||
@click="addEnvironmentVariable"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,12 +32,19 @@
|
||||
:environment="environment"
|
||||
@edit-environment="editEnvironment(index)"
|
||||
/>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="environments.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
:text="t('empty.environments')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
@@ -45,7 +52,7 @@
|
||||
class="mb-4"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<EnvironmentsMyDetails
|
||||
:show="showModalDetails"
|
||||
:action="action"
|
||||
|
||||
@@ -83,12 +83,19 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="vars.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
:text="t('empty.environments')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
v-if="isViewer"
|
||||
disabled
|
||||
@@ -103,7 +110,7 @@
|
||||
class="mb-4"
|
||||
@click="addEnvironmentVariable"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -43,12 +43,19 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="!loading && teamEnvironments.length === 0 && !adapterError"
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
:text="t('empty.environments')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
v-if="team === undefined || team.myRole === 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
@@ -67,7 +74,7 @@
|
||||
class="mb-4"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<div v-else-if="!loading">
|
||||
<EnvironmentsTeamsEnvironment
|
||||
v-for="(environment, index) in JSON.parse(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="flex" @click="openLogoutModal()">
|
||||
<div class="flex" @click="OpenLogoutModal()">
|
||||
<HoppSmartItem
|
||||
ref="logoutItem"
|
||||
:icon="IconLogOut"
|
||||
:label="`${t('auth.logout')}`"
|
||||
:outline="outline"
|
||||
:shortcut="shortcut"
|
||||
@click="openLogoutModal()"
|
||||
@click="OpenLogoutModal()"
|
||||
/>
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmLogout"
|
||||
@@ -23,7 +23,6 @@ import IconLogOut from "~icons/lucide/log-out"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { platform } from "~/platform"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
|
||||
defineProps({
|
||||
outline: {
|
||||
@@ -56,12 +55,8 @@ const logout = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const openLogoutModal = () => {
|
||||
const OpenLogoutModal = () => {
|
||||
emit("confirm-logout")
|
||||
confirmLogout.value = true
|
||||
}
|
||||
|
||||
defineActionHandler("user.logout", () => {
|
||||
openLogoutModal()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -114,12 +114,19 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="authType === 'none'"
|
||||
:src="`/images/states/${colorMode.value}/login.svg`"
|
||||
:alt="`${t('empty.authorization')}`"
|
||||
:text="t('empty.authorization')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
outline
|
||||
:label="t('app.documentation')"
|
||||
@@ -129,7 +136,7 @@
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<div v-else class="flex flex-1 border-b border-dividerLight">
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div v-if="authType === 'basic'">
|
||||
|
||||
@@ -289,12 +289,19 @@
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="workingHeaders.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_category.svg`"
|
||||
:alt="`${t('empty.headers')}`"
|
||||
:text="t('empty.headers')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
@@ -302,7 +309,7 @@
|
||||
class="mb-4"
|
||||
@click="addHeader"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-auto whitespace-nowrap">
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="responseString === 'loading'"
|
||||
:text="t('state.loading')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<template #icon>
|
||||
<HoppSmartSpinner class="my-4" />
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
<HoppSmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div v-else-if="responseString" class="flex flex-col flex-1">
|
||||
<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"
|
||||
|
||||
@@ -24,18 +24,25 @@
|
||||
:icon="IconBookOpen"
|
||||
:label="`${t('tab.documentation')}`"
|
||||
>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="
|
||||
queryFields.length === 0 &&
|
||||
mutationFields.length === 0 &&
|
||||
subscriptionFields.length === 0 &&
|
||||
graphqlTypes.length === 0
|
||||
"
|
||||
:src="`/images/states/${colorMode.value}/add_comment.svg`"
|
||||
:alt="`${t('empty.documentation')}`"
|
||||
:text="t('empty.documentation')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<img
|
||||
: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
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto bg-primary"
|
||||
@@ -165,13 +172,20 @@
|
||||
ref="schemaEditor"
|
||||
class="flex flex-col flex-1"
|
||||
></div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-else
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
:alt="`${t('empty.schema')}`"
|
||||
:text="t('empty.schema')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<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.schema')}`"
|
||||
/>
|
||||
<span class="mb-4 text-center">
|
||||
{{ t("empty.schema") }}
|
||||
</span>
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</template>
|
||||
|
||||
@@ -108,23 +108,31 @@
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="history.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/history.svg`"
|
||||
:alt="`${t('empty.history')}`"
|
||||
:text="t('empty.history')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<HoppSmartPlaceholder
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/history.svg`"
|
||||
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="
|
||||
Object.keys(filteredHistoryGroups).length === 0 ||
|
||||
filteredHistory.length === 0
|
||||
"
|
||||
:text="`${t('state.nothing_found')} ‟${filterText || filterSelection}”`"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
</template>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="mt-2 mb-4 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ filterText || filterSelection }}"
|
||||
</span>
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.clear')"
|
||||
outline
|
||||
@@ -135,7 +143,7 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="`${t('confirm.remove_history')}`"
|
||||
@@ -176,7 +184,6 @@ import {
|
||||
import HistoryRestCard from "./rest/Card.vue"
|
||||
import HistoryGraphqlCard from "./graphql/Card.vue"
|
||||
import { createNewTab } from "~/helpers/rest/tab"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
|
||||
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
|
||||
|
||||
@@ -330,8 +337,4 @@ const toggleStar = (entry: HistoryEntry) => {
|
||||
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
|
||||
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
|
||||
}
|
||||
|
||||
defineActionHandler("history.clear", () => {
|
||||
confirmRemove.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -113,12 +113,17 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="auth.authType === 'none'"
|
||||
:src="`/images/states/${colorMode.value}/login.svg`"
|
||||
:alt="`${t('empty.authorization')}`"
|
||||
:text="t('empty.authorization')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
outline
|
||||
:label="t('app.documentation')"
|
||||
@@ -128,7 +133,7 @@
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<div v-else class="flex flex-1 border-b border-dividerLight">
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div v-if="auth.authType === 'basic'">
|
||||
|
||||
@@ -102,12 +102,17 @@
|
||||
v-model="body"
|
||||
/>
|
||||
<HttpRawBody v-else-if="body.contentType !== null" v-model="body" />
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="body.contentType == null"
|
||||
:src="`/images/states/${colorMode.value}/upload_single_file.svg`"
|
||||
:alt="`${t('empty.body')}`"
|
||||
:text="t('empty.body')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
outline
|
||||
:label="`${t('app.documentation')}`"
|
||||
@@ -117,7 +122,7 @@
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -152,12 +152,17 @@
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="workingParams.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/upload_single_file.svg`"
|
||||
:alt="`${t('empty.body')}`"
|
||||
:text="t('empty.body')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
@@ -165,7 +170,7 @@
|
||||
class="mb-4"
|
||||
@click="addBodyParam"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -56,19 +56,20 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="
|
||||
!(
|
||||
filteredCodegenDefinitions.length !== 0 ||
|
||||
CodegenDefinitions.length === 0
|
||||
)
|
||||
"
|
||||
:text="`${t('state.nothing_found')} ‟${searchQuery}”`"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ searchQuery }}"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -202,12 +202,17 @@
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="workingHeaders.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_category.svg`"
|
||||
:alt="`${t('empty.headers')}`"
|
||||
:text="t('empty.headers')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
filled
|
||||
:label="`${t('add.new')}`"
|
||||
@@ -215,7 +220,7 @@
|
||||
class="mb-4"
|
||||
@click="addHeader"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -145,12 +145,18 @@
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<HoppSmartPlaceholder
|
||||
|
||||
<div
|
||||
v-if="workingParams.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_files.svg`"
|
||||
:alt="`${t('empty.parameters')}`"
|
||||
:text="t('empty.parameters')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="`${t('add.new')}`"
|
||||
:icon="IconPlus"
|
||||
@@ -158,7 +164,7 @@
|
||||
class="mb-4"
|
||||
@click="addParam"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
|
||||
@@ -47,14 +47,13 @@
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
||||
class="flex flex-1 overflow-auto transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
||||
>
|
||||
<SmartEnvInput
|
||||
v-model="tab.document.request.endpoint"
|
||||
:placeholder="`${t('request.url')}`"
|
||||
:auto-complete-source="userHistories"
|
||||
@enter="newSendRequest()"
|
||||
@paste="onPasteUrl($event)"
|
||||
@enter="newSendRequest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,7 +228,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
|
||||
import { useStreamSubscriber } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { refAutoReset, useVModel } from "@vueuse/core"
|
||||
import * as E from "fp-ts/Either"
|
||||
@@ -260,7 +259,6 @@ import IconSave from "~icons/lucide/save"
|
||||
import IconShare2 from "~icons/lucide/share-2"
|
||||
import { HoppRESTTab } from "~/helpers/rest/tab"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
|
||||
import { platform } from "~/platform"
|
||||
import { getCurrentStrategyID } from "~/helpers/network"
|
||||
|
||||
@@ -315,12 +313,6 @@ const clearAll = ref<any | null>(null)
|
||||
const copyRequestAction = 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 () => {
|
||||
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
|
||||
toast.error(`${t("empty.endpoint")}`)
|
||||
|
||||
@@ -11,29 +11,51 @@
|
||||
<HoppSmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="response.type === 'network_fail'"
|
||||
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
|
||||
:alt="`${t('error.network_fail')}`"
|
||||
:heading="t('error.network_fail')"
|
||||
:text="t('helpers.network_fail')"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<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" />
|
||||
</HoppSmartPlaceholder>
|
||||
<HoppSmartPlaceholder
|
||||
</div>
|
||||
<div
|
||||
v-if="response.type === 'script_fail'"
|
||||
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
|
||||
:alt="`${t('error.script_fail')}`"
|
||||
:label="t('error.script_fail')"
|
||||
:text="t('helpers.script_fail')"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<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
|
||||
class="mt-2 w-full px-4 py-2 overflow-auto font-mono text-red-400 whitespace-normal rounded bg-primaryLight"
|
||||
class="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.stack }}
|
||||
</div>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<div
|
||||
v-if="response.type === 'success' || response.type === 'fail'"
|
||||
class="flex items-center font-semibold text-tiny"
|
||||
|
||||
@@ -153,21 +153,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-else-if="testResults && testResults.scriptError"
|
||||
: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')"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<HoppSmartPlaceholder
|
||||
<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.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
|
||||
:src="`/images/states/${colorMode.value}/validation.svg`"
|
||||
:alt="`${t('empty.tests')}`"
|
||||
:heading="t('empty.tests')"
|
||||
:text="t('helpers.tests')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
outline
|
||||
:label="`${t('action.learn_more')}`"
|
||||
@@ -177,7 +197,7 @@
|
||||
reverse
|
||||
class="my-4"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<EnvironmentsMyDetails
|
||||
:show="showMyEnvironmentDetailsModal"
|
||||
action="new"
|
||||
|
||||
@@ -143,12 +143,19 @@
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="workingUrlEncodedParams.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_category.svg`"
|
||||
:alt="`${t('empty.body')}`"
|
||||
:text="t('empty.body')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
filled
|
||||
:label="`${t('add.new')}`"
|
||||
@@ -156,7 +163,7 @@
|
||||
class="mb-4"
|
||||
@click="addUrlEncodedParam"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,13 +11,20 @@
|
||||
<HoppSmartSpinner class="mb-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="!loading && myShortcodes.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_files.svg`"
|
||||
:alt="`${t('empty.shortcodes')}`"
|
||||
:text="t('empty.shortcodes')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<img
|
||||
: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
|
||||
class="hidden w-full border-t rounded-t bg-primaryLight lg:flex border-x border-dividerLight"
|
||||
|
||||
@@ -52,19 +52,20 @@
|
||||
"
|
||||
/>
|
||||
</HoppSmartLink>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="
|
||||
!(
|
||||
filteredAppLanguages.length !== 0 ||
|
||||
APP_LANGUAGES.length === 0
|
||||
)
|
||||
"
|
||||
:text="`${t('state.nothing_found')} ‟${searchQuery}”`"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ searchQuery }}"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,44 +1,19 @@
|
||||
<template>
|
||||
<div class="autocomplete-wrapper">
|
||||
<div class="absolute inset-0 flex flex-1 overflow-x-auto">
|
||||
<div
|
||||
class="relative flex items-center flex-1 flex-shrink-0 py-4 overflow-auto whitespace-nowrap"
|
||||
>
|
||||
<div class="absolute inset-0 flex flex-1">
|
||||
<div
|
||||
ref="editor"
|
||||
:placeholder="placeholder"
|
||||
class="flex flex-1"
|
||||
:class="styles"
|
||||
@keydown.enter.prevent="emit('enter', $event)"
|
||||
@keyup="emit('keyup', $event)"
|
||||
@click="emit('click', $event)"
|
||||
@keydown="handleKeystroke"
|
||||
@focusin="showSuggestionPopover = true"
|
||||
@keydown="emit('keydown', $event)"
|
||||
></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>
|
||||
</template>
|
||||
|
||||
@@ -60,9 +35,6 @@ import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironme
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
|
||||
import { platform } from "~/platform"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { onClickOutside, useDebounceFn } from "@vueuse/core"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -74,7 +46,6 @@ const props = withDefaults(
|
||||
selectTextOnMount?: boolean
|
||||
environmentHighlights?: boolean
|
||||
readonly?: boolean
|
||||
autoCompleteSource?: string[]
|
||||
}>(),
|
||||
{
|
||||
modelValue: "",
|
||||
@@ -84,7 +55,6 @@ const props = withDefaults(
|
||||
focus: false,
|
||||
readonly: false,
|
||||
environmentHighlights: true,
|
||||
autoCompleteSource: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -98,165 +68,12 @@ const emit = defineEmits<{
|
||||
(e: "click", ev: any): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const cachedValue = ref(props.modelValue)
|
||||
|
||||
const view = ref<EditorView>()
|
||||
|
||||
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(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
@@ -305,46 +122,8 @@ const envVars = computed(() =>
|
||||
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
|
||||
|
||||
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 = [
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
|
||||
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (props.readonly) {
|
||||
update.view.contentDOM.inputMode = "none"
|
||||
@@ -457,49 +236,3 @@ watch(editor, () => {
|
||||
}
|
||||
})
|
||||
</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>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
class="flex flex-col flex-1"
|
||||
>
|
||||
<SmartTreeBranch
|
||||
:root-nodes-length="rootNodes.data.length"
|
||||
:node-item="rootNode"
|
||||
:adapter="adapter as SmartTreeAdapter<T>"
|
||||
>
|
||||
|
||||
@@ -85,25 +85,19 @@ const props = defineProps<{
|
||||
* The node item that will be used to render the tree branch content
|
||||
*/
|
||||
nodeItem: TreeNode<T>
|
||||
/**
|
||||
* Total number of rootNode
|
||||
*/
|
||||
rootNodesLength?: number
|
||||
}>()
|
||||
|
||||
const CHILD_SLOT_NAME = "default"
|
||||
const t = useI18n()
|
||||
|
||||
const isOnlyRootChild = computed(() => props.rootNodesLength === 1)
|
||||
|
||||
/**
|
||||
* Marks whether the children on this branch were ever rendered
|
||||
* See the usage inside '<template>' for more info
|
||||
*/
|
||||
const childrenRendered = ref(isOnlyRootChild.value)
|
||||
const childrenRendered = ref(false)
|
||||
|
||||
const showChildren = ref(isOnlyRootChild.value)
|
||||
const isNodeOpen = ref(isOnlyRootChild.value)
|
||||
const showChildren = ref(false)
|
||||
const isNodeOpen = ref(false)
|
||||
|
||||
const highlightNode = ref(false)
|
||||
|
||||
|
||||
@@ -47,12 +47,19 @@
|
||||
"
|
||||
class="border rounded border-divider"
|
||||
>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="teamDetails.data.right.team.teamMembers === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_group.svg`"
|
||||
:alt="`${t('empty.members')}`"
|
||||
:text="t('empty.members')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:icon="IconUserPlus"
|
||||
:label="t('team.invite')"
|
||||
@@ -62,7 +69,7 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<div v-else class="divide-y divide-dividerLight">
|
||||
<div
|
||||
v-for="(member, index) in membersList"
|
||||
|
||||
@@ -98,14 +98,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="
|
||||
E.isRight(pendingInvites.data) &&
|
||||
pendingInvites.data.right.team.teamInvitations.length === 0
|
||||
"
|
||||
:text="t('empty.pending_invites')"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<span class="text-center">
|
||||
{{ t("empty.pending_invites") }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!pendingInvites.loading && E.isLeft(pendingInvites.data)"
|
||||
class="flex flex-col items-center p-4"
|
||||
@@ -218,18 +221,25 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="newInvites.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_group.svg`"
|
||||
:alt="`${t('empty.invites')}`"
|
||||
:text="`${t('empty.invites')}`"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="t('add.new')"
|
||||
filled
|
||||
@click="addNewInvitee"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="newInvites.length"
|
||||
|
||||
@@ -10,18 +10,25 @@
|
||||
<HoppSmartSpinner class="mb-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="!loading && myTeams.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_group.svg`"
|
||||
:alt="`${t('empty.teams')}`"
|
||||
:text="`${t('empty.teams')}`"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="`${t('team.create_new')}`"
|
||||
filled
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="grid gap-4"
|
||||
|
||||
@@ -15,12 +15,19 @@
|
||||
<HoppSmartSpinner class="mb-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
<div
|
||||
v-if="!loading && myTeams.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_group.svg`"
|
||||
:alt="`${t('empty.teams')}`"
|
||||
:text="`${t('empty.teams')}`"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4 text-secondaryLight"
|
||||
>
|
||||
<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
|
||||
:label="t('team.create_new')"
|
||||
filled
|
||||
@@ -28,7 +35,7 @@
|
||||
:icon="IconPlus"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="flex flex-col">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between py-2 pl-2 mb-2 -top-2 bg-popover"
|
||||
|
||||
@@ -40,8 +40,6 @@ import {
|
||||
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
|
||||
import xmlFormat from "xml-formatter"
|
||||
import { platform } from "~/platform"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { useDebounceFn } from "@vueuse/core"
|
||||
// TODO: Migrate from legacy mode
|
||||
|
||||
type ExtendedEditorConfig = {
|
||||
@@ -220,40 +218,6 @@ export function useCodemirror(
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
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 line = update.state.doc.lineAt(cursorPos)
|
||||
|
||||
@@ -312,7 +276,6 @@ export function useCodemirror(
|
||||
run: indentLess,
|
||||
},
|
||||
]),
|
||||
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
|
||||
]
|
||||
|
||||
if (environmentTooltip) extensions.push(environmentTooltip.extension)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { describe, expect, test } from "vitest"
|
||||
import { getEditorLangForMimeType } from "../editorutils"
|
||||
|
||||
describe("getEditorLangForMimeType", () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { describe, test, expect } from "vitest"
|
||||
import jsonParse from "../jsonParse"
|
||||
|
||||
describe("jsonParse", () => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { vi, beforeEach, describe, expect, test } from "vitest"
|
||||
import { getPlatformSpecialKey } from "../platformutils"
|
||||
|
||||
describe("getPlatformSpecialKey", () => {
|
||||
let platformGetter
|
||||
|
||||
beforeEach(() => {
|
||||
platformGetter = vi.spyOn(navigator, "platform", "get")
|
||||
platformGetter = jest.spyOn(navigator, "platform", "get")
|
||||
})
|
||||
|
||||
test("returns '⌘' for Apple platforms", () => {
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
* For example, sending a request.
|
||||
*/
|
||||
|
||||
import { Ref, onBeforeUnmount, onMounted, watch } from "vue"
|
||||
import { onBeforeUnmount, onMounted } from "vue"
|
||||
import { BehaviorSubject } from "rxjs"
|
||||
import { HoppRESTDocument } from "./rest/document"
|
||||
import { HoppGQLRequest } from "@hoppscotch/data"
|
||||
|
||||
export type HoppAction =
|
||||
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
|
||||
| "request.send-cancel" // Send/Cancel a Hoppscotch Request
|
||||
| "request.reset" // Clear request data
|
||||
| "request.copy-link" // Copy Request Link
|
||||
@@ -25,7 +22,6 @@ export type HoppAction =
|
||||
| "modals.search.toggle" // Shows the search modal
|
||||
| "modals.support.toggle" // Shows the support 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.team.environment.edit" // Edit current team environment
|
||||
| "navigation.jump.rest" // Jump to REST page
|
||||
@@ -42,9 +38,6 @@ export type HoppAction =
|
||||
| "response.file.download" // Download response as file
|
||||
| "response.copy" // Copy response to clipboard
|
||||
| "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
|
||||
@@ -57,14 +50,7 @@ export type HoppAction =
|
||||
* 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
|
||||
*/
|
||||
type HoppActionArgsMap = {
|
||||
"contextmenu.open": {
|
||||
position: {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
text: string | null
|
||||
}
|
||||
type HoppActionArgs = {
|
||||
"modals.my.environment.edit": {
|
||||
envName: string
|
||||
variableName: string
|
||||
@@ -73,22 +59,12 @@ type HoppActionArgsMap = {
|
||||
envName: 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
|
||||
*/
|
||||
export type HoppActionWithArgs = keyof HoppActionArgsMap
|
||||
type HoppActionWithArgs = keyof HoppActionArgs
|
||||
|
||||
/**
|
||||
* HoppActions which do not require arguments for their invocation
|
||||
@@ -98,27 +74,27 @@ export type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
|
||||
/**
|
||||
* Resolves the argument type for a given HoppAction
|
||||
*/
|
||||
type ArgOfHoppAction<A extends HoppAction | HoppActionWithArgs> =
|
||||
A extends HoppActionWithArgs ? HoppActionArgsMap[A] : undefined
|
||||
type ArgOfHoppAction<A extends HoppAction> = A extends HoppActionWithArgs
|
||||
? HoppActionArgs[A]
|
||||
: undefined
|
||||
|
||||
/**
|
||||
* Resolves the action function for a given HoppAction, used by action handler function defs
|
||||
*/
|
||||
type ActionFunc<A extends HoppAction | HoppActionWithArgs> =
|
||||
A extends HoppActionWithArgs ? (arg: ArgOfHoppAction<A>) => void : () => void
|
||||
type ActionFunc<A extends HoppAction> = A extends HoppActionWithArgs
|
||||
? (arg: ArgOfHoppAction<A>) => void
|
||||
: () => void
|
||||
|
||||
type BoundActionList = {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
[A in HoppAction | HoppActionWithArgs]?: Array<ActionFunc<A>>
|
||||
[A in HoppAction]?: Array<ActionFunc<A>>
|
||||
}
|
||||
|
||||
const boundActions: BoundActionList = {}
|
||||
|
||||
export const activeActions$ = new BehaviorSubject<
|
||||
(HoppAction | HoppActionWithArgs)[]
|
||||
>([])
|
||||
export const activeActions$ = new BehaviorSubject<HoppAction[]>([])
|
||||
|
||||
export function bindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||
export function bindAction<A extends HoppAction>(
|
||||
action: A,
|
||||
handler: ActionFunc<A>
|
||||
) {
|
||||
@@ -134,7 +110,7 @@ export function bindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||
|
||||
type InvokeActionFunc = {
|
||||
(action: HoppActionWithNoArgs, args?: undefined): void
|
||||
<A extends HoppActionWithArgs>(action: A, args: HoppActionArgsMap[A]): void
|
||||
<A extends HoppActionWithArgs>(action: A, args: ArgOfHoppAction<A>): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,16 +119,14 @@ type InvokeActionFunc = {
|
||||
* @param action The action to fire
|
||||
* @param args The argument passed to the action handler. Optional if action has no args required
|
||||
*/
|
||||
export const invokeAction: InvokeActionFunc = <
|
||||
A extends HoppAction | HoppActionWithArgs
|
||||
>(
|
||||
export const invokeAction: InvokeActionFunc = <A extends HoppAction>(
|
||||
action: A,
|
||||
args: ArgOfHoppAction<A>
|
||||
) => {
|
||||
boundActions[action]?.forEach((handler) => handler(args! as any))
|
||||
boundActions[action]?.forEach((handler) => handler(args!))
|
||||
}
|
||||
|
||||
export function unbindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||
export function unbindAction<A extends HoppAction>(
|
||||
action: A,
|
||||
handler: ActionFunc<A>
|
||||
) {
|
||||
@@ -168,57 +142,15 @@ export function unbindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||
activeActions$.next(Object.keys(boundActions) as 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>(
|
||||
export function defineActionHandler<A extends HoppAction>(
|
||||
action: A,
|
||||
handler: ActionFunc<A>,
|
||||
isActive: Ref<boolean> | undefined = undefined
|
||||
handler: ActionFunc<A>
|
||||
) {
|
||||
let mounted = false
|
||||
let bound = false
|
||||
|
||||
onMounted(() => {
|
||||
mounted = true
|
||||
|
||||
// Only bind if isActive is undefined or true
|
||||
if (isActive === undefined || isActive.value === true) {
|
||||
bound = true
|
||||
bindAction(action, handler)
|
||||
}
|
||||
bindAction(action, handler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false
|
||||
bound = false
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @ts-check
|
||||
// ^^^ Enables Type Checking by the TypeScript compiler
|
||||
|
||||
import { describe, expect, test } from "vitest"
|
||||
import { makeRESTRequest, rawKeyValueEntriesToString } from "@hoppscotch/data"
|
||||
import { parseCurlToHoppRESTReq } from ".."
|
||||
|
||||
@@ -16,7 +15,7 @@ const samples = [
|
||||
`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://echo.hoppscotch.io/",
|
||||
auth: { authType: "none", authActive: true },
|
||||
body: {
|
||||
@@ -56,7 +55,7 @@ const samples = [
|
||||
`,
|
||||
response: makeRESTRequest({
|
||||
method: "PUT",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://127.0.0.1:8000/api/admin/crm/brand/4",
|
||||
auth: {
|
||||
authType: "basic",
|
||||
@@ -147,7 +146,7 @@ const samples = [
|
||||
command: `curl google.com`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://google.com/",
|
||||
auth: { authType: "none", authActive: true },
|
||||
body: {
|
||||
@@ -164,7 +163,7 @@ const samples = [
|
||||
command: `curl -X POST -d '{"foo":"bar"}' http://localhost:1111/hello/world/?bar=baz&buzz`,
|
||||
response: makeRESTRequest({
|
||||
method: "POST",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://localhost:1111/hello/world/?buzz",
|
||||
auth: { authType: "none", authActive: true },
|
||||
body: {
|
||||
@@ -187,7 +186,7 @@ const samples = [
|
||||
command: `curl --get -d "tool=curl" -d "age=old" https://example.com`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://example.com/",
|
||||
auth: { authType: "none", authActive: true },
|
||||
body: {
|
||||
@@ -215,7 +214,7 @@ const samples = [
|
||||
command: `curl -F hello=hello2 -F hello3=@hello4.txt bing.com`,
|
||||
response: makeRESTRequest({
|
||||
method: "POST",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://bing.com/",
|
||||
auth: { authType: "none", authActive: true },
|
||||
body: {
|
||||
@@ -246,7 +245,7 @@ const samples = [
|
||||
"curl -X GET localhost -H 'Accept: application/json' --user root:toor",
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://localhost/",
|
||||
auth: {
|
||||
authType: "basic",
|
||||
@@ -275,7 +274,7 @@ const samples = [
|
||||
"curl -X GET localhost --header 'Authorization: Basic dXNlcjpwYXNz'",
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://localhost/",
|
||||
auth: {
|
||||
authType: "basic",
|
||||
@@ -298,7 +297,7 @@ const samples = [
|
||||
"curl -X GET localhost:9900 --header 'Authorization: Basic 77898dXNlcjpwYXNz'",
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://localhost:9900/",
|
||||
auth: {
|
||||
authType: "none",
|
||||
@@ -319,7 +318,7 @@ const samples = [
|
||||
"curl -X GET localhost --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'",
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://localhost/",
|
||||
auth: {
|
||||
authType: "bearer",
|
||||
@@ -341,7 +340,7 @@ const samples = [
|
||||
command: `curl --get -I -d "tool=curl" -d "platform=hoppscotch" -d"io" https://hoppscotch.io`,
|
||||
response: makeRESTRequest({
|
||||
method: "HEAD",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://hoppscotch.io/?io",
|
||||
auth: {
|
||||
authActive: true,
|
||||
@@ -376,7 +375,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'`,
|
||||
response: makeRESTRequest({
|
||||
method: "POST",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://someshadywebsite.com/questionable/path/?so",
|
||||
auth: {
|
||||
authActive: true,
|
||||
@@ -437,7 +436,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'",
|
||||
response: makeRESTRequest({
|
||||
method: "POST",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://localhost/",
|
||||
auth: {
|
||||
authActive: true,
|
||||
@@ -471,7 +470,7 @@ const samples = [
|
||||
--compressed`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://hoppscotch.io/",
|
||||
auth: { authType: "none", authActive: true },
|
||||
body: {
|
||||
@@ -526,7 +525,7 @@ const samples = [
|
||||
--data c=d`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://echo.hoppscotch.io/",
|
||||
auth: { authType: "none", authActive: true },
|
||||
body: {
|
||||
@@ -570,7 +569,7 @@ const samples = [
|
||||
--form a=b \
|
||||
--form c=d`,
|
||||
response: makeRESTRequest({
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://echo.hoppscotch.io/",
|
||||
method: "POST",
|
||||
auth: { authType: "none", authActive: true },
|
||||
@@ -612,7 +611,7 @@ const samples = [
|
||||
{
|
||||
command: "curl 'muxueqz.top/skybook.html'",
|
||||
response: makeRESTRequest({
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://muxueqz.top/skybook.html",
|
||||
method: "GET",
|
||||
auth: { authType: "none", authActive: true },
|
||||
@@ -626,7 +625,7 @@ const samples = [
|
||||
{
|
||||
command: "curl -F abcd=efghi",
|
||||
response: makeRESTRequest({
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://echo.hoppscotch.io/",
|
||||
method: "POST",
|
||||
auth: { authType: "none", authActive: true },
|
||||
@@ -650,7 +649,7 @@ const samples = [
|
||||
{
|
||||
command: "curl 127.0.0.1 -X custommethod",
|
||||
response: makeRESTRequest({
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://127.0.0.1/",
|
||||
method: "CUSTOMMETHOD",
|
||||
auth: { authType: "none", authActive: true },
|
||||
@@ -667,7 +666,7 @@ const samples = [
|
||||
{
|
||||
command: "curl echo.hoppscotch.io -A pinephone",
|
||||
response: makeRESTRequest({
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://echo.hoppscotch.io/",
|
||||
method: "GET",
|
||||
auth: { authType: "none", authActive: true },
|
||||
@@ -690,7 +689,7 @@ const samples = [
|
||||
{
|
||||
command: "curl echo.hoppscotch.io -G",
|
||||
response: makeRESTRequest({
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://echo.hoppscotch.io/",
|
||||
method: "GET",
|
||||
auth: { authType: "none", authActive: true },
|
||||
@@ -707,7 +706,7 @@ const samples = [
|
||||
{
|
||||
command: `curl --get -I -d "tool=hopp" https://example.org`,
|
||||
response: makeRESTRequest({
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://example.org/",
|
||||
method: "HEAD",
|
||||
auth: { authType: "none", authActive: true },
|
||||
@@ -731,7 +730,7 @@ const samples = [
|
||||
command: `curl google.com -u userx`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://google.com/",
|
||||
auth: {
|
||||
authType: "basic",
|
||||
@@ -753,7 +752,7 @@ const samples = [
|
||||
command: `curl google.com -H "Authorization"`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://google.com/",
|
||||
auth: {
|
||||
authType: "none",
|
||||
@@ -774,7 +773,7 @@ const samples = [
|
||||
google.com -H "content-type: application/json"`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://google.com/",
|
||||
auth: {
|
||||
authType: "none",
|
||||
@@ -794,7 +793,7 @@ const samples = [
|
||||
command: `curl 192.168.0.24:8080/ping`,
|
||||
response: makeRESTRequest({
|
||||
method: "GET",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "http://192.168.0.24:8080/ping",
|
||||
auth: {
|
||||
authType: "none",
|
||||
@@ -814,7 +813,7 @@ const samples = [
|
||||
command: `curl https://example.com -d "alpha=beta&request_id=4"`,
|
||||
response: makeRESTRequest({
|
||||
method: "POST",
|
||||
name: "Untitled",
|
||||
name: "Untitled request",
|
||||
endpoint: "https://example.com/",
|
||||
auth: {
|
||||
authType: "none",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user